mirror of
https://github.com/moonshadowrev/PersonalAccounter.git
synced 2025-12-17 22:44:19 -06:00
[Init]: v1.0.0
This commit is contained in:
114
.dockerignore
Normal file
114
.dockerignore
Normal file
@@ -0,0 +1,114 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-swarm.yml
|
||||
.dockerignore
|
||||
docker/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
README-Docker.md
|
||||
CONTRIBUTING.md
|
||||
SECURITY.md
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
sessions/
|
||||
public/uploads/
|
||||
vendor/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Node modules (if any)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
*.cache
|
||||
|
||||
# Test files
|
||||
tests/
|
||||
test/
|
||||
*.test.php
|
||||
phpunit.xml
|
||||
.phpunit.result.cache
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.sql
|
||||
|
||||
# Archives
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
|
||||
# IDE specific files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.sublime-*
|
||||
*.code-workspace
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Project specific excludes
|
||||
setup.sh
|
||||
cron-example.md
|
||||
43
.env.example
Normal file
43
.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# Environment Configuration
|
||||
APP_ENV=development
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_DOMAIN=localhost
|
||||
APP_TIMEZONE=UTC
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=database
|
||||
DB_NAME=accounting_panel
|
||||
DB_USER=accounting_user
|
||||
DB_PASS=user_password_123
|
||||
DB_PORT=3306
|
||||
DB_ROOT_PASSWORD=root_password_123
|
||||
|
||||
# Session Configuration
|
||||
SESSION_LIFETIME=0
|
||||
SESSION_SECURE=false
|
||||
SESSION_SAMESITE=Lax
|
||||
|
||||
# Authentication Configuration
|
||||
LOGIN_ATTEMPTS_LIMIT=5
|
||||
LOGIN_ATTEMPTS_TIMEOUT=300
|
||||
|
||||
# API Configuration
|
||||
API_MAX_FAILED_ATTEMPTS=5
|
||||
API_BLOCK_DURATION=300
|
||||
API_DEFAULT_RATE_LIMIT=60
|
||||
API_MAX_RATE_LIMIT=1000
|
||||
|
||||
# Logging Configuration
|
||||
LOG_CHANNEL=file
|
||||
LOG_LEVEL=warning
|
||||
LOG_MAX_FILES=5
|
||||
|
||||
# Admin User Configuration (for automated setup)
|
||||
ADMIN_NAME=Admin
|
||||
ADMIN_EMAIL=admin@localhost
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# Docker Configuration
|
||||
COMPOSE_PROJECT_NAME=accounting_panel
|
||||
COMPOSE_FILE=docker-compose.yml
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
115
.github/dependabot.yml
vendored
Normal file
115
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for Composer
|
||||
- package-ecosystem: "composer"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
# Increase the number of open pull requests for Composer
|
||||
open-pull-requests-limit: 10
|
||||
# Allow both direct and indirect updates
|
||||
allow:
|
||||
- dependency-type: "direct"
|
||||
- dependency-type: "indirect"
|
||||
# Group security updates
|
||||
groups:
|
||||
security-updates:
|
||||
applies-to: security-updates
|
||||
patterns:
|
||||
- "*"
|
||||
minor-updates:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
# Reviewers for dependency updates
|
||||
reviewers:
|
||||
- "moonshadowrev"
|
||||
# Assignees for dependency updates
|
||||
assignees:
|
||||
- "moonshadowrev"
|
||||
# Commit message prefix
|
||||
commit-message:
|
||||
prefix: "composer"
|
||||
prefix-development: "composer-dev"
|
||||
include: "scope"
|
||||
# Labels to apply to pull requests
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "composer"
|
||||
- "automated"
|
||||
# Branch to target for pull requests
|
||||
target-branch: "main"
|
||||
# Rebase strategy
|
||||
rebase-strategy: "auto"
|
||||
# Ignore specific dependencies if needed
|
||||
ignore:
|
||||
# Ignore major version updates for critical dependencies
|
||||
- dependency-name: "php"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# Example: ignore a specific package
|
||||
# - dependency-name: "vendor/package-name"
|
||||
# versions: ["1.x", "2.x"]
|
||||
# Milestone to add to pull requests
|
||||
# milestone: 1
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
- "automated"
|
||||
reviewers:
|
||||
- "moonshadowrev"
|
||||
assignees:
|
||||
- "moonshadowrev"
|
||||
|
||||
# Enable version updates for Docker Compose
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "tuesday"
|
||||
time: "10:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 3
|
||||
commit-message:
|
||||
prefix: "docker"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
- "automated"
|
||||
|
||||
# Enable version updates for GitHub Actions (if you add any)
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "wednesday"
|
||||
time: "09:00"
|
||||
timezone: "UTC"
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: "github-actions"
|
||||
include: "scope"
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
- "automated"
|
||||
39
.github/workflows/php.yml
vendored
Normal file
39
.github/workflows/php.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: PHP Composer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate composer.json and composer.lock
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Cache Composer packages
|
||||
id: composer-cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-php-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
# Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit"
|
||||
# Docs: https://getcomposer.org/doc/articles/scripts.md
|
||||
|
||||
# - name: Run test suite
|
||||
# run: composer run-script test
|
||||
153
.gitignore
vendored
Normal file
153
.gitignore
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
# Environment files (contains sensitive data)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
.env.staging
|
||||
|
||||
# Logs and runtime data
|
||||
logs/
|
||||
*.log
|
||||
sessions/
|
||||
public/uploads/
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Composer dependencies
|
||||
vendor/
|
||||
|
||||
# IDE and editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.sublime-*
|
||||
*.code-workspace
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Docker runtime files
|
||||
docker-compose.override.yml
|
||||
.docker/
|
||||
|
||||
# Database files and backups
|
||||
*.sql
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
backups/
|
||||
database/backups/
|
||||
|
||||
# Cache and temporary files
|
||||
cache/
|
||||
.cache/
|
||||
*.cache
|
||||
.tmp/
|
||||
|
||||
# Node modules (if any frontend build tools are used)
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm
|
||||
.yarn/
|
||||
|
||||
# PHP specific
|
||||
*.php~
|
||||
.phpunit.result.cache
|
||||
phpunit.xml
|
||||
phpunit.xml.dist
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
clover.xml
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Archives
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
*.7z
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.orig
|
||||
|
||||
# Configuration files that might contain sensitive data
|
||||
config/database.php
|
||||
config/mail.php
|
||||
config/app.local.php
|
||||
|
||||
# Test files
|
||||
tests/temp/
|
||||
tests/coverage/
|
||||
|
||||
# Docker volumes data (if mounted locally)
|
||||
db_data/
|
||||
app_logs/
|
||||
app_sessions/
|
||||
app_uploads/
|
||||
|
||||
# SSL certificates
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.csr
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# API keys and secrets
|
||||
secrets/
|
||||
.secrets/
|
||||
|
||||
# Local development files
|
||||
.local/
|
||||
local/
|
||||
|
||||
# Error logs
|
||||
error_log
|
||||
php_errors.log
|
||||
|
||||
# Session files
|
||||
sess_*
|
||||
|
||||
# Temporary upload files
|
||||
upload_tmp/
|
||||
|
||||
# Generated documentation
|
||||
docs/generated/
|
||||
api-docs/
|
||||
|
||||
# Development tools
|
||||
.php_cs.cache
|
||||
.phpstan.cache
|
||||
.psalm/cache/
|
||||
|
||||
# Deployment scripts (if they contain sensitive info)
|
||||
deploy.sh
|
||||
deploy/
|
||||
|
||||
# Local Docker overrides
|
||||
docker-compose.local.yml
|
||||
docker-compose.dev.yml
|
||||
|
||||
# Git patches
|
||||
*.patch
|
||||
*.diff
|
||||
|
||||
# Runtime configuration
|
||||
runtime/
|
||||
15
.htaccess
Normal file
15
.htaccess
Normal file
@@ -0,0 +1,15 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# Send requests to the public directory
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} !^/public/
|
||||
RewriteRule ^(.*)$ public/$1 [L,QSA]
|
||||
|
||||
# Handle requests inside the public directory
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^public/(.*)$ public/index.php [L,QSA]
|
||||
</IfModule>
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
info@overlord.team.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
671
CONTRIBUTING.md
Normal file
671
CONTRIBUTING.md
Normal file
@@ -0,0 +1,671 @@
|
||||
# Contributing to PersonalAccounter
|
||||
|
||||
We welcome contributions to PersonalAccounter! This guide will help you get started with contributing to the project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Code of Conduct](#code-of-conduct)
|
||||
2. [Getting Started](#getting-started)
|
||||
3. [Development Setup](#development-setup)
|
||||
4. [Contributing Process](#contributing-process)
|
||||
5. [Coding Standards](#coding-standards)
|
||||
6. [Testing Guidelines](#testing-guidelines)
|
||||
7. [Documentation Guidelines](#documentation-guidelines)
|
||||
8. [Commit Message Guidelines](#commit-message-guidelines)
|
||||
9. [Pull Request Process](#pull-request-process)
|
||||
10. [Issue Reporting](#issue-reporting)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
By participating in this project, you agree to abide by our Code of Conduct:
|
||||
|
||||
- **Be respectful** and inclusive of all contributors
|
||||
- **Be collaborative** and help others learn and grow
|
||||
- **Be patient** with questions and different skill levels
|
||||
- **Be constructive** in feedback and criticism
|
||||
- **Focus on the code**, not the person
|
||||
|
||||
### Unacceptable Behavior
|
||||
|
||||
- Harassment, discrimination, or offensive language
|
||||
- Personal attacks or inflammatory comments
|
||||
- Spam or off-topic discussions
|
||||
- Sharing private information without permission
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before contributing, ensure you have:
|
||||
|
||||
- **PHP 8.0+** installed
|
||||
- **MySQL 5.7+** or **MariaDB 10.3+**
|
||||
- **Composer** for dependency management
|
||||
- **Git** for version control
|
||||
- **Node.js** (optional, for frontend development)
|
||||
|
||||
### First Contribution
|
||||
|
||||
If this is your first contribution:
|
||||
|
||||
1. **Look for issues** labeled `good first issue` or `help wanted`
|
||||
2. **Read the documentation** thoroughly
|
||||
3. **Start small** with bug fixes or documentation improvements
|
||||
4. **Ask questions** if anything is unclear
|
||||
|
||||
## Development Setup
|
||||
|
||||
### 1. Fork and Clone
|
||||
|
||||
```bash
|
||||
# Fork the repository on GitHub, then clone your fork
|
||||
git clone https://github.com/your-username/PersonalAccounter.git
|
||||
cd PersonalAccounter
|
||||
|
||||
# Add the original repository as upstream
|
||||
git remote add upstream https://github.com/original-repo/PersonalAccounter.git
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install PHP dependencies
|
||||
composer install
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 3. Database Setup
|
||||
|
||||
```bash
|
||||
# Create database
|
||||
mysql -u root -p
|
||||
CREATE DATABASE personal_accounter_dev;
|
||||
exit
|
||||
|
||||
# Update .env file with your database credentials
|
||||
# Run migrations
|
||||
php control migrate run
|
||||
|
||||
# Seed with test data
|
||||
php control db seed
|
||||
```
|
||||
|
||||
### 4. Development Environment
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
php -S localhost:8000 -t public/
|
||||
|
||||
# Or use your preferred web server (Apache/Nginx)
|
||||
```
|
||||
|
||||
### 5. Verify Installation
|
||||
|
||||
Visit `http://localhost:8000` and ensure:
|
||||
- ✅ Application loads without errors
|
||||
- ✅ Database connection works
|
||||
- ✅ You can log in with seeded user
|
||||
- ✅ Basic functionality works
|
||||
|
||||
## Contributing Process
|
||||
|
||||
### 1. Create a Branch
|
||||
|
||||
```bash
|
||||
# Update your fork
|
||||
git fetch upstream
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
|
||||
# Create feature branch
|
||||
git checkout -b feature/your-feature-name
|
||||
# or
|
||||
git checkout -b bugfix/issue-number-description
|
||||
```
|
||||
|
||||
### 2. Make Changes
|
||||
|
||||
- **Keep changes focused** on a single feature or bug fix
|
||||
- **Write tests** for new functionality
|
||||
- **Update documentation** as needed
|
||||
- **Follow coding standards** (see below)
|
||||
|
||||
### 3. Test Your Changes
|
||||
|
||||
```bash
|
||||
# Run manual tests
|
||||
php control test
|
||||
|
||||
# Check for syntax errors
|
||||
find . -name "*.php" -exec php -l {} \;
|
||||
|
||||
# Test database migrations
|
||||
php control migrate fresh
|
||||
```
|
||||
|
||||
### 4. Commit and Push
|
||||
|
||||
```bash
|
||||
# Stage your changes
|
||||
git add .
|
||||
|
||||
# Commit with descriptive message
|
||||
git commit -m "feat: add expense category filtering"
|
||||
|
||||
# Push to your fork
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### PHP Standards
|
||||
|
||||
#### **PSR Standards**
|
||||
- Follow **PSR-12** coding style
|
||||
- Use **PSR-4** autoloading conventions
|
||||
- Implement **PSR-3** logging interfaces where applicable
|
||||
|
||||
#### **Naming Conventions**
|
||||
|
||||
```php
|
||||
// Classes: PascalCase
|
||||
class ExpenseController extends Controller
|
||||
|
||||
// Methods and variables: camelCase
|
||||
public function createExpense($expenseData)
|
||||
|
||||
// Constants: UPPER_SNAKE_CASE
|
||||
const MAX_FILE_SIZE = 5242880;
|
||||
|
||||
// Database tables: snake_case
|
||||
$table = 'expense_categories';
|
||||
```
|
||||
|
||||
#### **Code Structure**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
// 1. Namespace declaration
|
||||
namespace App\Controllers;
|
||||
|
||||
// 2. Use statements (grouped and sorted)
|
||||
use App\Models\Expense;
|
||||
use App\Services\Logger;
|
||||
use Exception;
|
||||
|
||||
// 3. Class declaration
|
||||
class ExpenseController extends Controller
|
||||
{
|
||||
// 4. Properties (visibility order: public, protected, private)
|
||||
private $expenseModel;
|
||||
|
||||
// 5. Constructor
|
||||
public function __construct($database)
|
||||
{
|
||||
$this->expenseModel = new Expense($database);
|
||||
}
|
||||
|
||||
// 6. Methods (public first, then protected, then private)
|
||||
public function index()
|
||||
{
|
||||
// Implementation
|
||||
}
|
||||
|
||||
private function validateExpenseData($data)
|
||||
{
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **Documentation Standards**
|
||||
|
||||
```php
|
||||
/**
|
||||
* Create a new expense record
|
||||
*
|
||||
* @param array $expenseData The expense data to create
|
||||
* @param int $userId The ID of the user creating the expense
|
||||
* @return int|false The created expense ID or false on failure
|
||||
* @throws InvalidArgumentException When expense data is invalid
|
||||
* @throws DatabaseException When database operation fails
|
||||
*/
|
||||
public function createExpense(array $expenseData, int $userId)
|
||||
{
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Database Standards
|
||||
|
||||
#### **Migration Guidelines**
|
||||
|
||||
```php
|
||||
class CreateExpensesTable extends Migration
|
||||
{
|
||||
public function getName()
|
||||
{
|
||||
return '010_create_expenses_table';
|
||||
}
|
||||
|
||||
public function up()
|
||||
{
|
||||
$this->createTable('expenses', function($table) {
|
||||
// Primary key first
|
||||
$table->id();
|
||||
|
||||
// Foreign keys
|
||||
$table->integer('user_id')->index();
|
||||
$table->integer('category_id')->nullable()->index();
|
||||
|
||||
// Required fields
|
||||
$table->string('title');
|
||||
$table->decimal('amount', 12, 2);
|
||||
|
||||
// Optional fields
|
||||
$table->text('description')->nullable();
|
||||
|
||||
// Timestamps last
|
||||
$table->timestamps();
|
||||
|
||||
// Foreign key constraints
|
||||
$table->foreign('user_id', 'users', 'id', 'CASCADE');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
$this->dropTable('expenses');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **Query Guidelines**
|
||||
|
||||
```php
|
||||
// ✅ Good: Use prepared statements
|
||||
$expenses = $this->db->select('expenses', '*', [
|
||||
'user_id' => $userId,
|
||||
'created_at[>=]' => $fromDate
|
||||
]);
|
||||
|
||||
// ❌ Bad: String concatenation
|
||||
$query = "SELECT * FROM expenses WHERE user_id = " . $userId;
|
||||
|
||||
// ✅ Good: Use meaningful variable names
|
||||
$userExpenses = $this->expenseModel->getByUserId($userId);
|
||||
|
||||
// ❌ Bad: Unclear variable names
|
||||
$data = $this->model->get($id);
|
||||
```
|
||||
|
||||
### Frontend Standards
|
||||
|
||||
#### **JavaScript Guidelines**
|
||||
|
||||
```javascript
|
||||
// Use ES6+ features
|
||||
const createExpenseChart = (data) => {
|
||||
const canvas = document.getElementById('expense-chart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: data,
|
||||
options: {
|
||||
responsive: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Use meaningful function names
|
||||
function validateExpenseForm(formData) {
|
||||
const errors = [];
|
||||
|
||||
if (!formData.title) {
|
||||
errors.push('Title is required');
|
||||
}
|
||||
|
||||
if (formData.amount <= 0) {
|
||||
errors.push('Amount must be positive');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
```
|
||||
|
||||
#### **CSS Guidelines**
|
||||
|
||||
```css
|
||||
/* Use BEM naming convention */
|
||||
.expense-form {
|
||||
/* Block */
|
||||
}
|
||||
|
||||
.expense-form__input {
|
||||
/* Element */
|
||||
}
|
||||
|
||||
.expense-form__input--error {
|
||||
/* Modifier */
|
||||
}
|
||||
|
||||
/* Use consistent spacing */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Use CSS custom properties for themes */
|
||||
:root {
|
||||
--primary-color: #3B82F6;
|
||||
--success-color: #10B981;
|
||||
--error-color: #EF4444;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Manual Testing
|
||||
|
||||
Before submitting:
|
||||
|
||||
1. **Test core functionality**
|
||||
- Create, read, update, delete operations
|
||||
- User authentication and authorization
|
||||
- API endpoints
|
||||
|
||||
2. **Test edge cases**
|
||||
- Invalid input data
|
||||
- Large datasets
|
||||
- Concurrent operations
|
||||
|
||||
3. **Cross-browser testing**
|
||||
- Chrome, Firefox, Safari
|
||||
- Mobile responsiveness
|
||||
|
||||
### Automated Testing
|
||||
|
||||
#### **Unit Tests** (Coming Soon)
|
||||
|
||||
```php
|
||||
class ExpenseTest extends TestCase
|
||||
{
|
||||
public function testCreateExpense()
|
||||
{
|
||||
$expense = new Expense($this->database);
|
||||
|
||||
$data = [
|
||||
'title' => 'Test Expense',
|
||||
'amount' => 100.50,
|
||||
'user_id' => 1
|
||||
];
|
||||
|
||||
$result = $expense->create($data);
|
||||
|
||||
$this->assertIsInt($result);
|
||||
$this->assertGreaterThan(0, $result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### **API Tests**
|
||||
|
||||
```bash
|
||||
# Test API endpoints
|
||||
curl -X GET \
|
||||
'http://localhost:8000/api/v1/expenses' \
|
||||
-H 'X-API-Key: your-test-key' \
|
||||
-H 'Content-Type: application/json'
|
||||
```
|
||||
|
||||
## Documentation Guidelines
|
||||
|
||||
### Code Documentation
|
||||
|
||||
- **Document all public methods** with PHPDoc
|
||||
- **Explain complex logic** with inline comments
|
||||
- **Include examples** for non-obvious usage
|
||||
- **Keep documentation up-to-date** with code changes
|
||||
|
||||
### Feature Documentation
|
||||
|
||||
When adding new features:
|
||||
|
||||
1. **Update the Feature Wiki** (`docs/FEATURES.md`)
|
||||
2. **Add API documentation** if creating new endpoints
|
||||
3. **Update README** if changing installation or usage
|
||||
4. **Add examples** showing how to use the feature
|
||||
|
||||
### API Documentation
|
||||
|
||||
```php
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/expenses",
|
||||
* summary="Create a new expense",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"title", "amount", "user_id"},
|
||||
* @OA\Property(property="title", type="string"),
|
||||
* @OA\Property(property="amount", type="number", format="float"),
|
||||
* @OA\Property(property="user_id", type="integer")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Expense created successfully"
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
```
|
||||
|
||||
## Commit Message Guidelines
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- **feat**: New feature
|
||||
- **fix**: Bug fix
|
||||
- **docs**: Documentation changes
|
||||
- **style**: Code style changes (formatting, etc.)
|
||||
- **refactor**: Code refactoring
|
||||
- **test**: Adding or updating tests
|
||||
- **chore**: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Good commit messages
|
||||
git commit -m "feat(expenses): add category filtering to expense list"
|
||||
git commit -m "fix(api): resolve rate limiting bug for high-volume users"
|
||||
git commit -m "docs(readme): update installation instructions for PHP 8.1"
|
||||
|
||||
# Detailed commit with body
|
||||
git commit -m "feat(dashboard): add real-time expense analytics
|
||||
|
||||
- Implement WebSocket connection for live updates
|
||||
- Add Chart.js integration for visual analytics
|
||||
- Include date range filtering for analytics
|
||||
|
||||
Closes #123"
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Submitting
|
||||
|
||||
- [ ] **Code follows** style guidelines
|
||||
- [ ] **Tests pass** (manual and automated)
|
||||
- [ ] **Documentation updated** as needed
|
||||
- [ ] **Commit messages** follow guidelines
|
||||
- [ ] **Branch is up-to-date** with main
|
||||
|
||||
### PR Template
|
||||
|
||||
```markdown
|
||||
## Description
|
||||
Brief description of changes made.
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Documentation update
|
||||
- [ ] Refactoring
|
||||
|
||||
## Testing
|
||||
- [ ] Manual testing completed
|
||||
- [ ] Automated tests pass
|
||||
- [ ] Cross-browser testing (if applicable)
|
||||
|
||||
## Checklist
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Self-review completed
|
||||
- [ ] Documentation updated
|
||||
- [ ] No breaking changes (or documented)
|
||||
|
||||
## Screenshots (if applicable)
|
||||
Add screenshots for UI changes.
|
||||
|
||||
## Related Issues
|
||||
Closes #123
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
1. **Automated checks** must pass
|
||||
2. **At least one approval** from maintainer required
|
||||
3. **Address feedback** promptly and respectfully
|
||||
4. **Maintainer merges** after approval
|
||||
|
||||
### After Merge
|
||||
|
||||
- **Delete feature branch** from your fork
|
||||
- **Update your local main** branch
|
||||
- **Thank reviewers** for their time
|
||||
|
||||
## Issue Reporting
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Use this template for bug reports:
|
||||
|
||||
```markdown
|
||||
**Bug Description**
|
||||
Clear description of what the bug is.
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected Behavior**
|
||||
What you expected to happen.
|
||||
|
||||
**Actual Behavior**
|
||||
What actually happened.
|
||||
|
||||
**Environment**
|
||||
- OS: [e.g. Ubuntu 20.04]
|
||||
- PHP Version: [e.g. 8.1]
|
||||
- Browser: [e.g. Chrome 96]
|
||||
|
||||
**Additional Context**
|
||||
Add any other context about the problem.
|
||||
```
|
||||
|
||||
### Feature Requests
|
||||
|
||||
```markdown
|
||||
**Feature Description**
|
||||
Clear description of the feature you'd like to see.
|
||||
|
||||
**Problem Statement**
|
||||
What problem does this feature solve?
|
||||
|
||||
**Proposed Solution**
|
||||
Describe your preferred solution.
|
||||
|
||||
**Alternatives Considered**
|
||||
Alternative solutions you've considered.
|
||||
|
||||
**Additional Context**
|
||||
Any other context or screenshots.
|
||||
```
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Debugging
|
||||
|
||||
```php
|
||||
// Use AppLogger for debugging
|
||||
AppLogger::debug('Expense data', ['data' => $expenseData]);
|
||||
|
||||
// Environment-specific debugging
|
||||
if (Config::get('debug')) {
|
||||
var_dump($variable);
|
||||
}
|
||||
```
|
||||
|
||||
### Performance
|
||||
|
||||
- **Use database indexes** for frequently queried columns
|
||||
- **Implement pagination** for large datasets
|
||||
- **Cache expensive operations** when appropriate
|
||||
- **Optimize database queries** to avoid N+1 problems
|
||||
|
||||
### Security
|
||||
|
||||
- **Validate all input** data
|
||||
- **Use prepared statements** for database queries
|
||||
- **Implement proper authentication** checks
|
||||
- **Follow OWASP guidelines** for web security
|
||||
|
||||
## Getting Help
|
||||
|
||||
### Resources
|
||||
|
||||
- **Documentation**: Check `docs/` directory
|
||||
- **API Docs**: Available at `/api/docs/ui` (development mode)
|
||||
- **Examples**: Look at existing code for patterns
|
||||
|
||||
### Communication
|
||||
|
||||
- **GitHub Issues**: For bugs and feature requests
|
||||
- **GitHub Discussions**: For questions and general discussion
|
||||
- **Email**: maintainers@your-domain.com for urgent matters
|
||||
|
||||
### Response Times
|
||||
|
||||
- **Issues**: We aim to respond within 48 hours
|
||||
- **Pull Requests**: Reviews typically within 72 hours
|
||||
- **Security Issues**: Response within 24 hours
|
||||
|
||||
## Recognition
|
||||
|
||||
We appreciate all contributions! Contributors are recognized in:
|
||||
|
||||
- **CHANGELOG.md**: Major contributions listed in releases
|
||||
- **README.md**: Top contributors section
|
||||
- **GitHub**: Automatic contribution tracking
|
||||
|
||||
Thank you for contributing to PersonalAccounter! 🎉
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Don't hesitate to ask! We're here to help you contribute successfully.
|
||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
FROM php:8.2-fpm
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git \
|
||||
curl \
|
||||
libpng-dev \
|
||||
libonig-dev \
|
||||
libxml2-dev \
|
||||
zip \
|
||||
unzip \
|
||||
libzip-dev \
|
||||
mariadb-client \
|
||||
cron \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install \
|
||||
pdo_mysql \
|
||||
mbstring \
|
||||
xml \
|
||||
zip \
|
||||
gd \
|
||||
opcache
|
||||
|
||||
# Install Composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
# Create application directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy composer files first for better caching
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
# Install PHP dependencies
|
||||
RUN composer install --no-dev --optimize-autoloader --no-scripts
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Set proper permissions
|
||||
RUN chown -R www-data:www-data /var/www/html \
|
||||
&& chmod -R 755 /var/www/html \
|
||||
&& chmod -R 777 /var/www/html/logs \
|
||||
&& chmod -R 777 /var/www/html/sessions \
|
||||
&& chmod -R 777 /var/www/html/public/uploads
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p logs sessions public/uploads
|
||||
|
||||
# Copy PHP configuration
|
||||
COPY docker/php/php.ini /usr/local/etc/php/conf.d/custom.ini
|
||||
|
||||
# Expose port 9000
|
||||
EXPOSE 9000
|
||||
|
||||
# Start PHP-FPM
|
||||
CMD ["php-fpm"]
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
414
README-Docker.md
Normal file
414
README-Docker.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# Accounting Panel - Docker Setup
|
||||
|
||||
This document provides comprehensive instructions for setting up and running the Accounting Panel using Docker.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Automated Setup (Recommended)
|
||||
|
||||
The easiest way to get started is using the automated setup script:
|
||||
|
||||
```bash
|
||||
# Make the script executable
|
||||
chmod +x setup.sh
|
||||
|
||||
# Run the automated setup
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Check and install Docker if needed
|
||||
- Set up the environment with secure passwords
|
||||
- Build and deploy all services
|
||||
- Initialize the database and create an admin user
|
||||
- Run health checks
|
||||
|
||||
### Manual Setup
|
||||
|
||||
If you prefer to set up manually:
|
||||
|
||||
```bash
|
||||
# 1. Copy and configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your preferred settings
|
||||
|
||||
# 2. Build and start services
|
||||
docker-compose up -d --build
|
||||
|
||||
# 3. Run database migrations
|
||||
docker-compose exec app php control migrate run
|
||||
|
||||
# 4. Create admin user
|
||||
docker-compose exec app php control user admin
|
||||
```
|
||||
|
||||
## 📋 Services
|
||||
|
||||
The Docker setup includes the following services:
|
||||
|
||||
### 🐘 MariaDB Database
|
||||
- **Container**: `accounting_panel_db`
|
||||
- **Port**: `3306`
|
||||
- **Version**: MariaDB 10.11
|
||||
- **Data**: Persisted in `db_data` volume
|
||||
|
||||
### 🐘 PHP Application
|
||||
- **Container**: `accounting_panel_app`
|
||||
- **Framework**: PHP 8.2-FPM
|
||||
- **Extensions**: PDO MySQL, GD, ZIP, OpenSSL, etc.
|
||||
- **Data**: Logs, sessions, uploads persisted in volumes
|
||||
|
||||
### 🌐 Caddy Web Server
|
||||
- **Container**: `accounting_panel_caddy`
|
||||
- **Ports**: `80` (HTTP), `443` (HTTPS)
|
||||
- **Features**: Automatic HTTPS, security headers, compression
|
||||
- **Configuration**: `docker/caddy/Caddyfile`
|
||||
|
||||
### 🔧 phpMyAdmin
|
||||
- **Container**: `accounting_panel_phpmyadmin`
|
||||
- **Port**: `8080`
|
||||
- **Access**: `http://localhost:8080`
|
||||
- **Credentials**: Use database root credentials
|
||||
|
||||
### ⏰ Cron Service
|
||||
- **Container**: `accounting_panel_cron`
|
||||
- **Purpose**: Automated scheduled tasks
|
||||
- **Tasks**: Payment processing, log cleanup, health checks
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Key environment variables in `.env`:
|
||||
|
||||
```bash
|
||||
# Application
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_DOMAIN=localhost
|
||||
|
||||
# Database
|
||||
DB_HOST=database
|
||||
DB_NAME=accounting_panel
|
||||
DB_USER=accounting_user
|
||||
DB_PASS=secure_password
|
||||
DB_ROOT_PASSWORD=root_password
|
||||
|
||||
# Admin User
|
||||
ADMIN_NAME=Admin
|
||||
ADMIN_EMAIL=admin@localhost
|
||||
ADMIN_PASSWORD=admin_password
|
||||
```
|
||||
|
||||
### Service Configuration
|
||||
|
||||
#### PHP Configuration
|
||||
Location: `docker/php/php.ini`
|
||||
|
||||
Key settings:
|
||||
- Memory limit: 256M
|
||||
- Upload max size: 64M
|
||||
- Execution time: 300s
|
||||
- OPcache enabled
|
||||
|
||||
#### Caddy Configuration
|
||||
Location: `docker/caddy/Caddyfile`
|
||||
|
||||
Features:
|
||||
- Automatic HTTPS (in production)
|
||||
- Security headers
|
||||
- PHP-FPM integration
|
||||
- Static file handling
|
||||
- Access restrictions
|
||||
|
||||
#### MariaDB Configuration
|
||||
Location: `docker/mariadb/init.sql`
|
||||
|
||||
Optimizations:
|
||||
- UTF8MB4 character set
|
||||
- InnoDB buffer pool sizing
|
||||
- Connection limits
|
||||
- Query cache settings
|
||||
|
||||
## 🛠️ Management Commands
|
||||
|
||||
### Docker Compose Commands
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose up -d
|
||||
|
||||
# Stop all services
|
||||
docker-compose down
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# View specific service logs
|
||||
docker-compose logs -f app
|
||||
|
||||
# Rebuild services
|
||||
docker-compose build --no-cache
|
||||
|
||||
# Restart specific service
|
||||
docker-compose restart app
|
||||
|
||||
# Execute commands in containers
|
||||
docker-compose exec app bash
|
||||
docker-compose exec database mysql -u root -p
|
||||
```
|
||||
|
||||
### Application Commands
|
||||
|
||||
```bash
|
||||
# Run control commands
|
||||
docker-compose exec app php control <command>
|
||||
|
||||
# Database operations
|
||||
docker-compose exec app php control migrate run
|
||||
docker-compose exec app php control db seed
|
||||
docker-compose exec app php control faker all
|
||||
|
||||
# User management
|
||||
docker-compose exec app php control user create "John Doe" "john@example.com" "password"
|
||||
docker-compose exec app php control user list
|
||||
|
||||
# Schedule operations
|
||||
docker-compose exec app php control schedule status
|
||||
docker-compose exec app php control schedule run
|
||||
```
|
||||
|
||||
## 🔄 Docker Swarm Deployment
|
||||
|
||||
For production deployments with high availability:
|
||||
|
||||
```bash
|
||||
# Deploy with Docker Swarm
|
||||
./setup.sh --swarm
|
||||
|
||||
# Or manually
|
||||
docker swarm init
|
||||
docker build -t accounting_panel:latest .
|
||||
docker build -t accounting_panel_cron:latest -f docker/cron/Dockerfile .
|
||||
docker stack deploy -c docker-swarm.yml accounting-panel
|
||||
```
|
||||
|
||||
### Swarm Features
|
||||
|
||||
- **Load balancing**: Multiple app replicas
|
||||
- **High availability**: Automatic failover
|
||||
- **Resource limits**: CPU and memory constraints
|
||||
- **Rolling updates**: Zero-downtime deployments
|
||||
- **Service discovery**: Automatic service networking
|
||||
|
||||
### Swarm Management
|
||||
|
||||
```bash
|
||||
# View stack services
|
||||
docker stack services accounting-panel
|
||||
|
||||
# Scale services
|
||||
docker service scale accounting-panel_app=3
|
||||
|
||||
# Update services
|
||||
docker service update accounting-panel_app
|
||||
|
||||
# Remove stack
|
||||
docker stack rm accounting-panel
|
||||
```
|
||||
|
||||
## 📊 Monitoring and Logs
|
||||
|
||||
### Log Locations
|
||||
|
||||
- **Application logs**: `logs/` directory
|
||||
- **Cron logs**: `logs/cron.log`
|
||||
- **Health checks**: `logs/health.log`
|
||||
- **Caddy logs**: Available via `docker-compose logs caddy`
|
||||
|
||||
### Health Checks
|
||||
|
||||
Built-in health checks for:
|
||||
- Database connectivity
|
||||
- PHP-FPM process
|
||||
- Web server response
|
||||
- Application functionality
|
||||
|
||||
```bash
|
||||
# Check service health
|
||||
docker-compose ps
|
||||
|
||||
# Manual health check
|
||||
curl -f http://localhost/health
|
||||
|
||||
# View health check logs
|
||||
docker-compose logs caddy | grep health
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
### Default Security Measures
|
||||
|
||||
- **Secure passwords**: Generated automatically
|
||||
- **File permissions**: Proper ownership and modes
|
||||
- **Network isolation**: Services communicate via private network
|
||||
- **Access restrictions**: Sensitive files blocked by Caddy
|
||||
- **Security headers**: XSS protection, CSRF, etc.
|
||||
|
||||
### Production Security
|
||||
|
||||
For production deployments:
|
||||
|
||||
1. **Change default passwords**:
|
||||
```bash
|
||||
# Edit .env file
|
||||
nano .env
|
||||
# Update passwords and restart services
|
||||
docker-compose down && docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Use HTTPS**:
|
||||
```bash
|
||||
# Update APP_URL in .env
|
||||
APP_URL=https://your-domain.com
|
||||
APP_DOMAIN=your-domain.com
|
||||
SESSION_SECURE=true
|
||||
```
|
||||
|
||||
3. **Firewall configuration**:
|
||||
```bash
|
||||
# Only expose necessary ports
|
||||
ufw allow 80,443/tcp
|
||||
ufw deny 3306,8080/tcp
|
||||
```
|
||||
|
||||
4. **Regular updates**:
|
||||
```bash
|
||||
# Update Docker images
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Database Connection Failed
|
||||
```bash
|
||||
# Check database status
|
||||
docker-compose ps database
|
||||
|
||||
# Check logs
|
||||
docker-compose logs database
|
||||
|
||||
# Restart database
|
||||
docker-compose restart database
|
||||
```
|
||||
|
||||
#### Permission Errors
|
||||
```bash
|
||||
# Fix permissions
|
||||
sudo chown -R $USER:$USER .
|
||||
chmod -R 755 .
|
||||
chmod -R 777 logs sessions public/uploads
|
||||
```
|
||||
|
||||
#### Port Conflicts
|
||||
```bash
|
||||
# Change ports in docker-compose.yml
|
||||
ports:
|
||||
- "8080:80" # Use different port
|
||||
- "8443:443"
|
||||
```
|
||||
|
||||
#### Container Build Issues
|
||||
```bash
|
||||
# Clean build
|
||||
docker-compose down --volumes
|
||||
docker system prune -a
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Enable debug mode for troubleshooting:
|
||||
|
||||
```bash
|
||||
# Update .env
|
||||
APP_DEBUG=true
|
||||
APP_ENV=development
|
||||
|
||||
# Restart services
|
||||
docker-compose restart app
|
||||
```
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
### Documentation
|
||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
||||
- [Docker Swarm Documentation](https://docs.docker.com/engine/swarm/)
|
||||
- [Caddy Documentation](https://caddyserver.com/docs/)
|
||||
- [MariaDB Documentation](https://mariadb.org/documentation/)
|
||||
|
||||
### Support
|
||||
- Check application logs: `docker-compose logs -f app`
|
||||
- Review health checks: `curl -v http://localhost`
|
||||
- Validate configuration: `docker-compose config`
|
||||
|
||||
## 🔄 Backup and Recovery
|
||||
|
||||
### Database Backup
|
||||
```bash
|
||||
# Create backup
|
||||
docker-compose exec database mysqldump -u root -p${DB_ROOT_PASSWORD} accounting_panel > backup.sql
|
||||
|
||||
# Restore backup
|
||||
docker-compose exec -T database mysql -u root -p${DB_ROOT_PASSWORD} accounting_panel < backup.sql
|
||||
```
|
||||
|
||||
### Full Backup
|
||||
```bash
|
||||
# Backup volumes
|
||||
docker run --rm -v accounting_panel_db_data:/data -v $(pwd):/backup busybox tar czf /backup/db_backup.tar.gz /data
|
||||
docker run --rm -v accounting_panel_app_uploads:/data -v $(pwd):/backup busybox tar czf /backup/uploads_backup.tar.gz /data
|
||||
```
|
||||
|
||||
### Automated Backups
|
||||
```bash
|
||||
# Add to crontab
|
||||
0 2 * * * cd /path/to/project && docker-compose exec database mysqldump -u root -p${DB_ROOT_PASSWORD} accounting_panel > backups/$(date +%Y%m%d_%H%M%S).sql
|
||||
```
|
||||
|
||||
## 📈 Performance Optimization
|
||||
|
||||
### Resource Limits
|
||||
```yaml
|
||||
# In docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '1'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### Caching
|
||||
- OPcache enabled for PHP
|
||||
- Static file caching via Caddy
|
||||
- Database query optimization
|
||||
|
||||
### Scaling
|
||||
```bash
|
||||
# Scale application containers
|
||||
docker-compose up -d --scale app=3
|
||||
|
||||
# Use load balancer
|
||||
# Configure external load balancer to distribute traffic
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Happy accounting with Docker! 🐳📊**
|
||||
339
README.md
Normal file
339
README.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# PersonalAccounter
|
||||
|
||||
A comprehensive PHP-based personal and business accounting management system with powerful expense tracking, subscription management, and reporting capabilities.
|
||||
|
||||
## 🌟 Features
|
||||
|
||||
### 💰 **Expense Management**
|
||||
- **Multi-method expense tracking** with support for credit cards, bank accounts, and cryptocurrency wallets
|
||||
- **Smart categorization** with tags and hierarchical organization
|
||||
- **Tax calculation** with customizable rates and types
|
||||
- **Approval workflow** with pending/approved/rejected/paid status tracking
|
||||
- **File attachments** for receipts and documentation
|
||||
- **Bulk import/export** via Excel, CSV, and JSON formats
|
||||
|
||||
### 🔄 **Subscription Management**
|
||||
- **Recurring subscription tracking** for all your services and subscriptions
|
||||
- **Flexible billing cycles**: monthly, annual, weekly, daily, and one-time payments
|
||||
- **Multi-currency support** with automatic calculations
|
||||
- **Status management**: active, expired, cancelled, paused
|
||||
- **Cost projections** and spending analysis
|
||||
|
||||
### 💳 **Payment Methods**
|
||||
- **Credit Cards**: Full card management with bank association and currency support
|
||||
- **Bank Accounts**: International banking with IBAN, SWIFT/BIC, and routing number support
|
||||
- **Crypto Wallets**: Multi-network cryptocurrency wallet management with address validation
|
||||
|
||||
### 📊 **Analytics & Reporting**
|
||||
- **Real-time dashboard** with comprehensive financial statistics
|
||||
- **Visual analytics** with charts and graphs for spending patterns
|
||||
- **Date-filtered reports** for any time period
|
||||
- **Export capabilities** in multiple formats (CSV, JSON, Excel)
|
||||
- **Category and payment method breakdowns**
|
||||
|
||||
### 🔐 **Security & Authentication**
|
||||
- **Two-Factor Authentication (2FA)** with Google Authenticator support
|
||||
- **User role management** (admin, superadmin)
|
||||
- **API key management** with permission-based access control
|
||||
- **CSRF protection** and security headers
|
||||
- **Session security** with configurable timeouts
|
||||
|
||||
### 🚀 **API & Integration**
|
||||
- **RESTful API** with OpenAPI/Swagger documentation
|
||||
- **Authentication** via API keys or Bearer tokens
|
||||
- **Rate limiting** and comprehensive error handling
|
||||
- **Webhooks support** for external integrations
|
||||
- **Comprehensive endpoints** for all application features
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- **PHP**: 8.0 or higher
|
||||
- **MySQL**: 5.7 or higher
|
||||
- **Web Server**: Apache or Nginx
|
||||
- **Composer**: For dependency management
|
||||
|
||||
### PHP Extensions Required
|
||||
- `pdo_mysql`
|
||||
- `json`
|
||||
- `openssl`
|
||||
- `mbstring`
|
||||
- `gd`
|
||||
- `curl`
|
||||
- `zip`
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### 1. Clone the Repository
|
||||
```bash
|
||||
git clone https://github.com/moonshadowrev/PersonalAccounter
|
||||
cd PersonalAccounter
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit the `.env` file with your configuration:
|
||||
```env
|
||||
# Application
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://your-domain.com
|
||||
APP_DOMAIN=your-domain.com
|
||||
APP_TIMEZONE=UTC
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_NAME=personal_accounter
|
||||
DB_USER=your_username
|
||||
DB_PASS=your_password
|
||||
DB_PORT=3306
|
||||
|
||||
# Security
|
||||
SESSION_LIFETIME=0
|
||||
SESSION_SECURE=true
|
||||
SESSION_SAMESITE=Lax
|
||||
LOGIN_ATTEMPTS_LIMIT=5
|
||||
LOGIN_ATTEMPTS_TIMEOUT=300
|
||||
|
||||
# API
|
||||
API_MAX_FAILED_ATTEMPTS=5
|
||||
API_BLOCK_DURATION=300
|
||||
API_DEFAULT_RATE_LIMIT=60
|
||||
API_MAX_RATE_LIMIT=1000
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=file
|
||||
LOG_LEVEL=warning
|
||||
LOG_MAX_FILES=5
|
||||
```
|
||||
|
||||
### 4. Database Setup
|
||||
```bash
|
||||
# Run migrations
|
||||
php control migrate run
|
||||
|
||||
# Create admin user
|
||||
php control user admin
|
||||
|
||||
# Seed with sample data (optional)
|
||||
php control db seed
|
||||
```
|
||||
|
||||
### 5. File Permissions
|
||||
```bash
|
||||
chmod 755 -R .
|
||||
chmod 777 -R logs/
|
||||
chmod 777 -R sessions/
|
||||
chmod 777 -R public/uploads/
|
||||
```
|
||||
|
||||
### 6. Web Server Configuration
|
||||
|
||||
#### Apache (.htaccess)
|
||||
```apache
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)$ public/index.php [QSA,L]
|
||||
```
|
||||
|
||||
#### Nginx
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /public/index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/var/run/php/php8.0-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 Quick Start
|
||||
|
||||
### 1. Access the Application
|
||||
Navigate to `https://your-domain.com` and log in with your admin credentials.
|
||||
|
||||
### 2. Setup Payment Methods
|
||||
1. Go to **Payment Methods** → **Bank Accounts**
|
||||
2. Add your bank account information
|
||||
3. Add credit cards and crypto wallets as needed
|
||||
|
||||
### 3. Create Categories
|
||||
1. Go to **Expenses** → **Categories**
|
||||
2. Create categories for your expense types (Food, Transport, Utilities, etc.)
|
||||
3. Use the "Create Defaults" button to add common categories
|
||||
|
||||
### 4. Start Tracking Expenses
|
||||
1. Go to **Expenses** → **All Expenses**
|
||||
2. Click "Add Expense"
|
||||
3. Fill in the details and submit
|
||||
|
||||
### 5. Setup Subscriptions
|
||||
1. Go to **Subscriptions**
|
||||
2. Add your recurring services and subscriptions
|
||||
3. Set billing cycles and amounts
|
||||
|
||||
## 📱 Usage
|
||||
|
||||
### Managing Expenses
|
||||
- **Create**: Add new expenses with detailed information including tax calculations
|
||||
- **Categorize**: Organize expenses using categories and tags
|
||||
- **Approve**: Use the approval workflow for business expense management
|
||||
- **Import**: Bulk import expenses from Excel or CSV files
|
||||
- **Export**: Generate reports in various formats
|
||||
|
||||
### Subscription Tracking
|
||||
- **Add Services**: Track all your recurring subscriptions
|
||||
- **Monitor Costs**: View monthly and annual cost projections
|
||||
- **Status Management**: Mark subscriptions as active, paused, expired, or cancelled
|
||||
- **Billing Cycles**: Support for various billing frequencies
|
||||
|
||||
### Financial Reporting
|
||||
- **Dashboard**: View comprehensive financial overview
|
||||
- **Date Filtering**: Generate reports for specific time periods
|
||||
- **Visual Analytics**: Charts and graphs for spending patterns
|
||||
- **Export Options**: CSV, JSON, and Excel format exports
|
||||
|
||||
## 🔧 Command Line Interface
|
||||
|
||||
The application includes a powerful CLI tool:
|
||||
|
||||
```bash
|
||||
# Database migrations
|
||||
php control migrate run
|
||||
php control migrate fresh
|
||||
php control migrate rollback
|
||||
php control migrate status
|
||||
|
||||
# User management
|
||||
php control user create "John Doe" "john@example.com" "password" "admin"
|
||||
php control user list
|
||||
php control user delete "john@example.com"
|
||||
php control user admin
|
||||
|
||||
# Database operations
|
||||
php control db seed
|
||||
php control db reset
|
||||
php control db status
|
||||
|
||||
# Fake data generation
|
||||
php control faker all
|
||||
php control faker generate --users=10 --expenses=100
|
||||
```
|
||||
|
||||
## 🔌 API Usage
|
||||
|
||||
### Authentication
|
||||
```bash
|
||||
# Get API key from Profile → API Keys in the web interface
|
||||
curl -H "X-API-Key: your-api-key" https://your-domain.com/api/v1/expenses
|
||||
```
|
||||
|
||||
### Example API Calls
|
||||
```bash
|
||||
# Get all expenses
|
||||
curl -H "X-API-Key: your-key" https://your-domain.com/api/v1/expenses
|
||||
|
||||
# Create an expense
|
||||
curl -X POST -H "X-API-Key: your-key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"Lunch","amount":25.50,"category_id":1}' \
|
||||
https://your-domain.com/api/v1/expenses
|
||||
|
||||
# Get dashboard statistics
|
||||
curl -H "X-API-Key: your-key" https://your-domain.com/api/v1/reports/dashboard
|
||||
```
|
||||
|
||||
### API Documentation
|
||||
Access the interactive API documentation at:
|
||||
- **Swagger UI**: `https://your-domain.com/api/docs/ui` (development mode only)
|
||||
- **OpenAPI JSON**: `https://your-domain.com/api/docs` (development mode only)
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
PersonalAccounter/
|
||||
├── app/
|
||||
│ ├── Controllers/ # Application controllers
|
||||
│ ├── Models/ # Database models
|
||||
│ ├── Services/ # Business logic services
|
||||
│ ├── Routes/ # Route definitions
|
||||
│ └── Views/ # Template files
|
||||
├── bootstrap/ # Application bootstrap
|
||||
├── config/ # Configuration files
|
||||
├── database/ # Database migrations
|
||||
├── logs/ # Application logs
|
||||
├── public/ # Web accessible files
|
||||
├── sessions/ # Session storage
|
||||
└── vendor/ # Composer dependencies
|
||||
```
|
||||
|
||||
### Technology Stack
|
||||
- **Backend**: PHP 8.0+ with custom MVC framework
|
||||
- **Database**: MySQL with Medoo ORM
|
||||
- **Frontend**: HTML5, CSS3, JavaScript (Vanilla JS)
|
||||
- **Authentication**: Custom session-based auth with 2FA
|
||||
- **API**: RESTful with OpenAPI documentation
|
||||
- **Security**: CSRF protection, XSS prevention, input validation
|
||||
|
||||
## 🌍 Localization
|
||||
|
||||
The application supports multiple currencies and international banking:
|
||||
- **Currencies**: USD, EUR, GBP, CAD, AUD, JPY, CHF, CNY, SEK, NOK, DKK, SGD, HKD
|
||||
- **Banking**: IBAN and SWIFT/BIC support for international accounts
|
||||
- **Crypto**: Multi-network cryptocurrency support
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
PersonalAccounter implements comprehensive security measures:
|
||||
- **CSRF Protection**: All forms protected against cross-site request forgery
|
||||
- **XSS Prevention**: Input sanitization and output encoding
|
||||
- **SQL Injection**: Prepared statements and parameterized queries
|
||||
- **Session Security**: Secure cookie settings and session regeneration
|
||||
- **Rate Limiting**: API and login attempt limitations
|
||||
- **Security Headers**: HSTS, content type options, frame options
|
||||
- **Two-Factor Authentication**: Google Authenticator integration
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
For detailed documentation including:
|
||||
- **API Reference**: Complete endpoint documentation
|
||||
- **Security Guide**: Security implementation details
|
||||
- **Contribution Guidelines**: How to contribute to the project
|
||||
- **Feature Wiki**: Detailed feature documentation
|
||||
|
||||
See the [`docs/`](./docs/) directory.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 (GPL-3.0).
|
||||
|
||||
See [LICENSE](LICENSE) for the full license text.
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## 🐛 Issues & Support
|
||||
|
||||
- **Bug Reports**: [GitHub Issues](https://github.com/your-repo/PersonalAccounter/issues)
|
||||
- **Feature Requests**: [GitHub Discussions](https://github.com/your-repo/PersonalAccounter/discussions)
|
||||
- **Security Issues**: See [SECURITY.md](SECURITY.md)
|
||||
|
||||
## 🔄 Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for version history and changes.
|
||||
|
||||
---
|
||||
|
||||
**PersonalAccounter** - Take control of your finances with powerful, secure, and flexible accounting management.
|
||||
355
SECURITY.md
Normal file
355
SECURITY.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Security Policy
|
||||
|
||||
## Overview
|
||||
|
||||
PersonalAccounter takes security seriously and implements multiple layers of protection to safeguard your financial data. This document outlines our security measures, best practices, and procedures for reporting security vulnerabilities.
|
||||
|
||||
## 🛡️ Security Features
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
#### **Multi-Factor Authentication (MFA)**
|
||||
- **Two-Factor Authentication (2FA)** with Google Authenticator support
|
||||
- **TOTP (Time-based One-Time Password)** implementation
|
||||
- **Backup recovery codes** for account recovery
|
||||
- **Mandatory 2FA** option for enhanced security
|
||||
|
||||
#### **Session Management**
|
||||
- **Secure session handling** with HttpOnly and Secure cookie flags
|
||||
- **Session regeneration** on authentication to prevent session fixation
|
||||
- **Configurable session timeouts** to limit exposure
|
||||
- **Automatic session invalidation** on logout
|
||||
|
||||
#### **Password Security**
|
||||
- **Bcrypt hashing** with automatic salt generation (cost factor 12)
|
||||
- **Timing attack protection** using `hash_equals()` for comparisons
|
||||
- **Account lockout** after failed login attempts
|
||||
- **Rate limiting** on authentication endpoints
|
||||
|
||||
### Input Validation & Sanitization
|
||||
|
||||
#### **CSRF Protection**
|
||||
- **Token-based CSRF protection** on all forms
|
||||
- **Double-submit cookie pattern** for API requests
|
||||
- **SameSite cookie attributes** for additional protection
|
||||
- **Automatic token regeneration** on each request
|
||||
|
||||
#### **XSS Prevention**
|
||||
- **Input sanitization** using `htmlspecialchars()` with ENT_QUOTES
|
||||
- **Output encoding** for all user-generated content
|
||||
- **Content Security Policy (CSP)** headers
|
||||
- **X-XSS-Protection** headers for legacy browser support
|
||||
|
||||
#### **SQL Injection Prevention**
|
||||
- **Prepared statements** for all database queries
|
||||
- **Parameterized queries** using Medoo ORM
|
||||
- **Input type validation** and casting
|
||||
- **Whitelist validation** for dynamic table/column names
|
||||
|
||||
### API Security
|
||||
|
||||
#### **Authentication**
|
||||
- **API key authentication** with secure key generation
|
||||
- **Bearer token support** for OAuth-style authentication
|
||||
- **Rate limiting** per API key with configurable limits
|
||||
- **Request signing** for sensitive operations
|
||||
|
||||
#### **Authorization**
|
||||
- **Permission-based access control** with granular permissions
|
||||
- **Role-based API access** (admin, superadmin)
|
||||
- **Endpoint-specific permission checks**
|
||||
- **Audit logging** for all API requests
|
||||
|
||||
### Data Protection
|
||||
|
||||
#### **Encryption**
|
||||
- **TLS/SSL encryption** in transit (HTTPS required in production)
|
||||
- **Database connection encryption** when available
|
||||
- **Sensitive field encryption** for PII data
|
||||
- **Secure key management** with environment variables
|
||||
|
||||
#### **File Upload Security**
|
||||
- **File type validation** using MIME type checking
|
||||
- **File size limits** to prevent DoS attacks
|
||||
- **Upload directory isolation** outside web root
|
||||
- **Virus scanning** integration capability
|
||||
|
||||
## 🔧 Security Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# Security Configuration
|
||||
SESSION_SECURE=true
|
||||
SESSION_SAMESITE=Strict
|
||||
LOGIN_ATTEMPTS_LIMIT=5
|
||||
LOGIN_ATTEMPTS_TIMEOUT=300
|
||||
|
||||
# API Security
|
||||
API_MAX_FAILED_ATTEMPTS=5
|
||||
API_BLOCK_DURATION=300
|
||||
API_DEFAULT_RATE_LIMIT=60
|
||||
API_MAX_RATE_LIMIT=1000
|
||||
|
||||
# Encryption
|
||||
ENCRYPTION_KEY=your-32-character-key-here
|
||||
```
|
||||
|
||||
### Web Server Security Headers
|
||||
|
||||
#### **Apache Configuration**
|
||||
```apache
|
||||
# Security Headers
|
||||
Header always set X-Content-Type-Options nosniff
|
||||
Header always set X-Frame-Options SAMEORIGIN
|
||||
Header always set X-XSS-Protection "1; mode=block"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
|
||||
# Content Security Policy
|
||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'"
|
||||
```
|
||||
|
||||
#### **Nginx Configuration**
|
||||
```nginx
|
||||
# Security Headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-Frame-Options SAMEORIGIN;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
|
||||
|
||||
# Content Security Policy
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'";
|
||||
```
|
||||
|
||||
### Database Security
|
||||
|
||||
#### **MySQL Configuration**
|
||||
```sql
|
||||
-- Create dedicated database user
|
||||
CREATE USER 'personal_accounter'@'localhost' IDENTIFIED BY 'strong-password-here';
|
||||
|
||||
-- Grant minimal required permissions
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON personal_accounter.* TO 'personal_accounter'@'localhost';
|
||||
|
||||
-- Enable SSL connections
|
||||
REQUIRE SSL;
|
||||
|
||||
-- Flush privileges
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
## 🔍 Security Monitoring & Logging
|
||||
|
||||
### Audit Logging
|
||||
|
||||
PersonalAccounter logs all security-relevant events:
|
||||
|
||||
- **Authentication attempts** (successful and failed)
|
||||
- **API key usage** and rate limit violations
|
||||
- **Privilege escalation attempts**
|
||||
- **Data modification operations**
|
||||
- **File upload attempts**
|
||||
- **Suspicious request patterns**
|
||||
|
||||
### Log Analysis
|
||||
|
||||
```bash
|
||||
# Monitor failed login attempts
|
||||
grep "Failed login attempt" logs/app.log | tail -20
|
||||
|
||||
# Check API rate limiting
|
||||
grep "rate limit exceeded" logs/app.log | tail -20
|
||||
|
||||
# Monitor CSRF token failures
|
||||
grep "CSRF token validation failed" logs/app.log | tail -20
|
||||
```
|
||||
|
||||
### Automated Monitoring
|
||||
|
||||
Consider implementing automated monitoring for:
|
||||
- Unusual login patterns
|
||||
- Multiple failed authentication attempts
|
||||
- High-volume API requests
|
||||
- Suspicious database queries
|
||||
- File upload anomalies
|
||||
|
||||
## 🚨 Vulnerability Reporting
|
||||
|
||||
### Reporting Process
|
||||
|
||||
We take security vulnerabilities seriously. If you discover a security issue, please follow these steps:
|
||||
|
||||
1. **DO** create a public GitHub issue
|
||||
3. **Include** a clear description and reproduction steps
|
||||
4. **Provide** your contact information for follow-up
|
||||
|
||||
### What to Include
|
||||
|
||||
- **Vulnerability description** and potential impact
|
||||
- **Steps to reproduce** the issue
|
||||
- **Affected versions** or components
|
||||
- **Proof of concept** (if applicable)
|
||||
- **Suggested mitigation** (if you have ideas)
|
||||
|
||||
### Response Timeline
|
||||
|
||||
- **Initial response**: Within 24 hours
|
||||
- **Vulnerability assessment**: Within 72 hours
|
||||
- **Fix development**: Based on severity (1-30 days)
|
||||
- **Patch release**: As soon as possible after fix
|
||||
|
||||
### Responsible Disclosure
|
||||
|
||||
We follow responsible disclosure practices:
|
||||
- We'll work with you to understand and resolve the issue
|
||||
- We'll keep you informed of our progress
|
||||
- We'll credit you in our security advisory (if desired)
|
||||
- We ask that you give us reasonable time to fix the issue before public disclosure
|
||||
|
||||
## 🏆 Security Best Practices
|
||||
|
||||
### For Administrators
|
||||
|
||||
#### **Installation Security**
|
||||
```bash
|
||||
# Set secure file permissions
|
||||
chmod 755 -R .
|
||||
chmod 700 logs/ sessions/
|
||||
chmod 600 .env config/app.php
|
||||
|
||||
# Remove default files
|
||||
rm -f composer.lock.example .env.example
|
||||
|
||||
# Update dependencies regularly
|
||||
composer update --no-dev
|
||||
```
|
||||
|
||||
#### **Database Security**
|
||||
- Use a dedicated database user with minimal permissions
|
||||
- Enable SSL/TLS for database connections
|
||||
- Regularly backup and test restore procedures
|
||||
- Monitor database logs for suspicious activity
|
||||
|
||||
#### **Server Hardening**
|
||||
- Keep PHP and web server updated
|
||||
- Disable unnecessary PHP extensions
|
||||
- Configure fail2ban for brute force protection
|
||||
- Use a Web Application Firewall (WAF)
|
||||
|
||||
### For Users
|
||||
|
||||
#### **Account Security**
|
||||
- **Enable 2FA** immediately after account creation
|
||||
- **Use strong passwords** with mixed case, numbers, and symbols
|
||||
- **Regularly review** account activity and API keys
|
||||
- **Log out** when finished, especially on shared computers
|
||||
|
||||
#### **Data Protection**
|
||||
- **Regular backups** of your financial data
|
||||
- **Secure your API keys** and never share them
|
||||
- **Monitor** for unusual account activity
|
||||
- **Report** suspicious behavior immediately
|
||||
|
||||
### For Developers
|
||||
|
||||
#### **Secure Coding**
|
||||
```php
|
||||
// Always validate input
|
||||
$amount = filter_var($_POST['amount'], FILTER_VALIDATE_FLOAT);
|
||||
if ($amount === false || $amount < 0) {
|
||||
throw new InvalidArgumentException('Invalid amount');
|
||||
}
|
||||
|
||||
// Use prepared statements
|
||||
$stmt = $db->prepare("SELECT * FROM expenses WHERE user_id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
// Escape output
|
||||
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Validate CSRF tokens
|
||||
if (!hash_equals($_SESSION['_token'], $_POST['_token'])) {
|
||||
throw new SecurityException('CSRF token mismatch');
|
||||
}
|
||||
```
|
||||
|
||||
#### **API Development**
|
||||
```php
|
||||
// Check permissions
|
||||
if (!$this->hasPermission('expenses.read')) {
|
||||
$this->forbidden('Permission denied');
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (!$this->checkRateLimit($apiKey)) {
|
||||
$this->rateLimitExceeded();
|
||||
}
|
||||
|
||||
// Input validation
|
||||
$this->validateRequired($data, ['title', 'amount', 'category_id']);
|
||||
```
|
||||
|
||||
## 🔄 Security Updates
|
||||
|
||||
### Staying Secure
|
||||
|
||||
- **Subscribe** to security notifications
|
||||
- **Apply updates** promptly when released
|
||||
- **Monitor** the changelog for security fixes
|
||||
- **Test updates** in a staging environment first
|
||||
|
||||
### Security Releases
|
||||
|
||||
Security updates are released as:
|
||||
- **Patch releases** (e.g., 1.2.3 → 1.2.4) for minor security fixes
|
||||
- **Minor releases** (e.g., 1.2.0 → 1.3.0) for moderate security improvements
|
||||
- **Emergency releases** for critical vulnerabilities
|
||||
|
||||
### Notification Channels
|
||||
|
||||
- **GitHub Security Advisories**
|
||||
- **Release notes** on GitHub
|
||||
- **Security mailing list** (coming soon)
|
||||
- **Documentation updates**
|
||||
|
||||
## 📋 Security Checklist
|
||||
|
||||
### Pre-Production Checklist
|
||||
|
||||
- [ ] Enable HTTPS with valid SSL certificate
|
||||
- [ ] Configure security headers
|
||||
- [ ] Set secure environment variables
|
||||
- [ ] Review file permissions
|
||||
- [ ] Test authentication flows
|
||||
- [ ] Verify CSRF protection
|
||||
- [ ] Check input validation
|
||||
- [ ] Test rate limiting
|
||||
- [ ] Configure logging
|
||||
- [ ] Set up monitoring
|
||||
|
||||
### Regular Security Reviews
|
||||
|
||||
**Monthly:**
|
||||
- [ ] Review access logs
|
||||
- [ ] Check for failed login attempts
|
||||
- [ ] Audit API key usage
|
||||
- [ ] Review user accounts
|
||||
|
||||
**Quarterly:**
|
||||
- [ ] Update dependencies
|
||||
- [ ] Review security configurations
|
||||
- [ ] Test backup procedures
|
||||
- [ ] Audit permissions
|
||||
|
||||
**Annually:**
|
||||
- [ ] Security assessment
|
||||
- [ ] Penetration testing
|
||||
- [ ] Compliance review
|
||||
- [ ] Incident response plan review
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Remember**: Security is a shared responsibility. By following these guidelines and best practices, you help keep PersonalAccounter secure for everyone.
|
||||
1
app/.htaccess
Normal file
1
app/.htaccess
Normal file
@@ -0,0 +1 @@
|
||||
Deny from all
|
||||
513
app/Controllers/Api/ApiController.php
Normal file
513
app/Controllers/Api/ApiController.php
Normal file
@@ -0,0 +1,513 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../Services/Logger.php';
|
||||
|
||||
/**
|
||||
* Base API Controller
|
||||
*
|
||||
* @OA\Info(
|
||||
* title="Accounting Panel API",
|
||||
* version="1.0.0",
|
||||
* description="API for Accounting Panel",
|
||||
* @OA\Contact(
|
||||
* email="admin@example.com"
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\SecurityScheme(
|
||||
* securityScheme="ApiKeyAuth",
|
||||
* type="apiKey",
|
||||
* in="header",
|
||||
* name="X-API-Key"
|
||||
* )
|
||||
*
|
||||
* @OA\SecurityScheme(
|
||||
* securityScheme="BearerAuth",
|
||||
* type="http",
|
||||
* scheme="bearer"
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="User",
|
||||
* type="object",
|
||||
* title="User",
|
||||
* description="User model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="email", type="string", format="email", example="john@example.com"),
|
||||
* @OA\Property(property="role", type="string", enum={"admin", "user"}, example="user"),
|
||||
* @OA\Property(property="two_factor_enabled", type="boolean", example=false),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="BankAccount",
|
||||
* type="object",
|
||||
* title="BankAccount",
|
||||
* description="Bank account model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="Primary Checking"),
|
||||
* @OA\Property(property="bank_name", type="string", example="Bank of America"),
|
||||
* @OA\Property(property="account_type", type="string", enum={"checking", "savings", "business", "investment"}, example="checking"),
|
||||
* @OA\Property(property="account_number_last4", type="string", example="1234"),
|
||||
* @OA\Property(property="routing_number_last4", type="string", example="5678"),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="swift_code", type="string", example="BOFAUS3N"),
|
||||
* @OA\Property(property="iban", type="string", example="GB29 NWBK 6016 1331 9268 19"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="notes", type="string", example="Primary business account"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="owner_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="owner_email", type="string", format="email", example="john@example.com")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Category",
|
||||
* type="object",
|
||||
* title="Category",
|
||||
* description="Category model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="description", type="string", example="All office-related purchases"),
|
||||
* @OA\Property(property="color", type="string", example="#FF5733"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Tag",
|
||||
* type="object",
|
||||
* title="Tag",
|
||||
* description="Tag model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="urgent"),
|
||||
* @OA\Property(property="color", type="string", example="#FF0000"),
|
||||
* @OA\Property(property="description", type="string", example="Urgent expenses requiring immediate attention"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Expense",
|
||||
* type="object",
|
||||
* title="Expense",
|
||||
* description="Expense model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="category_id", type="integer", example=2),
|
||||
* @OA\Property(property="bank_account_id", type="integer", example=1),
|
||||
* @OA\Property(property="title", type="string", example="Office Supplies Purchase"),
|
||||
* @OA\Property(property="description", type="string", example="Monthly office supplies for the team"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=156.78),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="expense_date", type="string", format="date", example="2024-01-15"),
|
||||
* @OA\Property(property="status", type="string", enum={"pending", "approved", "rejected"}, example="pending"),
|
||||
* @OA\Property(property="receipt_url", type="string", example="/uploads/receipts/receipt_123.pdf"),
|
||||
* @OA\Property(property="notes", type="string", example="Purchased from Staples"),
|
||||
* @OA\Property(property="is_reimbursable", type="boolean", example=true),
|
||||
* @OA\Property(property="is_billable", type="boolean", example=false),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="category_name", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="user_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="bank_account_name", type="string", example="Primary Checking")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="CryptoWallet",
|
||||
* type="object",
|
||||
* title="CryptoWallet",
|
||||
* description="Crypto wallet model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="Bitcoin Wallet"),
|
||||
* @OA\Property(property="currency", type="string", example="BTC"),
|
||||
* @OA\Property(property="network", type="string", example="mainnet"),
|
||||
* @OA\Property(property="address", type="string", example="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
|
||||
* @OA\Property(property="balance", type="number", format="float", example=0.025),
|
||||
* @OA\Property(property="wallet_type", type="string", enum={"hot", "cold", "hardware"}, example="hardware"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="notes", type="string", example="Hardware wallet for Bitcoin storage"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="owner_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="owner_email", type="string", format="email", example="john@example.com")
|
||||
* )
|
||||
*
|
||||
* @OA\Schema(
|
||||
* schema="Pagination",
|
||||
* type="object",
|
||||
* title="Pagination",
|
||||
* description="Pagination metadata",
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=150),
|
||||
* @OA\Property(property="total_pages", type="integer", example=8),
|
||||
* @OA\Property(property="has_next", type="boolean", example=true),
|
||||
* @OA\Property(property="has_prev", type="boolean", example=false)
|
||||
* )
|
||||
*
|
||||
* @OA\Response(
|
||||
* response="Unauthorized",
|
||||
* description="Unauthorized access - API key missing or invalid",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Unauthorized"),
|
||||
* @OA\Property(property="message", type="string", example="Invalid or missing API key"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Response(
|
||||
* response="Forbidden",
|
||||
* description="Forbidden - Insufficient permissions",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Forbidden"),
|
||||
* @OA\Property(property="message", type="string", example="You do not have permission to access this resource"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Response(
|
||||
* response="NotFound",
|
||||
* description="Resource not found",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Not Found"),
|
||||
* @OA\Property(property="message", type="string", example="The requested resource was not found"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Response(
|
||||
* response="ValidationError",
|
||||
* description="Validation Error",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Validation Error"),
|
||||
* @OA\Property(property="message", type="string", example="Validation failed"),
|
||||
* @OA\Property(property="errors", type="object",
|
||||
* @OA\Property(property="field_name", type="string", example="This field is required")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*
|
||||
* @OA\Response(
|
||||
* response="BadRequest",
|
||||
* description="Bad Request",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Bad Request"),
|
||||
* @OA\Property(property="message", type="string", example="The request was invalid or cannot be served"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
class ApiController {
|
||||
|
||||
protected $db;
|
||||
protected $request;
|
||||
|
||||
public function __construct($database) {
|
||||
$this->db = $database;
|
||||
// Always get fresh request data, as middleware may have updated it
|
||||
$this->request = $this->getCurrentRequest();
|
||||
|
||||
AppLogger::debug('ApiController initialized', [
|
||||
'has_request' => !empty($this->request),
|
||||
'has_api_key_in_request' => isset($this->request['api_key']),
|
||||
'api_key_id' => $this->request['api_key']['id'] ?? 'none'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current request data
|
||||
*/
|
||||
protected function getCurrentRequest() {
|
||||
// First check if middleware has already processed the request
|
||||
if (isset($GLOBALS['api_request'])) {
|
||||
AppLogger::debug('Using request data from middleware', [
|
||||
'has_api_key' => isset($GLOBALS['api_request']['api_key']),
|
||||
'api_key_id' => $GLOBALS['api_request']['api_key']['id'] ?? 'none'
|
||||
]);
|
||||
return $GLOBALS['api_request'];
|
||||
}
|
||||
|
||||
// Fallback to creating request data
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
$body = [];
|
||||
|
||||
if (strpos($contentType, 'application/json') !== false) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
$body = json_decode($rawBody, true) ?: [];
|
||||
} else {
|
||||
$body = $_POST;
|
||||
}
|
||||
|
||||
$request = [
|
||||
'method' => $_SERVER['REQUEST_METHOD'],
|
||||
'uri' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
|
||||
'query' => $_GET,
|
||||
'body' => $body,
|
||||
'headers' => getallheaders() ?: []
|
||||
];
|
||||
|
||||
AppLogger::debug('Created new request data (no middleware)', [
|
||||
'uri' => $request['uri'],
|
||||
'method' => $request['method']
|
||||
]);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return JSON success response
|
||||
*/
|
||||
protected function success($data = null, $message = 'Success', $statusCode = 200) {
|
||||
http_response_code($statusCode);
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'message' => $message,
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
if ($data !== null) {
|
||||
$response['data'] = $data;
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return JSON error response
|
||||
*/
|
||||
protected function error($message = 'An error occurred', $statusCode = 400, $errors = null) {
|
||||
http_response_code($statusCode);
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => $this->getErrorType($statusCode),
|
||||
'message' => $message,
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
if ($errors !== null) {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
|
||||
echo json_encode($response, JSON_PRETTY_PRINT);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return validation error response
|
||||
*/
|
||||
protected function validationError($errors, $message = 'Validation failed') {
|
||||
$this->error($message, 422, $errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return not found error response
|
||||
*/
|
||||
protected function notFound($message = 'Resource not found') {
|
||||
$this->error($message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return unauthorized error response
|
||||
*/
|
||||
protected function unauthorized($message = 'Unauthorized') {
|
||||
$this->error($message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return forbidden error response
|
||||
*/
|
||||
protected function forbidden($message = 'Forbidden') {
|
||||
$this->error($message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return server error response
|
||||
*/
|
||||
protected function serverError($message = 'Internal server error') {
|
||||
$this->error($message, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error type based on status code
|
||||
*/
|
||||
private function getErrorType($statusCode) {
|
||||
$errorTypes = [
|
||||
400 => 'Bad Request',
|
||||
401 => 'Unauthorized',
|
||||
403 => 'Forbidden',
|
||||
404 => 'Not Found',
|
||||
422 => 'Validation Error',
|
||||
429 => 'Rate Limit Exceeded',
|
||||
500 => 'Internal Server Error'
|
||||
];
|
||||
|
||||
return $errorTypes[$statusCode] ?? 'Error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields
|
||||
*/
|
||||
protected function validateRequired($data, $requiredFields) {
|
||||
$errors = [];
|
||||
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($data[$field]) || empty($data[$field])) {
|
||||
$errors[$field] = "The {$field} field is required.";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
protected function validateEmail($email) {
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize input data
|
||||
*/
|
||||
protected function sanitize($data) {
|
||||
if (is_array($data)) {
|
||||
return array_map([$this, 'sanitize'], $data);
|
||||
}
|
||||
|
||||
return htmlspecialchars(trim($data), ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pagination parameters
|
||||
*/
|
||||
protected function getPagination() {
|
||||
$page = max(1, (int)($this->request['query']['page'] ?? 1));
|
||||
$limit = min(100, max(1, (int)($this->request['query']['limit'] ?? 20)));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pagination response
|
||||
*/
|
||||
protected function paginatedResponse($data, $total, $pagination) {
|
||||
$totalPages = ceil($total / $pagination['limit']);
|
||||
|
||||
return [
|
||||
'data' => $data,
|
||||
'pagination' => [
|
||||
'current_page' => $pagination['page'],
|
||||
'per_page' => $pagination['limit'],
|
||||
'total' => $total,
|
||||
'total_pages' => $totalPages,
|
||||
'has_next' => $pagination['page'] < $totalPages,
|
||||
'has_prev' => $pagination['page'] > 1
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Log API request
|
||||
*/
|
||||
protected function logRequest($action, $data = []) {
|
||||
AppLogger::info("API Request: {$action}", array_merge([
|
||||
'method' => $this->request['method'],
|
||||
'uri' => $this->request['uri'],
|
||||
'user_agent' => $this->request['headers']['User-Agent'] ?? 'unknown'
|
||||
], $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key data from request
|
||||
*/
|
||||
protected function getApiKeyData() {
|
||||
// First check if it's in the current request (set by middleware)
|
||||
if (isset($this->request['api_key'])) {
|
||||
return $this->request['api_key'];
|
||||
}
|
||||
|
||||
// Check global request data set by middleware
|
||||
if (isset($GLOBALS['api_request']['api_key'])) {
|
||||
return $GLOBALS['api_request']['api_key'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API key has permission
|
||||
*/
|
||||
protected function hasPermission($permission) {
|
||||
$apiKeyData = $this->getApiKeyData();
|
||||
|
||||
AppLogger::debug('Permission check started', [
|
||||
'permission' => $permission,
|
||||
'has_api_key_data' => !empty($apiKeyData),
|
||||
'api_key_id' => $apiKeyData['id'] ?? 'none',
|
||||
'api_key_permissions' => $apiKeyData['permissions'] ?? 'none'
|
||||
]);
|
||||
|
||||
if (!$apiKeyData) {
|
||||
AppLogger::warning('Permission denied - no API key data found', [
|
||||
'permission' => $permission
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../../Models/ApiKey.php';
|
||||
$apiKeyModel = new ApiKey($this->db);
|
||||
|
||||
$result = $apiKeyModel->hasPermission($apiKeyData, $permission);
|
||||
|
||||
AppLogger::debug('Permission check result', [
|
||||
'permission' => $permission,
|
||||
'result' => $result,
|
||||
'api_key_id' => $apiKeyData['id'],
|
||||
'api_key_permissions' => $apiKeyData['permissions']
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the API key belongs to a superadmin user
|
||||
*/
|
||||
protected function requireSuperAdmin() {
|
||||
$apiKeyData = $this->getApiKeyData();
|
||||
if (!$apiKeyData) {
|
||||
$this->unauthorized('API key data not found');
|
||||
}
|
||||
|
||||
// Get user data from API key
|
||||
require_once __DIR__ . '/../../Models/User.php';
|
||||
$userModel = new User($this->db);
|
||||
$user = $userModel->find($apiKeyData['user_id']);
|
||||
|
||||
if (!$user || $user['role'] !== 'superadmin') {
|
||||
$this->forbidden('Access denied. API key management is restricted to super administrators.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
503
app/Controllers/Api/BankAccountsApiController.php
Normal file
503
app/Controllers/Api/BankAccountsApiController.php
Normal file
@@ -0,0 +1,503 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/BankAccount.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Bank Accounts",
|
||||
* description="Bank account management operations"
|
||||
* )
|
||||
*/
|
||||
class BankAccountsApiController extends ApiController {
|
||||
|
||||
private $bankAccountModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->bankAccountModel = new BankAccount($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/bank-accounts",
|
||||
* summary="Get all bank accounts",
|
||||
* tags={"Bank Accounts"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="currency",
|
||||
* in="query",
|
||||
* description="Filter by currency",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="account_type",
|
||||
* in="query",
|
||||
* description="Filter by account type",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", enum={"checking", "savings", "business", "investment"})
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Bank accounts retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Bank accounts retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/BankAccount")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Bank Accounts');
|
||||
|
||||
if (!$this->hasPermission('bank_accounts.read')) {
|
||||
$this->forbidden('Permission denied: bank_accounts.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$currency = $this->request['query']['currency'] ?? null;
|
||||
$accountType = $this->request['query']['account_type'] ?? null;
|
||||
|
||||
try {
|
||||
$conditions = [];
|
||||
|
||||
if ($currency) {
|
||||
$conditions['currency'] = $currency;
|
||||
}
|
||||
|
||||
if ($accountType) {
|
||||
$conditions['account_type'] = $accountType;
|
||||
}
|
||||
|
||||
$bankAccounts = $this->bankAccountModel->db->select('bank_accounts', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'bank_accounts.id',
|
||||
'bank_accounts.name',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_type',
|
||||
'bank_accounts.account_number_last4',
|
||||
'bank_accounts.currency',
|
||||
'bank_accounts.routing_number_last4',
|
||||
'bank_accounts.is_active',
|
||||
'bank_accounts.created_at',
|
||||
'bank_accounts.updated_at',
|
||||
'users.name(owner_name)',
|
||||
'users.email(owner_email)'
|
||||
], array_merge($conditions, [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['bank_accounts.created_at' => 'DESC']
|
||||
]));
|
||||
|
||||
$total = $this->bankAccountModel->db->count('bank_accounts', $conditions);
|
||||
|
||||
$response = $this->paginatedResponse($bankAccounts, $total, $pagination);
|
||||
$this->success($response, 'Bank accounts retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Bank Accounts', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve bank accounts');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/bank-accounts/{id}",
|
||||
* summary="Get bank account by ID",
|
||||
* tags={"Bank Accounts"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Bank account ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Bank account retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Bank account retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/BankAccount"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Bank Account', ['bank_account_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('bank_accounts.read')) {
|
||||
$this->forbidden('Permission denied: bank_accounts.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$bankAccount = $this->bankAccountModel->db->get('bank_accounts', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'bank_accounts.id',
|
||||
'bank_accounts.name',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_type',
|
||||
'bank_accounts.account_number_last4',
|
||||
'bank_accounts.currency',
|
||||
'bank_accounts.routing_number_last4',
|
||||
'bank_accounts.swift_code',
|
||||
'bank_accounts.iban',
|
||||
'bank_accounts.is_active',
|
||||
'bank_accounts.notes',
|
||||
'bank_accounts.created_at',
|
||||
'bank_accounts.updated_at',
|
||||
'users.name(owner_name)',
|
||||
'users.email(owner_email)'
|
||||
], ['bank_accounts.id' => $id]);
|
||||
|
||||
if (!$bankAccount) {
|
||||
$this->notFound('Bank account not found');
|
||||
}
|
||||
|
||||
$this->success($bankAccount, 'Bank account retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Bank Account', ['bank_account_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve bank account');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/bank-accounts",
|
||||
* summary="Create a new bank account",
|
||||
* tags={"Bank Accounts"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"name", "bank_name", "account_type", "account_number", "currency", "user_id"},
|
||||
* @OA\Property(property="name", type="string", example="Primary Checking"),
|
||||
* @OA\Property(property="bank_name", type="string", example="Bank of America"),
|
||||
* @OA\Property(property="account_type", type="string", enum={"checking", "savings", "business", "investment"}, example="checking"),
|
||||
* @OA\Property(property="account_number", type="string", example="1234567890"),
|
||||
* @OA\Property(property="routing_number", type="string", example="123456789"),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="swift_code", type="string", example="BOFAUS3N"),
|
||||
* @OA\Property(property="iban", type="string", example="GB29 NWBK 6016 1331 9268 19"),
|
||||
* @OA\Property(property="notes", type="string", example="Primary business account"),
|
||||
* @OA\Property(property="user_id", type="integer", example=1)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Bank account created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Bank account created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/BankAccount"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Bank Account');
|
||||
|
||||
if (!$this->hasPermission('bank_accounts.create')) {
|
||||
$this->forbidden('Permission denied: bank_accounts.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, ['name', 'bank_name', 'account_type', 'account_number', 'currency', 'user_id']);
|
||||
|
||||
// Additional validations
|
||||
$validAccountTypes = ['checking', 'savings', 'business', 'investment'];
|
||||
if (isset($data['account_type']) && !in_array($data['account_type'], $validAccountTypes)) {
|
||||
$errors['account_type'] = 'Account type must be one of: ' . implode(', ', $validAccountTypes);
|
||||
}
|
||||
|
||||
if (isset($data['user_id'])) {
|
||||
$userExists = $this->bankAccountModel->db->has('users', ['id' => $data['user_id']]);
|
||||
if (!$userExists) {
|
||||
$errors['user_id'] = 'User does not exist.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$accountData = [
|
||||
'user_id' => $data['user_id'],
|
||||
'name' => $data['name'],
|
||||
'bank_name' => $data['bank_name'],
|
||||
'account_type' => $data['account_type'],
|
||||
'account_number' => $data['account_number'],
|
||||
'account_number_last4' => substr($data['account_number'], -4),
|
||||
'routing_number' => $data['routing_number'] ?? null,
|
||||
'routing_number_last4' => isset($data['routing_number']) ? substr($data['routing_number'], -4) : null,
|
||||
'currency' => $data['currency'],
|
||||
'swift_code' => $data['swift_code'] ?? null,
|
||||
'iban' => $data['iban'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'is_active' => true,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$accountId = $this->bankAccountModel->create($accountData);
|
||||
|
||||
if ($accountId) {
|
||||
$bankAccount = $this->bankAccountModel->find($accountId);
|
||||
$this->success($bankAccount, 'Bank account created successfully', 201);
|
||||
} else {
|
||||
$this->serverError('Failed to create bank account');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Bank Account', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
$this->serverError('Failed to create bank account');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/bank-accounts/{id}",
|
||||
* summary="Update a bank account",
|
||||
* tags={"Bank Accounts"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Bank account ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="name", type="string", example="Primary Checking"),
|
||||
* @OA\Property(property="bank_name", type="string", example="Bank of America"),
|
||||
* @OA\Property(property="account_type", type="string", enum={"checking", "savings", "business", "investment"}, example="checking"),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="swift_code", type="string", example="BOFAUS3N"),
|
||||
* @OA\Property(property="iban", type="string", example="GB29 NWBK 6016 1331 9268 19"),
|
||||
* @OA\Property(property="notes", type="string", example="Primary business account"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Bank account updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Bank account updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/BankAccount"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Bank Account', ['bank_account_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('bank_accounts.update')) {
|
||||
$this->forbidden('Permission denied: bank_accounts.update required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Check if bank account exists
|
||||
$existingAccount = $this->bankAccountModel->find($id);
|
||||
if (!$existingAccount) {
|
||||
$this->notFound('Bank account not found');
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
$errors = [];
|
||||
$validAccountTypes = ['checking', 'savings', 'business', 'investment'];
|
||||
if (isset($data['account_type']) && !in_array($data['account_type'], $validAccountTypes)) {
|
||||
$errors['account_type'] = 'Account type must be one of: ' . implode(', ', $validAccountTypes);
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$updateData = array_merge($data, [
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$result = $this->bankAccountModel->update($id, $updateData);
|
||||
|
||||
if ($result) {
|
||||
$bankAccount = $this->bankAccountModel->find($id);
|
||||
$this->success($bankAccount, 'Bank account updated successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to update bank account');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Bank Account', ['bank_account_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update bank account');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/bank-accounts/{id}",
|
||||
* summary="Delete a bank account",
|
||||
* tags={"Bank Accounts"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Bank account ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Bank account deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Bank account deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=400, ref="#/components/responses/BadRequest"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Bank Account', ['bank_account_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('bank_accounts.delete')) {
|
||||
$this->forbidden('Permission denied: bank_accounts.delete required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingAccount = $this->bankAccountModel->find($id);
|
||||
if (!$existingAccount) {
|
||||
$this->notFound('Bank account not found');
|
||||
}
|
||||
|
||||
// Check if bank account is used in expenses
|
||||
$expenseCount = $this->bankAccountModel->db->count('expenses', ['bank_account_id' => $id]);
|
||||
if ($expenseCount > 0) {
|
||||
$this->error('Cannot delete bank account that is used in expenses. Please reassign or delete the expenses first.', 400);
|
||||
}
|
||||
|
||||
$result = $this->bankAccountModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
$this->success(null, 'Bank account deleted successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to delete bank account');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Bank Account', ['bank_account_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete bank account');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/bank-accounts/by-currency/{currency}",
|
||||
* summary="Get bank accounts by currency",
|
||||
* tags={"Bank Accounts"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="currency",
|
||||
* in="path",
|
||||
* description="Currency code",
|
||||
* required=true,
|
||||
* @OA\Schema(type="string", example="USD")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Bank accounts retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Bank accounts retrieved successfully"),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/BankAccount")),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function byCurrency($currency) {
|
||||
$this->logRequest('Get Bank Accounts by Currency', ['currency' => $currency]);
|
||||
|
||||
if (!$this->hasPermission('bank_accounts.read')) {
|
||||
$this->forbidden('Permission denied: bank_accounts.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$bankAccounts = $this->bankAccountModel->db->select('bank_accounts', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'bank_accounts.id',
|
||||
'bank_accounts.name',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_type',
|
||||
'bank_accounts.account_number_last4',
|
||||
'bank_accounts.currency',
|
||||
'bank_accounts.is_active',
|
||||
'users.name(owner_name)'
|
||||
], [
|
||||
'bank_accounts.currency' => $currency,
|
||||
'bank_accounts.is_active' => true,
|
||||
'ORDER' => ['bank_accounts.name' => 'ASC']
|
||||
]);
|
||||
|
||||
$this->success($bankAccounts, 'Bank accounts retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Bank Accounts by Currency', ['currency' => $currency, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve bank accounts');
|
||||
}
|
||||
}
|
||||
}
|
||||
493
app/Controllers/Api/CategoriesApiController.php
Normal file
493
app/Controllers/Api/CategoriesApiController.php
Normal file
@@ -0,0 +1,493 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/Category.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Categories",
|
||||
* description="Category management operations"
|
||||
* )
|
||||
*/
|
||||
class CategoriesApiController extends ApiController {
|
||||
|
||||
private $categoryModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->categoryModel = new Category($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories",
|
||||
* summary="Get all categories",
|
||||
* tags={"Categories"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="search",
|
||||
* in="query",
|
||||
* description="Search categories by name",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Categories retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Categories retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/Category")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Categories');
|
||||
|
||||
if (!$this->hasPermission('categories.read')) {
|
||||
$this->forbidden('Permission denied: categories.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$search = $this->request['query']['search'] ?? null;
|
||||
|
||||
try {
|
||||
$conditions = [];
|
||||
|
||||
if ($search) {
|
||||
$conditions['name[~]'] = $search;
|
||||
}
|
||||
|
||||
$categories = $this->categoryModel->db->select('categories', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'categories.id',
|
||||
'categories.name',
|
||||
'categories.description',
|
||||
'categories.color',
|
||||
'categories.icon',
|
||||
'categories.created_at',
|
||||
'categories.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], array_merge($conditions, [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['categories.name' => 'ASC']
|
||||
]));
|
||||
|
||||
// Add expense count for each category
|
||||
foreach ($categories as &$category) {
|
||||
$category['expense_count'] = $this->categoryModel->db->count('expenses', [
|
||||
'category_id' => $category['id']
|
||||
]);
|
||||
}
|
||||
|
||||
$total = $this->categoryModel->db->count('categories', $conditions);
|
||||
|
||||
$response = $this->paginatedResponse($categories, $total, $pagination);
|
||||
$this->success($response, 'Categories retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Categories', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve categories');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/{id}",
|
||||
* summary="Get category by ID",
|
||||
* tags={"Categories"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Category ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Category retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Category retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Category"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Category', ['category_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('categories.read')) {
|
||||
$this->forbidden('Permission denied: categories.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$category = $this->categoryModel->db->get('categories', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'categories.id',
|
||||
'categories.name',
|
||||
'categories.description',
|
||||
'categories.color',
|
||||
'categories.icon',
|
||||
'categories.created_at',
|
||||
'categories.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], ['categories.id' => $id]);
|
||||
|
||||
if (!$category) {
|
||||
$this->notFound('Category not found');
|
||||
}
|
||||
|
||||
// Add expense count
|
||||
$category['expense_count'] = $this->categoryModel->db->count('expenses', [
|
||||
'category_id' => $id
|
||||
]);
|
||||
|
||||
$this->success($category, 'Category retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Category', ['category_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve category');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/categories",
|
||||
* summary="Create a new category",
|
||||
* tags={"Categories"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"name", "user_id"},
|
||||
* @OA\Property(property="name", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="description", type="string", example="Office and business supplies"),
|
||||
* @OA\Property(property="color", type="string", example="#3B82F6"),
|
||||
* @OA\Property(property="icon", type="string", example="fas fa-briefcase"),
|
||||
* @OA\Property(property="user_id", type="integer", example=1)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Category created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Category created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Category"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Category');
|
||||
|
||||
if (!$this->hasPermission('categories.create')) {
|
||||
$this->forbidden('Permission denied: categories.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, ['name', 'user_id']);
|
||||
|
||||
// Additional validations
|
||||
if (isset($data['color']) && !preg_match('/^#[0-9A-Fa-f]{6}$/', $data['color'])) {
|
||||
$errors['color'] = 'Color must be a valid hex color code (e.g., #3B82F6).';
|
||||
}
|
||||
|
||||
if (isset($data['user_id'])) {
|
||||
$userExists = $this->categoryModel->db->has('users', ['id' => $data['user_id']]);
|
||||
if (!$userExists) {
|
||||
$errors['user_id'] = 'User does not exist.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$categoryData = [
|
||||
'user_id' => $data['user_id'],
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'color' => $data['color'] ?? '#3B82F6',
|
||||
'icon' => $data['icon'] ?? 'fas fa-folder',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$categoryId = $this->categoryModel->create($categoryData);
|
||||
|
||||
if ($categoryId) {
|
||||
$category = $this->categoryModel->find($categoryId);
|
||||
$this->success($category, 'Category created successfully', 201);
|
||||
} else {
|
||||
$this->serverError('Failed to create category');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Category', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
$this->serverError('Failed to create category');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/categories/{id}",
|
||||
* summary="Update a category",
|
||||
* tags={"Categories"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Category ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="name", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="description", type="string", example="Office and business supplies"),
|
||||
* @OA\Property(property="color", type="string", example="#3B82F6"),
|
||||
* @OA\Property(property="icon", type="string", example="fas fa-briefcase")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Category updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Category updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Category"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Category', ['category_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('categories.update')) {
|
||||
$this->forbidden('Permission denied: categories.update required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Check if category exists
|
||||
$existingCategory = $this->categoryModel->find($id);
|
||||
if (!$existingCategory) {
|
||||
$this->notFound('Category not found');
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
$errors = [];
|
||||
if (isset($data['color']) && !preg_match('/^#[0-9A-Fa-f]{6}$/', $data['color'])) {
|
||||
$errors['color'] = 'Color must be a valid hex color code (e.g., #3B82F6).';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$updateData = array_merge($data, [
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$result = $this->categoryModel->update($id, $updateData);
|
||||
|
||||
if ($result) {
|
||||
$category = $this->categoryModel->find($id);
|
||||
$this->success($category, 'Category updated successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to update category');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Category', ['category_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update category');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/categories/{id}",
|
||||
* summary="Delete a category",
|
||||
* tags={"Categories"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Category ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Category deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Category deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=400, ref="#/components/responses/BadRequest"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Category', ['category_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('categories.delete')) {
|
||||
$this->forbidden('Permission denied: categories.delete required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingCategory = $this->categoryModel->find($id);
|
||||
if (!$existingCategory) {
|
||||
$this->notFound('Category not found');
|
||||
}
|
||||
|
||||
// Check if category is used in expenses
|
||||
$expenseCount = $this->categoryModel->db->count('expenses', ['category_id' => $id]);
|
||||
if ($expenseCount > 0) {
|
||||
$this->error('Cannot delete category that is used in expenses. Please reassign or delete the expenses first.', 400);
|
||||
}
|
||||
|
||||
$result = $this->categoryModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
$this->success(null, 'Category deleted successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to delete category');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Category', ['category_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete category');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/categories/popular",
|
||||
* summary="Get popular categories",
|
||||
* tags={"Categories"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Number of categories to return",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=20, default=5)
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Popular categories retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Popular categories retrieved successfully"),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Category")),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function popular() {
|
||||
$this->logRequest('Get Popular Categories');
|
||||
|
||||
if (!$this->hasPermission('categories.read')) {
|
||||
$this->forbidden('Permission denied: categories.read required');
|
||||
}
|
||||
|
||||
$limit = min(20, max(1, (int)($this->request['query']['limit'] ?? 5)));
|
||||
|
||||
try {
|
||||
$categories = $this->categoryModel->db->select('categories', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'categories.id',
|
||||
'categories.name',
|
||||
'categories.description',
|
||||
'categories.color',
|
||||
'categories.icon',
|
||||
'categories.created_at',
|
||||
'users.name(creator_name)'
|
||||
], [
|
||||
'ORDER' => ['categories.created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
// Add expense count and filter/sort
|
||||
$categoriesWithCount = [];
|
||||
foreach ($categories as $category) {
|
||||
$category['expense_count'] = $this->categoryModel->db->count('expenses', [
|
||||
'category_id' => $category['id']
|
||||
]);
|
||||
if ($category['expense_count'] > 0) {
|
||||
$categoriesWithCount[] = $category;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by expense count descending
|
||||
usort($categoriesWithCount, function($a, $b) {
|
||||
return $b['expense_count'] - $a['expense_count'];
|
||||
});
|
||||
|
||||
// Limit results
|
||||
$popularCategories = array_slice($categoriesWithCount, 0, $limit);
|
||||
|
||||
$this->success($popularCategories, 'Popular categories retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Popular Categories', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve popular categories');
|
||||
}
|
||||
}
|
||||
}
|
||||
362
app/Controllers/Api/CreditCardsApiController.php
Normal file
362
app/Controllers/Api/CreditCardsApiController.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/CreditCard.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Credit Cards",
|
||||
* description="Credit card management operations"
|
||||
* )
|
||||
*/
|
||||
class CreditCardsApiController extends ApiController {
|
||||
|
||||
private $creditCardModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->creditCardModel = new CreditCard($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/credit-cards",
|
||||
* summary="Get all credit cards",
|
||||
* tags={"Credit Cards"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="user_id",
|
||||
* in="query",
|
||||
* description="Filter by user ID",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Credit cards retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Credit cards retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/CreditCard")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Credit Cards');
|
||||
|
||||
if (!$this->hasPermission('credit_cards.read')) {
|
||||
$this->forbidden('Permission denied: credit_cards.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$userId = $this->request['query']['user_id'] ?? null;
|
||||
|
||||
try {
|
||||
$conditions = [];
|
||||
if ($userId) {
|
||||
$conditions['user_id'] = $userId;
|
||||
}
|
||||
|
||||
$creditCards = $this->creditCardModel->db->select('credit_cards', [
|
||||
'id', 'user_id', 'card_number_masked', 'card_holder_name',
|
||||
'expiry_month', 'expiry_year', 'card_type', 'is_active', 'created_at', 'updated_at'
|
||||
], array_merge($conditions, [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]));
|
||||
|
||||
$total = $this->creditCardModel->db->count('credit_cards', $conditions);
|
||||
|
||||
$response = $this->paginatedResponse($creditCards, $total, $pagination);
|
||||
$this->success($response, 'Credit cards retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Credit Cards', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve credit cards');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/credit-cards/{id}",
|
||||
* summary="Get credit card by ID",
|
||||
* tags={"Credit Cards"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Credit card ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Credit card retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Credit card retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CreditCard"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Credit Card', ['credit_card_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('credit_cards.read')) {
|
||||
$this->forbidden('Permission denied: credit_cards.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$creditCard = $this->creditCardModel->db->get('credit_cards', [
|
||||
'id', 'user_id', 'card_number_masked', 'card_holder_name',
|
||||
'expiry_month', 'expiry_year', 'card_type', 'is_active', 'created_at', 'updated_at'
|
||||
], ['id' => $id]);
|
||||
|
||||
if (!$creditCard) {
|
||||
$this->notFound('Credit card not found');
|
||||
}
|
||||
|
||||
$this->success($creditCard, 'Credit card retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Credit Card', ['credit_card_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve credit card');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/credit-cards",
|
||||
* summary="Create a new credit card",
|
||||
* tags={"Credit Cards"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"user_id", "card_number", "card_holder_name", "expiry_month", "expiry_year", "cvv"},
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="card_number", type="string", example="4111111111111111"),
|
||||
* @OA\Property(property="card_holder_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="expiry_month", type="integer", minimum=1, maximum=12, example=12),
|
||||
* @OA\Property(property="expiry_year", type="integer", example=2025),
|
||||
* @OA\Property(property="cvv", type="string", example="123"),
|
||||
* @OA\Property(property="card_type", type="string", example="visa")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Credit card created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Credit card created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CreditCard"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Credit Card');
|
||||
|
||||
if (!$this->hasPermission('credit_cards.create')) {
|
||||
$this->forbidden('Permission denied: credit_cards.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, [
|
||||
'user_id', 'card_number', 'card_holder_name', 'expiry_month', 'expiry_year', 'cvv'
|
||||
]);
|
||||
|
||||
// Additional validations
|
||||
if (isset($data['expiry_month']) && ($data['expiry_month'] < 1 || $data['expiry_month'] > 12)) {
|
||||
$errors['expiry_month'] = 'Expiry month must be between 1 and 12.';
|
||||
}
|
||||
|
||||
if (isset($data['expiry_year']) && $data['expiry_year'] < date('Y')) {
|
||||
$errors['expiry_year'] = 'Expiry year cannot be in the past.';
|
||||
}
|
||||
|
||||
if (isset($data['card_number']) && !preg_match('/^\d{13,19}$/', $data['card_number'])) {
|
||||
$errors['card_number'] = 'Card number must be 13-19 digits.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$creditCardId = $this->creditCardModel->create($data);
|
||||
|
||||
$creditCard = $this->creditCardModel->find($creditCardId);
|
||||
|
||||
$this->success($creditCard, 'Credit card created successfully', 201);
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Credit Card', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
$this->serverError('Failed to create credit card');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/credit-cards/{id}",
|
||||
* summary="Update a credit card",
|
||||
* tags={"Credit Cards"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Credit card ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="card_holder_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="expiry_month", type="integer", minimum=1, maximum=12, example=12),
|
||||
* @OA\Property(property="expiry_year", type="integer", example=2025),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Credit card updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Credit card updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CreditCard"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Credit Card', ['credit_card_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('credit_cards.update')) {
|
||||
$this->forbidden('Permission denied: credit_cards.update required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Check if credit card exists
|
||||
$existingCard = $this->creditCardModel->find($id);
|
||||
if (!$existingCard) {
|
||||
$this->notFound('Credit card not found');
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
$errors = [];
|
||||
if (isset($data['expiry_month']) && ($data['expiry_month'] < 1 || $data['expiry_month'] > 12)) {
|
||||
$errors['expiry_month'] = 'Expiry month must be between 1 and 12.';
|
||||
}
|
||||
|
||||
if (isset($data['expiry_year']) && $data['expiry_year'] < date('Y')) {
|
||||
$errors['expiry_year'] = 'Expiry year cannot be in the past.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->creditCardModel->update($id, $data);
|
||||
|
||||
$creditCard = $this->creditCardModel->find($id);
|
||||
|
||||
$this->success($creditCard, 'Credit card updated successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Credit Card', ['credit_card_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update credit card');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/credit-cards/{id}",
|
||||
* summary="Delete a credit card",
|
||||
* tags={"Credit Cards"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Credit card ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Credit card deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Credit card deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Credit Card', ['credit_card_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('credit_cards.delete')) {
|
||||
$this->forbidden('Permission denied: credit_cards.delete required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingCard = $this->creditCardModel->find($id);
|
||||
if (!$existingCard) {
|
||||
$this->notFound('Credit card not found');
|
||||
}
|
||||
|
||||
$this->creditCardModel->delete($id);
|
||||
|
||||
$this->success(null, 'Credit card deleted successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Credit Card', ['credit_card_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete credit card');
|
||||
}
|
||||
}
|
||||
}
|
||||
550
app/Controllers/Api/CryptoWalletsApiController.php
Normal file
550
app/Controllers/Api/CryptoWalletsApiController.php
Normal file
@@ -0,0 +1,550 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/CryptoWallet.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Crypto Wallets",
|
||||
* description="Crypto wallet management operations"
|
||||
* )
|
||||
*/
|
||||
class CryptoWalletsApiController extends ApiController {
|
||||
|
||||
private $cryptoWalletModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->cryptoWalletModel = new CryptoWallet($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/crypto-wallets",
|
||||
* summary="Get all crypto wallets",
|
||||
* tags={"Crypto Wallets"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="currency",
|
||||
* in="query",
|
||||
* description="Filter by currency",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="network",
|
||||
* in="query",
|
||||
* description="Filter by network",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Crypto wallets retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Crypto wallets retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/CryptoWallet")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Crypto Wallets');
|
||||
|
||||
if (!$this->hasPermission('crypto_wallets.read')) {
|
||||
$this->forbidden('Permission denied: crypto_wallets.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$currency = $this->request['query']['currency'] ?? null;
|
||||
$network = $this->request['query']['network'] ?? null;
|
||||
|
||||
try {
|
||||
$conditions = [];
|
||||
|
||||
if ($currency) {
|
||||
$conditions['currency'] = $currency;
|
||||
}
|
||||
|
||||
if ($network) {
|
||||
$conditions['network'] = $network;
|
||||
}
|
||||
|
||||
$cryptoWallets = $this->cryptoWalletModel->db->select('crypto_wallets', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'crypto_wallets.id',
|
||||
'crypto_wallets.name',
|
||||
'crypto_wallets.currency',
|
||||
'crypto_wallets.network',
|
||||
'crypto_wallets.address_masked',
|
||||
'crypto_wallets.wallet_type',
|
||||
'crypto_wallets.is_active',
|
||||
'crypto_wallets.created_at',
|
||||
'crypto_wallets.updated_at',
|
||||
'users.name(owner_name)',
|
||||
'users.email(owner_email)'
|
||||
], array_merge($conditions, [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['crypto_wallets.created_at' => 'DESC']
|
||||
]));
|
||||
|
||||
$total = $this->cryptoWalletModel->db->count('crypto_wallets', $conditions);
|
||||
|
||||
$response = $this->paginatedResponse($cryptoWallets, $total, $pagination);
|
||||
$this->success($response, 'Crypto wallets retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Crypto Wallets', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve crypto wallets');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/crypto-wallets/{id}",
|
||||
* summary="Get crypto wallet by ID",
|
||||
* tags={"Crypto Wallets"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Crypto wallet ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Crypto wallet retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Crypto wallet retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CryptoWallet"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Crypto Wallet', ['crypto_wallet_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('crypto_wallets.read')) {
|
||||
$this->forbidden('Permission denied: crypto_wallets.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$cryptoWallet = $this->cryptoWalletModel->db->get('crypto_wallets', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'crypto_wallets.id',
|
||||
'crypto_wallets.name',
|
||||
'crypto_wallets.currency',
|
||||
'crypto_wallets.network',
|
||||
'crypto_wallets.address_masked',
|
||||
'crypto_wallets.wallet_type',
|
||||
'crypto_wallets.is_active',
|
||||
'crypto_wallets.notes',
|
||||
'crypto_wallets.created_at',
|
||||
'crypto_wallets.updated_at',
|
||||
'users.name(owner_name)',
|
||||
'users.email(owner_email)'
|
||||
], ['crypto_wallets.id' => $id]);
|
||||
|
||||
if (!$cryptoWallet) {
|
||||
$this->notFound('Crypto wallet not found');
|
||||
}
|
||||
|
||||
$this->success($cryptoWallet, 'Crypto wallet retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Crypto Wallet', ['crypto_wallet_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve crypto wallet');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/crypto-wallets",
|
||||
* summary="Create a new crypto wallet",
|
||||
* tags={"Crypto Wallets"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"name", "currency", "network", "address", "wallet_type", "user_id"},
|
||||
* @OA\Property(property="name", type="string", example="Main Bitcoin Wallet"),
|
||||
* @OA\Property(property="currency", type="string", example="BTC"),
|
||||
* @OA\Property(property="network", type="string", example="Bitcoin"),
|
||||
* @OA\Property(property="address", type="string", example="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
|
||||
* @OA\Property(property="wallet_type", type="string", enum={"hot", "cold", "hardware", "exchange"}, example="hardware"),
|
||||
* @OA\Property(property="notes", type="string", example="Hardware wallet for long-term storage"),
|
||||
* @OA\Property(property="user_id", type="integer", example=1)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Crypto wallet created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Crypto wallet created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CryptoWallet"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Crypto Wallet');
|
||||
|
||||
if (!$this->hasPermission('crypto_wallets.create')) {
|
||||
$this->forbidden('Permission denied: crypto_wallets.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, ['name', 'currency', 'network', 'address', 'wallet_type', 'user_id']);
|
||||
|
||||
// Additional validations
|
||||
$validWalletTypes = ['hot', 'cold', 'hardware', 'exchange'];
|
||||
if (isset($data['wallet_type']) && !in_array($data['wallet_type'], $validWalletTypes)) {
|
||||
$errors['wallet_type'] = 'Wallet type must be one of: ' . implode(', ', $validWalletTypes);
|
||||
}
|
||||
|
||||
if (isset($data['user_id'])) {
|
||||
$userExists = $this->cryptoWalletModel->db->has('users', ['id' => $data['user_id']]);
|
||||
if (!$userExists) {
|
||||
$errors['user_id'] = 'User does not exist.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$walletData = [
|
||||
'user_id' => $data['user_id'],
|
||||
'name' => $data['name'],
|
||||
'currency' => $data['currency'],
|
||||
'network' => $data['network'],
|
||||
'address' => $data['address'],
|
||||
'address_masked' => substr($data['address'], 0, 6) . '...' . substr($data['address'], -4),
|
||||
'wallet_type' => $data['wallet_type'],
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'is_active' => true,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$walletId = $this->cryptoWalletModel->create($walletData);
|
||||
|
||||
if ($walletId) {
|
||||
$cryptoWallet = $this->cryptoWalletModel->find($walletId);
|
||||
$this->success($cryptoWallet, 'Crypto wallet created successfully', 201);
|
||||
} else {
|
||||
$this->serverError('Failed to create crypto wallet');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Crypto Wallet', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
$this->serverError('Failed to create crypto wallet');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/crypto-wallets/{id}",
|
||||
* summary="Update a crypto wallet",
|
||||
* tags={"Crypto Wallets"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Crypto wallet ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="name", type="string", example="Main Bitcoin Wallet"),
|
||||
* @OA\Property(property="currency", type="string", example="BTC"),
|
||||
* @OA\Property(property="network", type="string", example="Bitcoin"),
|
||||
* @OA\Property(property="wallet_type", type="string", enum={"hot", "cold", "hardware", "exchange"}, example="hardware"),
|
||||
* @OA\Property(property="notes", type="string", example="Hardware wallet for long-term storage"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Crypto wallet updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Crypto wallet updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/CryptoWallet"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Crypto Wallet', ['crypto_wallet_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('crypto_wallets.update')) {
|
||||
$this->forbidden('Permission denied: crypto_wallets.update required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Check if crypto wallet exists
|
||||
$existingWallet = $this->cryptoWalletModel->find($id);
|
||||
if (!$existingWallet) {
|
||||
$this->notFound('Crypto wallet not found');
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
$errors = [];
|
||||
$validWalletTypes = ['hot', 'cold', 'hardware', 'exchange'];
|
||||
if (isset($data['wallet_type']) && !in_array($data['wallet_type'], $validWalletTypes)) {
|
||||
$errors['wallet_type'] = 'Wallet type must be one of: ' . implode(', ', $validWalletTypes);
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$updateData = array_merge($data, [
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$result = $this->cryptoWalletModel->update($id, $updateData);
|
||||
|
||||
if ($result) {
|
||||
$cryptoWallet = $this->cryptoWalletModel->find($id);
|
||||
$this->success($cryptoWallet, 'Crypto wallet updated successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to update crypto wallet');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Crypto Wallet', ['crypto_wallet_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update crypto wallet');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/crypto-wallets/{id}",
|
||||
* summary="Delete a crypto wallet",
|
||||
* tags={"Crypto Wallets"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Crypto wallet ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Crypto wallet deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Crypto wallet deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=400, ref="#/components/responses/BadRequest"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Crypto Wallet', ['crypto_wallet_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('crypto_wallets.delete')) {
|
||||
$this->forbidden('Permission denied: crypto_wallets.delete required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingWallet = $this->cryptoWalletModel->find($id);
|
||||
if (!$existingWallet) {
|
||||
$this->notFound('Crypto wallet not found');
|
||||
}
|
||||
|
||||
// Check if crypto wallet is used in expenses
|
||||
$expenseCount = $this->cryptoWalletModel->db->count('expenses', ['crypto_wallet_id' => $id]);
|
||||
if ($expenseCount > 0) {
|
||||
$this->error('Cannot delete crypto wallet that is used in expenses. Please reassign or delete the expenses first.', 400);
|
||||
}
|
||||
|
||||
$result = $this->cryptoWalletModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
$this->success(null, 'Crypto wallet deleted successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to delete crypto wallet');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Crypto Wallet', ['crypto_wallet_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete crypto wallet');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/crypto-wallets/by-currency/{currency}",
|
||||
* summary="Get crypto wallets by currency",
|
||||
* tags={"Crypto Wallets"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="currency",
|
||||
* in="path",
|
||||
* description="Currency code",
|
||||
* required=true,
|
||||
* @OA\Schema(type="string", example="BTC")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Crypto wallets retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Crypto wallets retrieved successfully"),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CryptoWallet")),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function byCurrency($currency) {
|
||||
$this->logRequest('Get Crypto Wallets by Currency', ['currency' => $currency]);
|
||||
|
||||
if (!$this->hasPermission('crypto_wallets.read')) {
|
||||
$this->forbidden('Permission denied: crypto_wallets.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$cryptoWallets = $this->cryptoWalletModel->db->select('crypto_wallets', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'crypto_wallets.id',
|
||||
'crypto_wallets.name',
|
||||
'crypto_wallets.currency',
|
||||
'crypto_wallets.network',
|
||||
'crypto_wallets.address_masked',
|
||||
'crypto_wallets.wallet_type',
|
||||
'crypto_wallets.is_active',
|
||||
'users.name(owner_name)'
|
||||
], [
|
||||
'crypto_wallets.currency' => $currency,
|
||||
'crypto_wallets.is_active' => true,
|
||||
'ORDER' => ['crypto_wallets.name' => 'ASC']
|
||||
]);
|
||||
|
||||
$this->success($cryptoWallets, 'Crypto wallets retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Crypto Wallets by Currency', ['currency' => $currency, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve crypto wallets');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/crypto-wallets/by-network/{network}",
|
||||
* summary="Get crypto wallets by network",
|
||||
* tags={"Crypto Wallets"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="network",
|
||||
* in="path",
|
||||
* description="Network name",
|
||||
* required=true,
|
||||
* @OA\Schema(type="string", example="Ethereum")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Crypto wallets retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Crypto wallets retrieved successfully"),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/CryptoWallet")),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function byNetwork($network) {
|
||||
$this->logRequest('Get Crypto Wallets by Network', ['network' => $network]);
|
||||
|
||||
if (!$this->hasPermission('crypto_wallets.read')) {
|
||||
$this->forbidden('Permission denied: crypto_wallets.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$cryptoWallets = $this->cryptoWalletModel->db->select('crypto_wallets', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'crypto_wallets.id',
|
||||
'crypto_wallets.name',
|
||||
'crypto_wallets.currency',
|
||||
'crypto_wallets.network',
|
||||
'crypto_wallets.address_masked',
|
||||
'crypto_wallets.wallet_type',
|
||||
'crypto_wallets.is_active',
|
||||
'users.name(owner_name)'
|
||||
], [
|
||||
'crypto_wallets.network' => $network,
|
||||
'crypto_wallets.is_active' => true,
|
||||
'ORDER' => ['crypto_wallets.name' => 'ASC']
|
||||
]);
|
||||
|
||||
$this->success($cryptoWallets, 'Crypto wallets retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Crypto Wallets by Network', ['network' => $network, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve crypto wallets');
|
||||
}
|
||||
}
|
||||
}
|
||||
557
app/Controllers/Api/ExpensesApiController.php
Normal file
557
app/Controllers/Api/ExpensesApiController.php
Normal file
@@ -0,0 +1,557 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/Expense.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Expenses",
|
||||
* description="Expense management operations"
|
||||
* )
|
||||
*/
|
||||
class ExpensesApiController extends ApiController {
|
||||
|
||||
private $expenseModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->expenseModel = new Expense($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/expenses",
|
||||
* summary="Get all expenses",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="user_id",
|
||||
* in="query",
|
||||
* description="Filter by user ID",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="status",
|
||||
* in="query",
|
||||
* description="Filter by status",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", enum={"pending", "approved", "rejected"})
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="category_id",
|
||||
* in="query",
|
||||
* description="Filter by category ID",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Expenses retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Expenses retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/Expense")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Expenses');
|
||||
|
||||
if (!$this->hasPermission('expenses.read')) {
|
||||
$this->forbidden('Permission denied: expenses.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$filters = [
|
||||
'user_id' => $this->request['query']['user_id'] ?? null,
|
||||
'status' => $this->request['query']['status'] ?? null,
|
||||
'category_id' => $this->request['query']['category_id'] ?? null,
|
||||
'search' => $this->request['query']['search'] ?? null
|
||||
];
|
||||
|
||||
try {
|
||||
$expenses = $this->expenseModel->getExpensesWithFilters(
|
||||
$filters['user_id'],
|
||||
$filters,
|
||||
$pagination['page'],
|
||||
$pagination['limit']
|
||||
);
|
||||
|
||||
$total = $this->expenseModel->countExpensesWithFilters($filters['user_id'], $filters);
|
||||
|
||||
$response = $this->paginatedResponse($expenses, $total, $pagination);
|
||||
$this->success($response, 'Expenses retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Expenses', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve expenses');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/expenses/{id}",
|
||||
* summary="Get expense by ID",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Expense ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Expense retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Expense retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Expense"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Expense', ['expense_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('expenses.read')) {
|
||||
$this->forbidden('Permission denied: expenses.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$expense = $this->expenseModel->find($id);
|
||||
|
||||
if (!$expense) {
|
||||
$this->notFound('Expense not found');
|
||||
}
|
||||
|
||||
// Get expense tags
|
||||
$expense['tags'] = $this->expenseModel->getExpenseTags($id);
|
||||
|
||||
$this->success($expense, 'Expense retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Expense', ['expense_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve expense');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/expenses",
|
||||
* summary="Create a new expense",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"user_id", "title", "amount", "payment_method", "payment_id", "expense_date"},
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="title", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="description", type="string", example="Monthly office supplies purchase"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=150.75),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="category_id", type="integer", example=1),
|
||||
* @OA\Property(property="tag_ids", type="array", @OA\Items(type="integer")),
|
||||
* @OA\Property(property="payment_method", type="string", enum={"credit_card", "bank_account", "crypto_wallet"}),
|
||||
* @OA\Property(property="payment_id", type="integer", example=1),
|
||||
* @OA\Property(property="expense_date", type="string", format="date", example="2024-01-15"),
|
||||
* @OA\Property(property="due_date", type="string", format="date", example="2024-02-15"),
|
||||
* @OA\Property(property="tax_rate", type="number", format="float", example=8.5),
|
||||
* @OA\Property(property="notes", type="string", example="Business expense"),
|
||||
* @OA\Property(property="status", type="string", enum={"pending", "approved", "rejected"}, example="pending")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Expense created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Expense created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Expense"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Expense');
|
||||
|
||||
if (!$this->hasPermission('expenses.create')) {
|
||||
$this->forbidden('Permission denied: expenses.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, [
|
||||
'user_id', 'title', 'amount', 'payment_method', 'payment_id', 'expense_date'
|
||||
]);
|
||||
|
||||
// Additional validations
|
||||
if (isset($data['amount']) && $data['amount'] <= 0) {
|
||||
$errors['amount'] = 'Amount must be greater than 0.';
|
||||
}
|
||||
|
||||
if (isset($data['payment_method']) && !in_array($data['payment_method'], ['credit_card', 'bank_account', 'crypto_wallet'])) {
|
||||
$errors['payment_method'] = 'Invalid payment method.';
|
||||
}
|
||||
|
||||
if (isset($data['status']) && !in_array($data['status'], ['pending', 'approved', 'rejected'])) {
|
||||
$errors['status'] = 'Invalid status.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract tag IDs
|
||||
$tagIds = $data['tag_ids'] ?? [];
|
||||
unset($data['tag_ids']);
|
||||
|
||||
// Calculate tax amount
|
||||
$taxRate = floatval($data['tax_rate'] ?? 0);
|
||||
$amount = floatval($data['amount']);
|
||||
$taxAmount = ($taxRate > 0) ? ($amount * $taxRate / 100) : 0;
|
||||
|
||||
$data['tax_amount'] = $taxAmount;
|
||||
$data['total_amount'] = $amount + $taxAmount;
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
$expenseId = $this->expenseModel->create($data);
|
||||
|
||||
// Add tags if provided
|
||||
if (!empty($tagIds)) {
|
||||
$this->expenseModel->addTags($expenseId, $tagIds);
|
||||
}
|
||||
|
||||
// Generate transaction if approved
|
||||
if (($data['status'] ?? 'pending') === 'approved') {
|
||||
$this->expenseModel->generateTransaction($expenseId);
|
||||
}
|
||||
|
||||
$expense = $this->expenseModel->find($expenseId);
|
||||
$expense['tags'] = $this->expenseModel->getExpenseTags($expenseId);
|
||||
|
||||
$this->success($expense, 'Expense created successfully', 201);
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Expense', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
$this->serverError('Failed to create expense');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/expenses/{id}",
|
||||
* summary="Update an expense",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Expense ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="title", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="description", type="string", example="Monthly office supplies purchase"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=150.75),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="category_id", type="integer", example=1),
|
||||
* @OA\Property(property="tag_ids", type="array", @OA\Items(type="integer")),
|
||||
* @OA\Property(property="expense_date", type="string", format="date", example="2024-01-15"),
|
||||
* @OA\Property(property="due_date", type="string", format="date", example="2024-02-15"),
|
||||
* @OA\Property(property="tax_rate", type="number", format="float", example=8.5),
|
||||
* @OA\Property(property="notes", type="string", example="Business expense"),
|
||||
* @OA\Property(property="status", type="string", enum={"pending", "approved", "rejected"}, example="pending")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Expense updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Expense updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Expense"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Expense', ['expense_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('expenses.update')) {
|
||||
$this->forbidden('Permission denied: expenses.update required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Check if expense exists
|
||||
$existingExpense = $this->expenseModel->find($id);
|
||||
if (!$existingExpense) {
|
||||
$this->notFound('Expense not found');
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
$errors = [];
|
||||
if (isset($data['amount']) && $data['amount'] <= 0) {
|
||||
$errors['amount'] = 'Amount must be greater than 0.';
|
||||
}
|
||||
|
||||
if (isset($data['status']) && !in_array($data['status'], ['pending', 'approved', 'rejected'])) {
|
||||
$errors['status'] = 'Invalid status.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract tag IDs
|
||||
$tagIds = $data['tag_ids'] ?? null;
|
||||
unset($data['tag_ids']);
|
||||
|
||||
// Recalculate tax if amount or tax_rate changed
|
||||
if (isset($data['amount']) || isset($data['tax_rate'])) {
|
||||
$amount = floatval($data['amount'] ?? $existingExpense['amount']);
|
||||
$taxRate = floatval($data['tax_rate'] ?? $existingExpense['tax_rate']);
|
||||
$taxAmount = ($taxRate > 0) ? ($amount * $taxRate / 100) : 0;
|
||||
|
||||
$data['tax_amount'] = $taxAmount;
|
||||
$data['total_amount'] = $amount + $taxAmount;
|
||||
}
|
||||
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
$this->expenseModel->update($id, $data);
|
||||
|
||||
// Update tags if provided
|
||||
if ($tagIds !== null) {
|
||||
$this->expenseModel->removeTags($id);
|
||||
if (!empty($tagIds)) {
|
||||
$this->expenseModel->addTags($id, $tagIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle transaction generation based on status change
|
||||
if (isset($data['status'])) {
|
||||
if ($data['status'] === 'approved' && $existingExpense['status'] !== 'approved') {
|
||||
$this->expenseModel->generateTransaction($id);
|
||||
} elseif ($data['status'] !== 'approved' && $existingExpense['status'] === 'approved') {
|
||||
$this->expenseModel->removeTransaction($id);
|
||||
}
|
||||
}
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
$expense['tags'] = $this->expenseModel->getExpenseTags($id);
|
||||
|
||||
$this->success($expense, 'Expense updated successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Expense', ['expense_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update expense');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/expenses/{id}",
|
||||
* summary="Delete an expense",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Expense ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Expense deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Expense deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Expense', ['expense_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('expenses.delete')) {
|
||||
$this->forbidden('Permission denied: expenses.delete required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingExpense = $this->expenseModel->find($id);
|
||||
if (!$existingExpense) {
|
||||
$this->notFound('Expense not found');
|
||||
}
|
||||
|
||||
$this->expenseModel->delete($id);
|
||||
|
||||
$this->success(null, 'Expense deleted successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Expense', ['expense_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete expense');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/expenses/{id}/approve",
|
||||
* summary="Approve an expense",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Expense ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Expense approved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Expense approved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Expense"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function approve($id) {
|
||||
$this->logRequest('Approve Expense', ['expense_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('expenses.approve')) {
|
||||
$this->forbidden('Permission denied: expenses.approve required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingExpense = $this->expenseModel->find($id);
|
||||
if (!$existingExpense) {
|
||||
$this->notFound('Expense not found');
|
||||
}
|
||||
|
||||
$this->expenseModel->approve($id);
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
$expense['tags'] = $this->expenseModel->getExpenseTags($id);
|
||||
|
||||
$this->success($expense, 'Expense approved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Approve Expense', ['expense_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to approve expense');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/expenses/analytics",
|
||||
* summary="Get expense analytics",
|
||||
* tags={"Expenses"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="user_id",
|
||||
* in="query",
|
||||
* description="Filter by user ID",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="period",
|
||||
* in="query",
|
||||
* description="Time period for analytics",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", enum={"week", "month", "quarter", "year"}, default="month")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Analytics retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Analytics retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function analytics() {
|
||||
$this->logRequest('Get Expense Analytics');
|
||||
|
||||
if (!$this->hasPermission('expenses.analytics')) {
|
||||
$this->forbidden('Permission denied: expenses.analytics required');
|
||||
}
|
||||
|
||||
$userId = $this->request['query']['user_id'] ?? null;
|
||||
$period = $this->request['query']['period'] ?? 'month';
|
||||
|
||||
try {
|
||||
$analytics = $this->expenseModel->getAnalytics($userId, $period);
|
||||
|
||||
$this->success($analytics, 'Analytics retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Expense Analytics', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve analytics');
|
||||
}
|
||||
}
|
||||
}
|
||||
644
app/Controllers/Api/ReportsApiController.php
Normal file
644
app/Controllers/Api/ReportsApiController.php
Normal file
@@ -0,0 +1,644 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Reports",
|
||||
* description="Report and analytics operations"
|
||||
* )
|
||||
*/
|
||||
class ReportsApiController extends ApiController {
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/reports/dashboard",
|
||||
* summary="Get dashboard statistics",
|
||||
* tags={"Reports"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="from_date",
|
||||
* in="query",
|
||||
* description="Start date for filtering (Y-m-d format)",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", format="date")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="to_date",
|
||||
* in="query",
|
||||
* description="End date for filtering (Y-m-d format)",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", format="date")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Dashboard statistics retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Dashboard statistics retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="total_expenses", type="number", format="float", example=1250.50),
|
||||
* @OA\Property(property="total_subscriptions", type="integer", example=15),
|
||||
* @OA\Property(property="active_subscriptions", type="integer", example=12),
|
||||
* @OA\Property(property="total_monthly_cost", type="number", format="float", example=89.99),
|
||||
* @OA\Property(property="total_annual_cost", type="number", format="float", example=1079.88),
|
||||
* @OA\Property(property="expense_count", type="integer", example=42),
|
||||
* @OA\Property(property="categories_count", type="integer", example=8),
|
||||
* @OA\Property(property="tags_count", type="integer", example=15)
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function dashboard() {
|
||||
$this->logRequest('Get Dashboard Statistics');
|
||||
|
||||
if (!$this->hasPermission('reports.read')) {
|
||||
$this->forbidden('Permission denied: reports.read required');
|
||||
}
|
||||
|
||||
$fromDate = $this->request['query']['from_date'] ?? null;
|
||||
$toDate = $this->request['query']['to_date'] ?? null;
|
||||
|
||||
try {
|
||||
$stats = [];
|
||||
|
||||
// Base conditions for date filtering
|
||||
$dateConditions = [];
|
||||
if ($fromDate) {
|
||||
$dateConditions['created_at[>=]'] = $fromDate . ' 00:00:00';
|
||||
}
|
||||
if ($toDate) {
|
||||
$dateConditions['created_at[<=]'] = $toDate . ' 23:59:59';
|
||||
}
|
||||
|
||||
// Total expenses
|
||||
$expenseConditions = $dateConditions;
|
||||
$stats['total_expenses'] = (float) $this->db->sum('expenses', 'amount', $expenseConditions);
|
||||
$stats['expense_count'] = $this->db->count('expenses', $expenseConditions);
|
||||
|
||||
// Subscription statistics
|
||||
$subscriptionConditions = $dateConditions;
|
||||
$stats['total_subscriptions'] = $this->db->count('subscriptions', $subscriptionConditions);
|
||||
$stats['active_subscriptions'] = $this->db->count('subscriptions', array_merge($subscriptionConditions, ['status' => 'active']));
|
||||
$stats['expired_subscriptions'] = $this->db->count('subscriptions', array_merge($subscriptionConditions, ['status' => 'expired']));
|
||||
$stats['cancelled_subscriptions'] = $this->db->count('subscriptions', array_merge($subscriptionConditions, ['status' => 'cancelled']));
|
||||
|
||||
// Monthly and annual costs
|
||||
$activeSubscriptions = $this->db->select('subscriptions', ['amount', 'billing_cycle'], array_merge($subscriptionConditions, ['status' => 'active']));
|
||||
|
||||
$monthlyTotal = 0;
|
||||
$annualTotal = 0;
|
||||
foreach ($activeSubscriptions as $subscription) {
|
||||
$amount = (float) $subscription['amount'];
|
||||
switch ($subscription['billing_cycle']) {
|
||||
case 'monthly':
|
||||
$monthlyTotal += $amount;
|
||||
$annualTotal += $amount * 12;
|
||||
break;
|
||||
case 'annual':
|
||||
$monthlyTotal += $amount / 12;
|
||||
$annualTotal += $amount;
|
||||
break;
|
||||
case 'weekly':
|
||||
$monthlyTotal += $amount * 4.33;
|
||||
$annualTotal += $amount * 52;
|
||||
break;
|
||||
case 'daily':
|
||||
$monthlyTotal += $amount * 30;
|
||||
$annualTotal += $amount * 365;
|
||||
break;
|
||||
case 'onetime':
|
||||
// One-time payments don't contribute to recurring costs
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$stats['total_monthly_cost'] = round($monthlyTotal, 2);
|
||||
$stats['total_annual_cost'] = round($annualTotal, 2);
|
||||
$stats['avg_monthly_spend'] = $stats['active_subscriptions'] > 0 ? round($monthlyTotal / $stats['active_subscriptions'], 2) : 0;
|
||||
|
||||
// One-time costs
|
||||
$onetimeSubscriptions = $this->db->select('subscriptions', ['amount'], array_merge($subscriptionConditions, ['billing_cycle' => 'onetime']));
|
||||
$stats['total_onetime_cost'] = (float) array_sum(array_column($onetimeSubscriptions, 'amount'));
|
||||
|
||||
// Categories and tags count
|
||||
$stats['categories_count'] = $this->db->count('categories');
|
||||
$stats['tags_count'] = $this->db->count('tags');
|
||||
|
||||
// Bank accounts and crypto wallets
|
||||
$stats['bank_accounts_count'] = $this->db->count('bank_accounts', ['is_active' => true]);
|
||||
$stats['crypto_wallets_count'] = $this->db->count('crypto_wallets', ['is_active' => true]);
|
||||
$stats['credit_cards_count'] = $this->db->count('credit_cards', ['is_active' => true]);
|
||||
|
||||
$this->success($stats, 'Dashboard statistics retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Dashboard Statistics', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve dashboard statistics');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/reports/expenses",
|
||||
* summary="Get expense analytics",
|
||||
* tags={"Reports"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="from_date",
|
||||
* in="query",
|
||||
* description="Start date for filtering (Y-m-d format)",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", format="date")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="to_date",
|
||||
* in="query",
|
||||
* description="End date for filtering (Y-m-d format)",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", format="date")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="group_by",
|
||||
* in="query",
|
||||
* description="Group expenses by period",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", enum={"day", "week", "month", "year"}, default="month")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Expense analytics retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Expense analytics retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="by_category", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="by_period", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="by_payment_method", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="summary", type="object")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function expenses() {
|
||||
$this->logRequest('Get Expense Analytics');
|
||||
|
||||
if (!$this->hasPermission('reports.read')) {
|
||||
$this->forbidden('Permission denied: reports.read required');
|
||||
}
|
||||
|
||||
$fromDate = $this->request['query']['from_date'] ?? null;
|
||||
$toDate = $this->request['query']['to_date'] ?? null;
|
||||
$groupBy = $this->request['query']['group_by'] ?? 'month';
|
||||
|
||||
try {
|
||||
$dateConditions = [];
|
||||
if ($fromDate) {
|
||||
$dateConditions['expenses.created_at[>=]'] = $fromDate . ' 00:00:00';
|
||||
}
|
||||
if ($toDate) {
|
||||
$dateConditions['expenses.created_at[<=]'] = $toDate . ' 23:59:59';
|
||||
}
|
||||
|
||||
$analytics = [];
|
||||
|
||||
// Expenses by category
|
||||
$categoryExpenses = $this->db->select('expenses', [
|
||||
'[>]categories' => ['category_id' => 'id']
|
||||
], [
|
||||
'categories.name(category_name)',
|
||||
'categories.color(category_color)',
|
||||
'expenses.amount'
|
||||
], $dateConditions);
|
||||
|
||||
$byCategory = [];
|
||||
foreach ($categoryExpenses as $expense) {
|
||||
$categoryName = $expense['category_name'] ?? 'Uncategorized';
|
||||
if (!isset($byCategory[$categoryName])) {
|
||||
$byCategory[$categoryName] = [
|
||||
'name' => $categoryName,
|
||||
'color' => $expense['category_color'] ?? '#6B7280',
|
||||
'total' => 0,
|
||||
'count' => 0
|
||||
];
|
||||
}
|
||||
$byCategory[$categoryName]['total'] += (float) $expense['amount'];
|
||||
$byCategory[$categoryName]['count']++;
|
||||
}
|
||||
$analytics['by_category'] = array_values($byCategory);
|
||||
|
||||
// Expenses by period
|
||||
$periodFormat = match($groupBy) {
|
||||
'day' => '%Y-%m-%d',
|
||||
'week' => '%Y-%u',
|
||||
'month' => '%Y-%m',
|
||||
'year' => '%Y',
|
||||
default => '%Y-%m'
|
||||
};
|
||||
|
||||
$periodExpenses = $this->db->query("
|
||||
SELECT
|
||||
DATE_FORMAT(created_at, '{$periodFormat}') as period,
|
||||
SUM(amount) as total,
|
||||
COUNT(*) as count
|
||||
FROM expenses
|
||||
WHERE 1=1
|
||||
" . ($fromDate ? "AND created_at >= '{$fromDate} 00:00:00'" : "") . "
|
||||
" . ($toDate ? "AND created_at <= '{$toDate} 23:59:59'" : "") . "
|
||||
GROUP BY period
|
||||
ORDER BY period
|
||||
")->fetchAll();
|
||||
|
||||
$analytics['by_period'] = $periodExpenses;
|
||||
|
||||
// Expenses by payment method
|
||||
$paymentMethods = [
|
||||
'credit_card' => 'Credit Card',
|
||||
'bank_account' => 'Bank Account',
|
||||
'crypto_wallet' => 'Crypto Wallet',
|
||||
'cash' => 'Cash'
|
||||
];
|
||||
|
||||
$byPaymentMethod = [];
|
||||
foreach ($paymentMethods as $method => $label) {
|
||||
$total = (float) $this->db->sum('expenses', 'amount', array_merge($dateConditions, [
|
||||
'payment_method' => $method
|
||||
]));
|
||||
$count = $this->db->count('expenses', array_merge($dateConditions, [
|
||||
'payment_method' => $method
|
||||
]));
|
||||
|
||||
if ($total > 0) {
|
||||
$byPaymentMethod[] = [
|
||||
'method' => $method,
|
||||
'label' => $label,
|
||||
'total' => $total,
|
||||
'count' => $count
|
||||
];
|
||||
}
|
||||
}
|
||||
$analytics['by_payment_method'] = $byPaymentMethod;
|
||||
|
||||
// Summary
|
||||
$analytics['summary'] = [
|
||||
'total_amount' => (float) $this->db->sum('expenses', 'amount', $dateConditions),
|
||||
'total_count' => $this->db->count('expenses', $dateConditions),
|
||||
'average_amount' => 0,
|
||||
'date_range' => [
|
||||
'from' => $fromDate,
|
||||
'to' => $toDate
|
||||
]
|
||||
];
|
||||
|
||||
if ($analytics['summary']['total_count'] > 0) {
|
||||
$analytics['summary']['average_amount'] = round($analytics['summary']['total_amount'] / $analytics['summary']['total_count'], 2);
|
||||
}
|
||||
|
||||
$this->success($analytics, 'Expense analytics retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Expense Analytics', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve expense analytics');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/reports/subscriptions",
|
||||
* summary="Get subscription analytics",
|
||||
* tags={"Reports"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Subscription analytics retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Subscription analytics retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="by_status", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="by_billing_cycle", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="by_currency", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="top_services", type="array", @OA\Items(type="object")),
|
||||
* @OA\Property(property="summary", type="object")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function subscriptions() {
|
||||
$this->logRequest('Get Subscription Analytics');
|
||||
|
||||
if (!$this->hasPermission('reports.read')) {
|
||||
$this->forbidden('Permission denied: reports.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$analytics = [];
|
||||
|
||||
// By status
|
||||
$statuses = ['active', 'expired', 'cancelled', 'paused'];
|
||||
$byStatus = [];
|
||||
foreach ($statuses as $status) {
|
||||
$count = $this->db->count('subscriptions', ['status' => $status]);
|
||||
$total = (float) $this->db->sum('subscriptions', 'amount', ['status' => $status]);
|
||||
|
||||
$byStatus[] = [
|
||||
'status' => $status,
|
||||
'count' => $count,
|
||||
'total_amount' => $total
|
||||
];
|
||||
}
|
||||
$analytics['by_status'] = $byStatus;
|
||||
|
||||
// By billing cycle
|
||||
$cycles = ['monthly', 'annual', 'weekly', 'daily', 'onetime'];
|
||||
$byBillingCycle = [];
|
||||
foreach ($cycles as $cycle) {
|
||||
$count = $this->db->count('subscriptions', ['billing_cycle' => $cycle]);
|
||||
$total = (float) $this->db->sum('subscriptions', 'amount', ['billing_cycle' => $cycle]);
|
||||
|
||||
if ($count > 0) {
|
||||
$byBillingCycle[] = [
|
||||
'cycle' => $cycle,
|
||||
'count' => $count,
|
||||
'total_amount' => $total
|
||||
];
|
||||
}
|
||||
}
|
||||
$analytics['by_billing_cycle'] = $byBillingCycle;
|
||||
|
||||
// By currency
|
||||
$currencies = $this->db->select('subscriptions', 'currency', [
|
||||
'GROUP' => 'currency'
|
||||
]);
|
||||
|
||||
$byCurrency = [];
|
||||
foreach ($currencies as $currency) {
|
||||
$count = $this->db->count('subscriptions', ['currency' => $currency]);
|
||||
$total = (float) $this->db->sum('subscriptions', 'amount', ['currency' => $currency]);
|
||||
|
||||
$byCurrency[] = [
|
||||
'currency' => $currency,
|
||||
'count' => $count,
|
||||
'total_amount' => $total
|
||||
];
|
||||
}
|
||||
$analytics['by_currency'] = $byCurrency;
|
||||
|
||||
// Top services by amount
|
||||
$topServices = $this->db->select('subscriptions', [
|
||||
'plan_name',
|
||||
'amount',
|
||||
'billing_cycle',
|
||||
'currency'
|
||||
], [
|
||||
'ORDER' => ['amount' => 'DESC'],
|
||||
'LIMIT' => 10
|
||||
]);
|
||||
|
||||
$analytics['top_services'] = $topServices;
|
||||
|
||||
// Summary
|
||||
$analytics['summary'] = [
|
||||
'total_subscriptions' => $this->db->count('subscriptions'),
|
||||
'active_subscriptions' => $this->db->count('subscriptions', ['status' => 'active']),
|
||||
'total_monthly_value' => 0,
|
||||
'total_annual_value' => 0
|
||||
];
|
||||
|
||||
// Calculate monthly and annual values
|
||||
$activeSubscriptions = $this->db->select('subscriptions', ['amount', 'billing_cycle'], ['status' => 'active']);
|
||||
$monthlyValue = 0;
|
||||
$annualValue = 0;
|
||||
|
||||
foreach ($activeSubscriptions as $subscription) {
|
||||
$amount = (float) $subscription['amount'];
|
||||
switch ($subscription['billing_cycle']) {
|
||||
case 'monthly':
|
||||
$monthlyValue += $amount;
|
||||
$annualValue += $amount * 12;
|
||||
break;
|
||||
case 'annual':
|
||||
$monthlyValue += $amount / 12;
|
||||
$annualValue += $amount;
|
||||
break;
|
||||
case 'weekly':
|
||||
$monthlyValue += $amount * 4.33;
|
||||
$annualValue += $amount * 52;
|
||||
break;
|
||||
case 'daily':
|
||||
$monthlyValue += $amount * 30;
|
||||
$annualValue += $amount * 365;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$analytics['summary']['total_monthly_value'] = round($monthlyValue, 2);
|
||||
$analytics['summary']['total_annual_value'] = round($annualValue, 2);
|
||||
|
||||
$this->success($analytics, 'Subscription analytics retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Subscription Analytics', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve subscription analytics');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/reports/export",
|
||||
* summary="Export data to various formats",
|
||||
* tags={"Reports"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="type",
|
||||
* in="query",
|
||||
* description="Type of data to export",
|
||||
* required=true,
|
||||
* @OA\Schema(type="string", enum={"expenses", "subscriptions", "categories", "tags"})
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="format",
|
||||
* in="query",
|
||||
* description="Export format",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", enum={"csv", "json", "xlsx"}, default="csv")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="from_date",
|
||||
* in="query",
|
||||
* description="Start date for filtering (Y-m-d format)",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", format="date")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="to_date",
|
||||
* in="query",
|
||||
* description="End date for filtering (Y-m-d format)",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", format="date")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Export data retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Export data retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="download_url", type="string", example="/api/v1/reports/download/export_123456.csv"),
|
||||
* @OA\Property(property="filename", type="string", example="expenses_export_2024-01-15.csv"),
|
||||
* @OA\Property(property="record_count", type="integer", example=125),
|
||||
* @OA\Property(property="format", type="string", example="csv")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function export() {
|
||||
$this->logRequest('Export Data');
|
||||
|
||||
if (!$this->hasPermission('reports.export')) {
|
||||
$this->forbidden('Permission denied: reports.export required');
|
||||
}
|
||||
|
||||
$type = $this->request['query']['type'] ?? null;
|
||||
$format = $this->request['query']['format'] ?? 'csv';
|
||||
$fromDate = $this->request['query']['from_date'] ?? null;
|
||||
$toDate = $this->request['query']['to_date'] ?? null;
|
||||
|
||||
// Validate required parameters
|
||||
if (!$type) {
|
||||
$this->validationError(['type' => 'The type parameter is required.']);
|
||||
}
|
||||
|
||||
$validTypes = ['expenses', 'subscriptions', 'categories', 'tags'];
|
||||
if (!in_array($type, $validTypes)) {
|
||||
$this->validationError(['type' => 'Type must be one of: ' . implode(', ', $validTypes)]);
|
||||
}
|
||||
|
||||
$validFormats = ['csv', 'json', 'xlsx'];
|
||||
if (!in_array($format, $validFormats)) {
|
||||
$this->validationError(['format' => 'Format must be one of: ' . implode(', ', $validFormats)]);
|
||||
}
|
||||
|
||||
try {
|
||||
$data = [];
|
||||
$dateConditions = [];
|
||||
|
||||
if ($fromDate) {
|
||||
$dateConditions['created_at[>=]'] = $fromDate . ' 00:00:00';
|
||||
}
|
||||
if ($toDate) {
|
||||
$dateConditions['created_at[<=]'] = $toDate . ' 23:59:59';
|
||||
}
|
||||
|
||||
// Get data based on type
|
||||
switch ($type) {
|
||||
case 'expenses':
|
||||
$data = $this->db->select('expenses', [
|
||||
'[>]categories' => ['category_id' => 'id'],
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'expenses.id',
|
||||
'expenses.amount',
|
||||
'expenses.description',
|
||||
'expenses.date',
|
||||
'expenses.payment_method',
|
||||
'expenses.status',
|
||||
'categories.name(category_name)',
|
||||
'users.name(user_name)',
|
||||
'expenses.created_at'
|
||||
], $dateConditions);
|
||||
break;
|
||||
|
||||
case 'subscriptions':
|
||||
$data = $this->db->select('subscriptions', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'subscriptions.id',
|
||||
'subscriptions.plan_name',
|
||||
'subscriptions.amount',
|
||||
'subscriptions.currency',
|
||||
'subscriptions.billing_cycle',
|
||||
'subscriptions.status',
|
||||
'subscriptions.start_date',
|
||||
'subscriptions.next_billing_date',
|
||||
'users.name(user_name)',
|
||||
'subscriptions.created_at'
|
||||
], $dateConditions);
|
||||
break;
|
||||
|
||||
case 'categories':
|
||||
$data = $this->db->select('categories', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'categories.id',
|
||||
'categories.name',
|
||||
'categories.description',
|
||||
'categories.color',
|
||||
'categories.icon',
|
||||
'users.name(user_name)',
|
||||
'categories.created_at'
|
||||
], $dateConditions);
|
||||
break;
|
||||
|
||||
case 'tags':
|
||||
$data = $this->db->select('tags', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.description',
|
||||
'tags.color',
|
||||
'users.name(user_name)',
|
||||
'tags.created_at'
|
||||
], $dateConditions);
|
||||
break;
|
||||
}
|
||||
|
||||
$filename = $type . '_export_' . date('Y-m-d_H-i-s') . '.' . $format;
|
||||
$recordCount = count($data);
|
||||
|
||||
// For API, we'll return the data directly in the requested format
|
||||
$response = [
|
||||
'data' => $data,
|
||||
'filename' => $filename,
|
||||
'record_count' => $recordCount,
|
||||
'format' => $format,
|
||||
'export_info' => [
|
||||
'type' => $type,
|
||||
'date_range' => [
|
||||
'from' => $fromDate,
|
||||
'to' => $toDate
|
||||
],
|
||||
'generated_at' => date('c')
|
||||
]
|
||||
];
|
||||
|
||||
$this->success($response, 'Export data retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Export Data', ['type' => $type, 'format' => $format, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to export data');
|
||||
}
|
||||
}
|
||||
}
|
||||
253
app/Controllers/Api/Schemas.php
Normal file
253
app/Controllers/Api/Schemas.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* OpenAPI Schema Definitions
|
||||
*
|
||||
* This class contains all the schema and response definitions for the API documentation.
|
||||
* Each method defines a reusable component that can be referenced in API endpoints.
|
||||
*
|
||||
* @OA\Info(
|
||||
* version="1.0.0",
|
||||
* title="Accounting Panel API",
|
||||
* description="API for Accounting Panel - Manage users, subscriptions, credit cards, transactions, expenses, and more."
|
||||
* )
|
||||
*
|
||||
* @OA\Server(
|
||||
* url="/api/v1",
|
||||
* description="API Server"
|
||||
* )
|
||||
*
|
||||
* @OA\SecurityScheme(
|
||||
* securityScheme="ApiKeyAuth",
|
||||
* type="apiKey",
|
||||
* in="header",
|
||||
* name="X-API-Key"
|
||||
* )
|
||||
*
|
||||
* @OA\SecurityScheme(
|
||||
* securityScheme="BearerAuth",
|
||||
* type="http",
|
||||
* scheme="bearer"
|
||||
* )
|
||||
*/
|
||||
class OpenApiDocumentation
|
||||
{
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="User",
|
||||
* type="object",
|
||||
* title="User",
|
||||
* description="User model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="email", type="string", format="email", example="john@example.com"),
|
||||
* @OA\Property(property="role", type="string", enum={"admin", "user"}, example="user"),
|
||||
* @OA\Property(property="two_factor_enabled", type="boolean", example=false),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
*/
|
||||
public function userSchema() {}
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="BankAccount",
|
||||
* type="object",
|
||||
* title="BankAccount",
|
||||
* description="Bank account model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="Primary Checking"),
|
||||
* @OA\Property(property="bank_name", type="string", example="Bank of America"),
|
||||
* @OA\Property(property="account_type", type="string", enum={"checking", "savings", "business", "investment"}, example="checking"),
|
||||
* @OA\Property(property="account_number_last4", type="string", example="1234"),
|
||||
* @OA\Property(property="routing_number_last4", type="string", example="5678"),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="swift_code", type="string", example="BOFAUS3N"),
|
||||
* @OA\Property(property="iban", type="string", example="GB29 NWBK 6016 1331 9268 19"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="notes", type="string", example="Primary business account"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="owner_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="owner_email", type="string", format="email", example="john@example.com")
|
||||
* )
|
||||
*/
|
||||
public function bankAccountSchema() {}
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="Category",
|
||||
* type="object",
|
||||
* title="Category",
|
||||
* description="Category model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="description", type="string", example="All office-related purchases"),
|
||||
* @OA\Property(property="color", type="string", example="#FF5733"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
*/
|
||||
public function categorySchema() {}
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="Tag",
|
||||
* type="object",
|
||||
* title="Tag",
|
||||
* description="Tag model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="urgent"),
|
||||
* @OA\Property(property="color", type="string", example="#FF0000"),
|
||||
* @OA\Property(property="description", type="string", example="Urgent expenses requiring immediate attention"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
*/
|
||||
public function tagSchema() {}
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="Expense",
|
||||
* type="object",
|
||||
* title="Expense",
|
||||
* description="Expense model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="category_id", type="integer", example=2),
|
||||
* @OA\Property(property="bank_account_id", type="integer", example=1),
|
||||
* @OA\Property(property="title", type="string", example="Office Supplies Purchase"),
|
||||
* @OA\Property(property="description", type="string", example="Monthly office supplies for the team"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=156.78),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="expense_date", type="string", format="date", example="2024-01-15"),
|
||||
* @OA\Property(property="status", type="string", enum={"pending", "approved", "rejected"}, example="pending"),
|
||||
* @OA\Property(property="receipt_url", type="string", example="/uploads/receipts/receipt_123.pdf"),
|
||||
* @OA\Property(property="notes", type="string", example="Purchased from Staples"),
|
||||
* @OA\Property(property="is_reimbursable", type="boolean", example=true),
|
||||
* @OA\Property(property="is_billable", type="boolean", example=false),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="category_name", type="string", example="Office Supplies"),
|
||||
* @OA\Property(property="user_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="bank_account_name", type="string", example="Primary Checking")
|
||||
* )
|
||||
*/
|
||||
public function expenseSchema() {}
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="CryptoWallet",
|
||||
* type="object",
|
||||
* title="CryptoWallet",
|
||||
* description="Crypto wallet model",
|
||||
* @OA\Property(property="id", type="integer", example=1),
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="name", type="string", example="Bitcoin Wallet"),
|
||||
* @OA\Property(property="currency", type="string", example="BTC"),
|
||||
* @OA\Property(property="network", type="string", example="mainnet"),
|
||||
* @OA\Property(property="address", type="string", example="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"),
|
||||
* @OA\Property(property="balance", type="number", format="float", example=0.025),
|
||||
* @OA\Property(property="wallet_type", type="string", enum={"hot", "cold", "hardware"}, example="hardware"),
|
||||
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||
* @OA\Property(property="notes", type="string", example="Hardware wallet for Bitcoin storage"),
|
||||
* @OA\Property(property="created_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="updated_at", type="string", format="date-time", example="2024-01-15T10:30:00Z"),
|
||||
* @OA\Property(property="owner_name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="owner_email", type="string", format="email", example="john@example.com")
|
||||
* )
|
||||
*/
|
||||
public function cryptoWalletSchema() {}
|
||||
|
||||
/**
|
||||
* @OA\Schema(
|
||||
* schema="Pagination",
|
||||
* type="object",
|
||||
* title="Pagination",
|
||||
* description="Pagination metadata",
|
||||
* @OA\Property(property="current_page", type="integer", example=1),
|
||||
* @OA\Property(property="per_page", type="integer", example=20),
|
||||
* @OA\Property(property="total", type="integer", example=150),
|
||||
* @OA\Property(property="total_pages", type="integer", example=8),
|
||||
* @OA\Property(property="has_next", type="boolean", example=true),
|
||||
* @OA\Property(property="has_prev", type="boolean", example=false)
|
||||
* )
|
||||
*/
|
||||
public function paginationSchema() {}
|
||||
|
||||
/**
|
||||
* @OA\Response(
|
||||
* response="Unauthorized",
|
||||
* description="Unauthorized access - API key missing or invalid",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Unauthorized"),
|
||||
* @OA\Property(property="message", type="string", example="Invalid or missing API key"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function unauthorizedResponse() {}
|
||||
|
||||
/**
|
||||
* @OA\Response(
|
||||
* response="Forbidden",
|
||||
* description="Forbidden - Insufficient permissions",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Forbidden"),
|
||||
* @OA\Property(property="message", type="string", example="You do not have permission to access this resource"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function forbiddenResponse() {}
|
||||
|
||||
/**
|
||||
* @OA\Response(
|
||||
* response="NotFound",
|
||||
* description="Resource not found",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Not Found"),
|
||||
* @OA\Property(property="message", type="string", example="The requested resource was not found"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function notFoundResponse() {}
|
||||
|
||||
/**
|
||||
* @OA\Response(
|
||||
* response="ValidationError",
|
||||
* description="Validation Error",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Validation Error"),
|
||||
* @OA\Property(property="message", type="string", example="Validation failed"),
|
||||
* @OA\Property(property="errors", type="object",
|
||||
* @OA\Property(property="field_name", type="string", example="This field is required")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function validationErrorResponse() {}
|
||||
|
||||
/**
|
||||
* @OA\Response(
|
||||
* response="BadRequest",
|
||||
* description="Bad Request",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=false),
|
||||
* @OA\Property(property="error", type="string", example="Bad Request"),
|
||||
* @OA\Property(property="message", type="string", example="The request was invalid or cannot be served"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time", example="2024-01-15T10:30:00Z")
|
||||
* )
|
||||
* )
|
||||
*/
|
||||
public function badRequestResponse() {}
|
||||
}
|
||||
402
app/Controllers/Api/SubscriptionsApiController.php
Normal file
402
app/Controllers/Api/SubscriptionsApiController.php
Normal file
@@ -0,0 +1,402 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/Subscription.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Subscriptions",
|
||||
* description="Subscription management operations"
|
||||
* )
|
||||
*/
|
||||
class SubscriptionsApiController extends ApiController {
|
||||
|
||||
private $subscriptionModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->subscriptionModel = new Subscription($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/subscriptions",
|
||||
* summary="Get all subscriptions",
|
||||
* tags={"Subscriptions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="user_id",
|
||||
* in="query",
|
||||
* description="Filter by user ID",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="status",
|
||||
* in="query",
|
||||
* description="Filter by status",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", enum={"active", "inactive", "cancelled", "expired"})
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Subscriptions retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Subscriptions retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/Subscription")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Subscriptions');
|
||||
|
||||
if (!$this->hasPermission('subscriptions.read')) {
|
||||
$this->forbidden('Permission denied: subscriptions.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$userId = $this->request['query']['user_id'] ?? null;
|
||||
$status = $this->request['query']['status'] ?? null;
|
||||
|
||||
try {
|
||||
$conditions = [];
|
||||
if ($userId) {
|
||||
$conditions['user_id'] = $userId;
|
||||
}
|
||||
if ($status) {
|
||||
$conditions['status'] = $status;
|
||||
}
|
||||
|
||||
$subscriptions = $this->subscriptionModel->db->select('subscriptions', [
|
||||
'id', 'user_id', 'plan_name', 'amount', 'currency', 'billing_cycle',
|
||||
'status', 'start_date', 'end_date', 'next_billing_date', 'created_at', 'updated_at'
|
||||
], array_merge($conditions, [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]));
|
||||
|
||||
$total = $this->subscriptionModel->db->count('subscriptions', $conditions);
|
||||
|
||||
$response = $this->paginatedResponse($subscriptions, $total, $pagination);
|
||||
$this->success($response, 'Subscriptions retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Subscriptions', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve subscriptions');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/subscriptions/{id}",
|
||||
* summary="Get subscription by ID",
|
||||
* tags={"Subscriptions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Subscription ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Subscription retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Subscription retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Subscription"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Subscription', ['subscription_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('subscriptions.read')) {
|
||||
$this->forbidden('Permission denied: subscriptions.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$subscription = $this->subscriptionModel->db->get('subscriptions', [
|
||||
'id', 'user_id', 'plan_name', 'amount', 'currency', 'billing_cycle',
|
||||
'status', 'start_date', 'end_date', 'next_billing_date', 'created_at', 'updated_at'
|
||||
], ['id' => $id]);
|
||||
|
||||
if (!$subscription) {
|
||||
$this->notFound('Subscription not found');
|
||||
}
|
||||
|
||||
$this->success($subscription, 'Subscription retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Subscription', ['subscription_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve subscription');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/subscriptions",
|
||||
* summary="Create a new subscription",
|
||||
* tags={"Subscriptions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"user_id", "plan_name", "amount", "currency", "billing_cycle", "start_date"},
|
||||
* @OA\Property(property="user_id", type="integer", example=1),
|
||||
* @OA\Property(property="plan_name", type="string", example="Premium Plan"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=29.99),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="billing_cycle", type="string", enum={"monthly", "yearly"}, example="monthly"),
|
||||
* @OA\Property(property="start_date", type="string", format="date", example="2024-01-15"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2024-12-31"),
|
||||
* @OA\Property(property="status", type="string", enum={"active", "inactive", "cancelled", "expired"}, example="active")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Subscription created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Subscription created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Subscription"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Subscription');
|
||||
|
||||
if (!$this->hasPermission('subscriptions.create')) {
|
||||
$this->forbidden('Permission denied: subscriptions.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, [
|
||||
'user_id', 'plan_name', 'amount', 'currency', 'billing_cycle', 'start_date'
|
||||
]);
|
||||
|
||||
// Additional validations
|
||||
if (isset($data['amount']) && (!is_numeric($data['amount']) || $data['amount'] <= 0)) {
|
||||
$errors['amount'] = 'Amount must be a positive number.';
|
||||
}
|
||||
|
||||
if (isset($data['billing_cycle']) && !in_array($data['billing_cycle'], ['monthly', 'yearly'])) {
|
||||
$errors['billing_cycle'] = 'Billing cycle must be either monthly or yearly.';
|
||||
}
|
||||
|
||||
if (isset($data['status']) && !in_array($data['status'], ['active', 'inactive', 'cancelled', 'expired'])) {
|
||||
$errors['status'] = 'Status must be one of: active, inactive, cancelled, expired.';
|
||||
}
|
||||
|
||||
if (isset($data['start_date']) && !strtotime($data['start_date'])) {
|
||||
$errors['start_date'] = 'Start date must be a valid date.';
|
||||
}
|
||||
|
||||
if (isset($data['end_date']) && !strtotime($data['end_date'])) {
|
||||
$errors['end_date'] = 'End date must be a valid date.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
// Set default status if not provided
|
||||
if (!isset($data['status'])) {
|
||||
$data['status'] = 'active';
|
||||
}
|
||||
|
||||
$subscriptionId = $this->subscriptionModel->create($data);
|
||||
|
||||
$subscription = $this->subscriptionModel->find($subscriptionId);
|
||||
|
||||
$this->success($subscription, 'Subscription created successfully', 201);
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Subscription', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
$this->serverError('Failed to create subscription');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/subscriptions/{id}",
|
||||
* summary="Update a subscription",
|
||||
* tags={"Subscriptions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Subscription ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="plan_name", type="string", example="Premium Plan"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=29.99),
|
||||
* @OA\Property(property="currency", type="string", example="USD"),
|
||||
* @OA\Property(property="billing_cycle", type="string", enum={"monthly", "yearly"}, example="monthly"),
|
||||
* @OA\Property(property="status", type="string", enum={"active", "inactive", "cancelled", "expired"}, example="active"),
|
||||
* @OA\Property(property="end_date", type="string", format="date", example="2024-12-31"),
|
||||
* @OA\Property(property="next_billing_date", type="string", format="date", example="2024-02-15")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Subscription updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Subscription updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Subscription"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Subscription', ['subscription_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('subscriptions.update')) {
|
||||
$this->forbidden('Permission denied: subscriptions.update required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Check if subscription exists
|
||||
$existingSubscription = $this->subscriptionModel->find($id);
|
||||
if (!$existingSubscription) {
|
||||
$this->notFound('Subscription not found');
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
$errors = [];
|
||||
if (isset($data['amount']) && (!is_numeric($data['amount']) || $data['amount'] <= 0)) {
|
||||
$errors['amount'] = 'Amount must be a positive number.';
|
||||
}
|
||||
|
||||
if (isset($data['billing_cycle']) && !in_array($data['billing_cycle'], ['monthly', 'yearly'])) {
|
||||
$errors['billing_cycle'] = 'Billing cycle must be either monthly or yearly.';
|
||||
}
|
||||
|
||||
if (isset($data['status']) && !in_array($data['status'], ['active', 'inactive', 'cancelled', 'expired'])) {
|
||||
$errors['status'] = 'Status must be one of: active, inactive, cancelled, expired.';
|
||||
}
|
||||
|
||||
if (isset($data['end_date']) && !strtotime($data['end_date'])) {
|
||||
$errors['end_date'] = 'End date must be a valid date.';
|
||||
}
|
||||
|
||||
if (isset($data['next_billing_date']) && !strtotime($data['next_billing_date'])) {
|
||||
$errors['next_billing_date'] = 'Next billing date must be a valid date.';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->subscriptionModel->update($id, $data);
|
||||
|
||||
$subscription = $this->subscriptionModel->find($id);
|
||||
|
||||
$this->success($subscription, 'Subscription updated successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Subscription', ['subscription_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update subscription');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/subscriptions/{id}",
|
||||
* summary="Delete a subscription",
|
||||
* tags={"Subscriptions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Subscription ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Subscription deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Subscription deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Subscription', ['subscription_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('subscriptions.delete')) {
|
||||
$this->forbidden('Permission denied: subscriptions.delete required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingSubscription = $this->subscriptionModel->find($id);
|
||||
if (!$existingSubscription) {
|
||||
$this->notFound('Subscription not found');
|
||||
}
|
||||
|
||||
$this->subscriptionModel->delete($id);
|
||||
|
||||
$this->success(null, 'Subscription deleted successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Subscription', ['subscription_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete subscription');
|
||||
}
|
||||
}
|
||||
}
|
||||
487
app/Controllers/Api/TagsApiController.php
Normal file
487
app/Controllers/Api/TagsApiController.php
Normal file
@@ -0,0 +1,487 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/Tag.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Tags",
|
||||
* description="Tag management operations"
|
||||
* )
|
||||
*/
|
||||
class TagsApiController extends ApiController {
|
||||
|
||||
private $tagModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->tagModel = new Tag($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/tags",
|
||||
* summary="Get all tags",
|
||||
* tags={"Tags"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="search",
|
||||
* in="query",
|
||||
* description="Search tags by name",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Tags retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Tags retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/Tag")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Tags');
|
||||
|
||||
if (!$this->hasPermission('tags.read')) {
|
||||
$this->forbidden('Permission denied: tags.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$search = $this->request['query']['search'] ?? null;
|
||||
|
||||
try {
|
||||
$conditions = [];
|
||||
|
||||
if ($search) {
|
||||
$conditions['name[~]'] = $search;
|
||||
}
|
||||
|
||||
$tags = $this->tagModel->db->select('tags', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.description',
|
||||
'tags.color',
|
||||
'tags.created_at',
|
||||
'tags.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], array_merge($conditions, [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['tags.name' => 'ASC']
|
||||
]));
|
||||
|
||||
// Add usage count for each tag
|
||||
foreach ($tags as &$tag) {
|
||||
$tag['usage_count'] = $this->tagModel->db->count('expense_tags', [
|
||||
'tag_id' => $tag['id']
|
||||
]);
|
||||
}
|
||||
|
||||
$total = $this->tagModel->db->count('tags', $conditions);
|
||||
|
||||
$response = $this->paginatedResponse($tags, $total, $pagination);
|
||||
$this->success($response, 'Tags retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Tags', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve tags');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/tags/{id}",
|
||||
* summary="Get tag by ID",
|
||||
* tags={"Tags"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Tag ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Tag retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Tag retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Tag"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Tag', ['tag_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('tags.read')) {
|
||||
$this->forbidden('Permission denied: tags.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$tag = $this->tagModel->db->get('tags', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.description',
|
||||
'tags.color',
|
||||
'tags.created_at',
|
||||
'tags.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], ['tags.id' => $id]);
|
||||
|
||||
if (!$tag) {
|
||||
$this->notFound('Tag not found');
|
||||
}
|
||||
|
||||
// Add usage count
|
||||
$tag['usage_count'] = $this->tagModel->db->count('expense_tags', [
|
||||
'tag_id' => $id
|
||||
]);
|
||||
|
||||
$this->success($tag, 'Tag retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Tag', ['tag_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve tag');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/tags",
|
||||
* summary="Create a new tag",
|
||||
* tags={"Tags"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"name", "user_id"},
|
||||
* @OA\Property(property="name", type="string", example="Business"),
|
||||
* @OA\Property(property="description", type="string", example="Business related expenses"),
|
||||
* @OA\Property(property="color", type="string", example="#3B82F6"),
|
||||
* @OA\Property(property="user_id", type="integer", example=1)
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Tag created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Tag created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Tag"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Tag');
|
||||
|
||||
if (!$this->hasPermission('tags.create')) {
|
||||
$this->forbidden('Permission denied: tags.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, ['name', 'user_id']);
|
||||
|
||||
// Additional validations
|
||||
if (isset($data['color']) && !preg_match('/^#[0-9A-Fa-f]{6}$/', $data['color'])) {
|
||||
$errors['color'] = 'Color must be a valid hex color code (e.g., #3B82F6).';
|
||||
}
|
||||
|
||||
if (isset($data['user_id'])) {
|
||||
$userExists = $this->tagModel->db->has('users', ['id' => $data['user_id']]);
|
||||
if (!$userExists) {
|
||||
$errors['user_id'] = 'User does not exist.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$tagData = [
|
||||
'user_id' => $data['user_id'],
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? '',
|
||||
'color' => $data['color'] ?? '#3B82F6',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$tagId = $this->tagModel->create($tagData);
|
||||
|
||||
if ($tagId) {
|
||||
$tag = $this->tagModel->find($tagId);
|
||||
$this->success($tag, 'Tag created successfully', 201);
|
||||
} else {
|
||||
$this->serverError('Failed to create tag');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Tag', ['error' => $e->getMessage(), 'data' => $data]);
|
||||
$this->serverError('Failed to create tag');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/tags/{id}",
|
||||
* summary="Update a tag",
|
||||
* tags={"Tags"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Tag ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="name", type="string", example="Business"),
|
||||
* @OA\Property(property="description", type="string", example="Business related expenses"),
|
||||
* @OA\Property(property="color", type="string", example="#3B82F6")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Tag updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Tag updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Tag"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Tag', ['tag_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('tags.update')) {
|
||||
$this->forbidden('Permission denied: tags.update required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Check if tag exists
|
||||
$existingTag = $this->tagModel->find($id);
|
||||
if (!$existingTag) {
|
||||
$this->notFound('Tag not found');
|
||||
}
|
||||
|
||||
// Validate fields if provided
|
||||
$errors = [];
|
||||
if (isset($data['color']) && !preg_match('/^#[0-9A-Fa-f]{6}$/', $data['color'])) {
|
||||
$errors['color'] = 'Color must be a valid hex color code (e.g., #3B82F6).';
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$updateData = array_merge($data, [
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$result = $this->tagModel->update($id, $updateData);
|
||||
|
||||
if ($result) {
|
||||
$tag = $this->tagModel->find($id);
|
||||
$this->success($tag, 'Tag updated successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to update tag');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Tag', ['tag_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update tag');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/tags/{id}",
|
||||
* summary="Delete a tag",
|
||||
* tags={"Tags"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Tag ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Tag deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Tag deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=400, ref="#/components/responses/BadRequest"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Tag', ['tag_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('tags.delete')) {
|
||||
$this->forbidden('Permission denied: tags.delete required');
|
||||
}
|
||||
|
||||
try {
|
||||
$existingTag = $this->tagModel->find($id);
|
||||
if (!$existingTag) {
|
||||
$this->notFound('Tag not found');
|
||||
}
|
||||
|
||||
// Check if tag is used in expenses
|
||||
$usageCount = $this->tagModel->db->count('expense_tags', ['tag_id' => $id]);
|
||||
if ($usageCount > 0) {
|
||||
$this->error('Cannot delete tag that is used in expenses. Please remove the tag from expenses first.', 400);
|
||||
}
|
||||
|
||||
$result = $this->tagModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
$this->success(null, 'Tag deleted successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to delete tag');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Tag', ['tag_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete tag');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/tags/popular",
|
||||
* summary="Get popular tags",
|
||||
* tags={"Tags"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Number of tags to return",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=20, default=5)
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Popular tags retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Popular tags retrieved successfully"),
|
||||
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Tag")),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function popular() {
|
||||
$this->logRequest('Get Popular Tags');
|
||||
|
||||
if (!$this->hasPermission('tags.read')) {
|
||||
$this->forbidden('Permission denied: tags.read required');
|
||||
}
|
||||
|
||||
$limit = min(20, max(1, (int)($this->request['query']['limit'] ?? 5)));
|
||||
|
||||
try {
|
||||
$tags = $this->tagModel->db->select('tags', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.description',
|
||||
'tags.color',
|
||||
'tags.created_at',
|
||||
'users.name(creator_name)'
|
||||
], [
|
||||
'ORDER' => ['tags.created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
// Add usage count and filter/sort
|
||||
$tagsWithCount = [];
|
||||
foreach ($tags as $tag) {
|
||||
$tag['usage_count'] = $this->tagModel->db->count('expense_tags', [
|
||||
'tag_id' => $tag['id']
|
||||
]);
|
||||
if ($tag['usage_count'] > 0) {
|
||||
$tagsWithCount[] = $tag;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by usage count descending
|
||||
usort($tagsWithCount, function($a, $b) {
|
||||
return $b['usage_count'] - $a['usage_count'];
|
||||
});
|
||||
|
||||
// Limit results
|
||||
$popularTags = array_slice($tagsWithCount, 0, $limit);
|
||||
|
||||
$this->success($popularTags, 'Popular tags retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Popular Tags', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve popular tags');
|
||||
}
|
||||
}
|
||||
}
|
||||
435
app/Controllers/Api/TransactionsApiController.php
Normal file
435
app/Controllers/Api/TransactionsApiController.php
Normal file
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/Transaction.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Transactions",
|
||||
* description="Transaction management operations"
|
||||
* )
|
||||
*/
|
||||
class TransactionsApiController extends ApiController {
|
||||
|
||||
private $transactionModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->transactionModel = new Transaction($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/transactions",
|
||||
* summary="Get all transactions",
|
||||
* tags={"Transactions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="type",
|
||||
* in="query",
|
||||
* description="Filter by transaction type",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string", enum={"income", "expense"})
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="category",
|
||||
* in="query",
|
||||
* description="Filter by category",
|
||||
* required=false,
|
||||
* @OA\Schema(type="string")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Transactions retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Transactions retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/Transaction")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Transactions');
|
||||
|
||||
if (!$this->hasPermission('transactions.read')) {
|
||||
$this->forbidden('Permission denied: transactions.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
$filters = [];
|
||||
|
||||
// Apply filters
|
||||
if (isset($this->request['query']['type']) && in_array($this->request['query']['type'], ['income', 'expense'])) {
|
||||
$filters['type'] = $this->request['query']['type'];
|
||||
}
|
||||
|
||||
if (isset($this->request['query']['category']) && !empty($this->request['query']['category'])) {
|
||||
$filters['category'] = $this->request['query']['category'];
|
||||
}
|
||||
|
||||
try {
|
||||
$whereClause = array_merge($filters, [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
$transactions = $this->transactionModel->db->select('transactions', '*', $whereClause);
|
||||
$total = $this->transactionModel->db->count('transactions', $filters);
|
||||
|
||||
$response = $this->paginatedResponse($transactions, $total, $pagination);
|
||||
$this->success($response, 'Transactions retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Transactions', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve transactions');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/transactions/{id}",
|
||||
* summary="Get transaction by ID",
|
||||
* tags={"Transactions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Transaction ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Transaction retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Transaction retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Transaction"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get Transaction', ['transaction_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('transactions.read')) {
|
||||
$this->forbidden('Permission denied: transactions.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$transaction = $this->transactionModel->find($id);
|
||||
|
||||
if (!$transaction) {
|
||||
$this->notFound('Transaction not found');
|
||||
}
|
||||
|
||||
$this->success($transaction, 'Transaction retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Transaction', ['transaction_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve transaction');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/transactions",
|
||||
* summary="Create a new transaction",
|
||||
* tags={"Transactions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"type", "amount", "description", "category", "transaction_date"},
|
||||
* @OA\Property(property="type", type="string", enum={"income", "expense"}, example="expense"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=99.99),
|
||||
* @OA\Property(property="description", type="string", example="Office supplies"),
|
||||
* @OA\Property(property="category", type="string", example="Office Expenses"),
|
||||
* @OA\Property(property="transaction_date", type="string", format="date", example="2024-01-15"),
|
||||
* @OA\Property(property="reference_number", type="string", example="REF-001"),
|
||||
* @OA\Property(property="notes", type="string", example="Additional notes")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="Transaction created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Transaction created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Transaction"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create Transaction');
|
||||
|
||||
if (!$this->hasPermission('transactions.create')) {
|
||||
$this->forbidden('Permission denied: transactions.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, ['type', 'amount', 'description', 'category', 'transaction_date']);
|
||||
|
||||
// Additional validations
|
||||
if (isset($data['type']) && !in_array($data['type'], ['income', 'expense'])) {
|
||||
$errors['type'] = 'Type must be either income or expense.';
|
||||
}
|
||||
|
||||
if (isset($data['amount'])) {
|
||||
$amount = floatval($data['amount']);
|
||||
if ($amount <= 0) {
|
||||
$errors['amount'] = 'Amount must be greater than 0.';
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['transaction_date'])) {
|
||||
$transactionDate = strtotime($data['transaction_date']);
|
||||
if ($transactionDate === false) {
|
||||
$errors['transaction_date'] = 'Please provide a valid transaction date.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$transactionData = [
|
||||
'type' => $data['type'],
|
||||
'amount' => floatval($data['amount']),
|
||||
'description' => $data['description'],
|
||||
'category' => $data['category'],
|
||||
'transaction_date' => date('Y-m-d', strtotime($data['transaction_date'])),
|
||||
'reference_number' => $data['reference_number'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $this->transactionModel->db->insert('transactions', $transactionData);
|
||||
|
||||
if ($result->rowCount() > 0) {
|
||||
$transactionId = $this->transactionModel->db->id();
|
||||
$transaction = $this->transactionModel->find($transactionId);
|
||||
|
||||
$this->success($transaction, 'Transaction created successfully', 201);
|
||||
} else {
|
||||
$this->serverError('Failed to create transaction');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create Transaction', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to create transaction');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/transactions/{id}",
|
||||
* summary="Update transaction",
|
||||
* tags={"Transactions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Transaction ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="type", type="string", enum={"income", "expense"}, example="expense"),
|
||||
* @OA\Property(property="amount", type="number", format="float", example=99.99),
|
||||
* @OA\Property(property="description", type="string", example="Office supplies"),
|
||||
* @OA\Property(property="category", type="string", example="Office Expenses"),
|
||||
* @OA\Property(property="transaction_date", type="string", format="date", example="2024-01-15"),
|
||||
* @OA\Property(property="reference_number", type="string", example="REF-001"),
|
||||
* @OA\Property(property="notes", type="string", example="Additional notes")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Transaction updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Transaction updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/Transaction"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update Transaction', ['transaction_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('transactions.update')) {
|
||||
$this->forbidden('Permission denied: transactions.update required');
|
||||
}
|
||||
|
||||
// Check if transaction exists
|
||||
$existingTransaction = $this->transactionModel->find($id);
|
||||
if (!$existingTransaction) {
|
||||
$this->notFound('Transaction not found');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
$errors = [];
|
||||
$updateData = ['updated_at' => date('Y-m-d H:i:s')];
|
||||
|
||||
// Validate and prepare update data
|
||||
if (isset($data['type']) && !empty($data['type'])) {
|
||||
if (!in_array($data['type'], ['income', 'expense'])) {
|
||||
$errors['type'] = 'Type must be either income or expense.';
|
||||
} else {
|
||||
$updateData['type'] = $data['type'];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['amount']) && !empty($data['amount'])) {
|
||||
$amount = floatval($data['amount']);
|
||||
if ($amount <= 0) {
|
||||
$errors['amount'] = 'Amount must be greater than 0.';
|
||||
} else {
|
||||
$updateData['amount'] = $amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['description']) && !empty($data['description'])) {
|
||||
$updateData['description'] = $data['description'];
|
||||
}
|
||||
|
||||
if (isset($data['category']) && !empty($data['category'])) {
|
||||
$updateData['category'] = $data['category'];
|
||||
}
|
||||
|
||||
if (isset($data['transaction_date']) && !empty($data['transaction_date'])) {
|
||||
$transactionDate = strtotime($data['transaction_date']);
|
||||
if ($transactionDate === false) {
|
||||
$errors['transaction_date'] = 'Please provide a valid transaction date.';
|
||||
} else {
|
||||
$updateData['transaction_date'] = date('Y-m-d', $transactionDate);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['reference_number'])) {
|
||||
$updateData['reference_number'] = $data['reference_number'];
|
||||
}
|
||||
|
||||
if (isset($data['notes'])) {
|
||||
$updateData['notes'] = $data['notes'];
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->transactionModel->db->update('transactions', $updateData, ['id' => $id]);
|
||||
|
||||
if ($result->rowCount() > 0) {
|
||||
$transaction = $this->transactionModel->find($id);
|
||||
$this->success($transaction, 'Transaction updated successfully');
|
||||
} else {
|
||||
$this->success($existingTransaction, 'No changes made');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update Transaction', ['transaction_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update transaction');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/transactions/{id}",
|
||||
* summary="Delete transaction",
|
||||
* tags={"Transactions"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="Transaction ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Transaction deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Transaction deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete Transaction', ['transaction_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('transactions.delete')) {
|
||||
$this->forbidden('Permission denied: transactions.delete required');
|
||||
}
|
||||
|
||||
// Check if transaction exists
|
||||
$transaction = $this->transactionModel->find($id);
|
||||
if (!$transaction) {
|
||||
$this->notFound('Transaction not found');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->transactionModel->db->delete('transactions', ['id' => $id]);
|
||||
|
||||
if ($result->rowCount() > 0) {
|
||||
$this->success(null, 'Transaction deleted successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to delete transaction');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete Transaction', ['transaction_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete transaction');
|
||||
}
|
||||
}
|
||||
}
|
||||
405
app/Controllers/Api/UsersApiController.php
Normal file
405
app/Controllers/Api/UsersApiController.php
Normal file
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ApiController.php';
|
||||
require_once __DIR__ . '/../../Models/User.php';
|
||||
|
||||
/**
|
||||
* @OA\Tag(
|
||||
* name="Users",
|
||||
* description="User management operations"
|
||||
* )
|
||||
*/
|
||||
class UsersApiController extends ApiController {
|
||||
|
||||
private $userModel;
|
||||
|
||||
public function __construct($database) {
|
||||
parent::__construct($database);
|
||||
$this->userModel = new User($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/users",
|
||||
* summary="Get all users",
|
||||
* tags={"Users"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="page",
|
||||
* in="query",
|
||||
* description="Page number",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, default=1)
|
||||
* ),
|
||||
* @OA\Parameter(
|
||||
* name="limit",
|
||||
* in="query",
|
||||
* description="Items per page",
|
||||
* required=false,
|
||||
* @OA\Schema(type="integer", minimum=1, maximum=100, default=20)
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Users retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="Users retrieved successfully"),
|
||||
* @OA\Property(property="data", type="object",
|
||||
* @OA\Property(property="data", type="array",
|
||||
* @OA\Items(ref="#/components/schemas/User")
|
||||
* ),
|
||||
* @OA\Property(property="pagination", ref="#/components/schemas/Pagination")
|
||||
* ),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function index() {
|
||||
$this->logRequest('Get Users');
|
||||
|
||||
if (!$this->hasPermission('users.read')) {
|
||||
$this->forbidden('Permission denied: users.read required');
|
||||
}
|
||||
|
||||
$pagination = $this->getPagination();
|
||||
|
||||
try {
|
||||
$users = $this->userModel->db->select('users', [
|
||||
'id', 'name', 'email', 'role', 'two_factor_enabled', 'created_at', 'updated_at'
|
||||
], [
|
||||
'LIMIT' => [$pagination['offset'], $pagination['limit']],
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
|
||||
$total = $this->userModel->db->count('users');
|
||||
|
||||
$response = $this->paginatedResponse($users, $total, $pagination);
|
||||
$this->success($response, 'Users retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get Users', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve users');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/v1/users/{id}",
|
||||
* summary="Get user by ID",
|
||||
* tags={"Users"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="User ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="User retrieved successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="User retrieved successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function show($id) {
|
||||
$this->logRequest('Get User', ['user_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('users.read')) {
|
||||
$this->forbidden('Permission denied: users.read required');
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->userModel->db->get('users', [
|
||||
'id', 'name', 'email', 'role', 'two_factor_enabled', 'created_at', 'updated_at'
|
||||
], ['id' => $id]);
|
||||
|
||||
if (!$user) {
|
||||
$this->notFound('User not found');
|
||||
}
|
||||
|
||||
$this->success($user, 'User retrieved successfully');
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Get User', ['user_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to retrieve user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Post(
|
||||
* path="/api/v1/users",
|
||||
* summary="Create a new user",
|
||||
* tags={"Users"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* required={"name", "email", "password", "role"},
|
||||
* @OA\Property(property="name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="email", type="string", format="email", example="john@example.com"),
|
||||
* @OA\Property(property="password", type="string", minLength=8, example="password123"),
|
||||
* @OA\Property(property="role", type="string", enum={"admin", "user"}, example="user")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=201,
|
||||
* description="User created successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="User created successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function store() {
|
||||
$this->logRequest('Create User');
|
||||
|
||||
if (!$this->hasPermission('users.create')) {
|
||||
$this->forbidden('Permission denied: users.create required');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
|
||||
// Validate required fields
|
||||
$errors = $this->validateRequired($data, ['name', 'email', 'password', 'role']);
|
||||
|
||||
// Additional validations
|
||||
if (isset($data['email']) && !$this->validateEmail($data['email'])) {
|
||||
$errors['email'] = 'Please provide a valid email address.';
|
||||
}
|
||||
|
||||
if (isset($data['password']) && strlen($data['password']) < 8) {
|
||||
$errors['password'] = 'Password must be at least 8 characters long.';
|
||||
}
|
||||
|
||||
if (isset($data['role']) && !in_array($data['role'], ['admin', 'user'])) {
|
||||
$errors['role'] = 'Role must be either admin or user.';
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
if (isset($data['email'])) {
|
||||
$existingUser = $this->userModel->findByEmail($data['email']);
|
||||
if ($existingUser) {
|
||||
$errors['email'] = 'Email address is already in use.';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$userData = [
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => password_hash($data['password'], PASSWORD_DEFAULT),
|
||||
'role' => $data['role'],
|
||||
'two_factor_enabled' => false,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
$result = $this->userModel->db->insert('users', $userData);
|
||||
|
||||
if ($result->rowCount() > 0) {
|
||||
$userId = $this->userModel->db->id();
|
||||
$user = $this->userModel->db->get('users', [
|
||||
'id', 'name', 'email', 'role', 'two_factor_enabled', 'created_at', 'updated_at'
|
||||
], ['id' => $userId]);
|
||||
|
||||
$this->success($user, 'User created successfully', 201);
|
||||
} else {
|
||||
$this->serverError('Failed to create user');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Create User', ['error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to create user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Put(
|
||||
* path="/api/v1/users/{id}",
|
||||
* summary="Update user",
|
||||
* tags={"Users"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="User ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\RequestBody(
|
||||
* required=true,
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="name", type="string", example="John Doe"),
|
||||
* @OA\Property(property="email", type="string", format="email", example="john@example.com"),
|
||||
* @OA\Property(property="password", type="string", minLength=8, example="newpassword123"),
|
||||
* @OA\Property(property="role", type="string", enum={"admin", "user"}, example="user")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="User updated successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="User updated successfully"),
|
||||
* @OA\Property(property="data", ref="#/components/schemas/User"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=422, ref="#/components/responses/ValidationError"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function update($id) {
|
||||
$this->logRequest('Update User', ['user_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('users.update')) {
|
||||
$this->forbidden('Permission denied: users.update required');
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
$existingUser = $this->userModel->find($id);
|
||||
if (!$existingUser) {
|
||||
$this->notFound('User not found');
|
||||
}
|
||||
|
||||
$data = $this->sanitize($this->request['body']);
|
||||
$errors = [];
|
||||
$updateData = ['updated_at' => date('Y-m-d H:i:s')];
|
||||
|
||||
// Validate and prepare update data
|
||||
if (isset($data['name']) && !empty($data['name'])) {
|
||||
$updateData['name'] = $data['name'];
|
||||
}
|
||||
|
||||
if (isset($data['email']) && !empty($data['email'])) {
|
||||
if (!$this->validateEmail($data['email'])) {
|
||||
$errors['email'] = 'Please provide a valid email address.';
|
||||
} else {
|
||||
// Check if email is already in use by another user
|
||||
$emailUser = $this->userModel->findByEmail($data['email']);
|
||||
if ($emailUser && $emailUser['id'] != $id) {
|
||||
$errors['email'] = 'Email address is already in use.';
|
||||
} else {
|
||||
$updateData['email'] = $data['email'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['password']) && !empty($data['password'])) {
|
||||
if (strlen($data['password']) < 8) {
|
||||
$errors['password'] = 'Password must be at least 8 characters long.';
|
||||
} else {
|
||||
$updateData['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($data['role']) && !empty($data['role'])) {
|
||||
if (!in_array($data['role'], ['admin', 'user'])) {
|
||||
$errors['role'] = 'Role must be either admin or user.';
|
||||
} else {
|
||||
$updateData['role'] = $data['role'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
$this->validationError($errors);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->userModel->db->update('users', $updateData, ['id' => $id]);
|
||||
|
||||
if ($result->rowCount() > 0) {
|
||||
$user = $this->userModel->db->get('users', [
|
||||
'id', 'name', 'email', 'role', 'two_factor_enabled', 'created_at', 'updated_at'
|
||||
], ['id' => $id]);
|
||||
|
||||
$this->success($user, 'User updated successfully');
|
||||
} else {
|
||||
$this->success($existingUser, 'No changes made');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Update User', ['user_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @OA\Delete(
|
||||
* path="/api/v1/users/{id}",
|
||||
* summary="Delete user",
|
||||
* tags={"Users"},
|
||||
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
|
||||
* @OA\Parameter(
|
||||
* name="id",
|
||||
* in="path",
|
||||
* description="User ID",
|
||||
* required=true,
|
||||
* @OA\Schema(type="integer")
|
||||
* ),
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="User deleted successfully",
|
||||
* @OA\JsonContent(
|
||||
* @OA\Property(property="success", type="boolean", example=true),
|
||||
* @OA\Property(property="message", type="string", example="User deleted successfully"),
|
||||
* @OA\Property(property="timestamp", type="string", format="date-time")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=404, ref="#/components/responses/NotFound"),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized"),
|
||||
* @OA\Response(response=403, ref="#/components/responses/Forbidden")
|
||||
* )
|
||||
*/
|
||||
public function delete($id) {
|
||||
$this->logRequest('Delete User', ['user_id' => $id]);
|
||||
|
||||
if (!$this->hasPermission('users.delete')) {
|
||||
$this->forbidden('Permission denied: users.delete required');
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
$user = $this->userModel->find($id);
|
||||
if (!$user) {
|
||||
$this->notFound('User not found');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->userModel->db->delete('users', ['id' => $id]);
|
||||
|
||||
if ($result->rowCount() > 0) {
|
||||
$this->success(null, 'User deleted successfully');
|
||||
} else {
|
||||
$this->serverError('Failed to delete user');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('API Error - Delete User', ['user_id' => $id, 'error' => $e->getMessage()]);
|
||||
$this->serverError('Failed to delete user');
|
||||
}
|
||||
}
|
||||
}
|
||||
245
app/Controllers/AuthController.php
Normal file
245
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/User.php';
|
||||
require_once __DIR__ . '/../Services/TwoFactorService.php';
|
||||
|
||||
class AuthController extends Controller {
|
||||
|
||||
private $userModel;
|
||||
private $twoFactorService;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->userModel = new User($db);
|
||||
$this->twoFactorService = new TwoFactorService();
|
||||
}
|
||||
|
||||
public function showLoginForm() {
|
||||
// CSRF token will be automatically added by base Controller
|
||||
$this->view('auth/login');
|
||||
}
|
||||
|
||||
public function login() {
|
||||
// Validate CSRF token using base controller method
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Security token mismatch. Please try again.');
|
||||
$this->view('auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isRateLimited()) {
|
||||
FlashMessage::error('Too many login attempts. Please try again later.');
|
||||
$this->view('auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate inputs
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
|
||||
if (empty($email) || empty($password) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->recordLoginAttempt(); // Record as a failed attempt
|
||||
FlashMessage::error('Please enter valid email and password.');
|
||||
$this->view('auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mitigate timing attacks and verify credentials
|
||||
$user = $this->userModel->findByEmail($email);
|
||||
|
||||
// If user exists, use their hash. If not, use a dummy hash.
|
||||
// This ensures password_verify runs every time, making timing attacks harder.
|
||||
$correctHash = $user ? $user['password'] : password_hash('dummy_password_for_timing_attack_mitigation', PASSWORD_DEFAULT);
|
||||
|
||||
if (password_verify($password, $correctHash) && $user) {
|
||||
// Check if 2FA is enabled
|
||||
if ($user['two_factor_enabled']) {
|
||||
// Store user data temporarily for 2FA verification
|
||||
$_SESSION['2fa_user_id'] = $user['id'];
|
||||
$_SESSION['2fa_user_data'] = [
|
||||
'id' => $user['id'],
|
||||
'name' => $user['name'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role']
|
||||
];
|
||||
|
||||
AppLogger::info('2FA required for user', [
|
||||
'user_id' => $user['id'],
|
||||
'email' => $user['email']
|
||||
]);
|
||||
|
||||
header('Location: /2fa/verify');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Regular login without 2FA
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user'] = [
|
||||
'id' => $user['id'],
|
||||
'name' => $user['name'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role']
|
||||
];
|
||||
unset($_SESSION['login_attempts']);
|
||||
|
||||
AppLogger::info('User logged in successfully', [
|
||||
'user_id' => $user['id'],
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role']
|
||||
]);
|
||||
|
||||
FlashMessage::success('Welcome back, ' . htmlspecialchars($user['name']) . '!');
|
||||
header('Location: /');
|
||||
exit();
|
||||
} else {
|
||||
$this->recordLoginAttempt();
|
||||
AppLogger::warning('Failed login attempt', [
|
||||
'email' => $email,
|
||||
'user_exists' => $user ? true : false
|
||||
]);
|
||||
FlashMessage::error('Invalid email or password. Please try again.');
|
||||
$this->view('auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
public function logout() {
|
||||
$userName = $_SESSION['user']['name'] ?? 'User';
|
||||
|
||||
// Unset all session values
|
||||
$_SESSION = [];
|
||||
|
||||
// Invalidate the session cookie
|
||||
if (ini_get("session.use_cookies")) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params["path"], $params["domain"],
|
||||
$params["secure"], $params["httponly"]
|
||||
);
|
||||
}
|
||||
|
||||
// Destroy the session
|
||||
session_destroy();
|
||||
|
||||
// Start a new session to show the flash message
|
||||
session_start();
|
||||
FlashMessage::success('You have been logged out successfully. See you soon, ' . htmlspecialchars($userName) . '!');
|
||||
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
|
||||
private function isRateLimited() {
|
||||
if (!isset($_SESSION['login_attempts'])) {
|
||||
$_SESSION['login_attempts'] = [];
|
||||
}
|
||||
|
||||
// Remove old attempts
|
||||
$timeout = Config::get('auth.login_attempts_timeout', 300);
|
||||
$_SESSION['login_attempts'] = array_filter($_SESSION['login_attempts'], function ($timestamp) use ($timeout) {
|
||||
return $timestamp > (time() - $timeout);
|
||||
});
|
||||
|
||||
$limit = Config::get('auth.login_attempts_limit', 5);
|
||||
return count($_SESSION['login_attempts']) >= $limit;
|
||||
}
|
||||
|
||||
private function recordLoginAttempt() {
|
||||
if (!isset($_SESSION['login_attempts'])) {
|
||||
$_SESSION['login_attempts'] = [];
|
||||
}
|
||||
$_SESSION['login_attempts'][] = time();
|
||||
}
|
||||
|
||||
public function show2FAForm() {
|
||||
// Check if user is in 2FA verification state
|
||||
if (!isset($_SESSION['2fa_user_id'])) {
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('auth/2fa-verify');
|
||||
}
|
||||
|
||||
public function verify2FA() {
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Security token mismatch. Please try again.');
|
||||
$this->view('auth/2fa-verify');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is in 2FA verification state
|
||||
if (!isset($_SESSION['2fa_user_id']) || !isset($_SESSION['2fa_user_data'])) {
|
||||
FlashMessage::error('Invalid 2FA session. Please login again.');
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['2fa_user_id'];
|
||||
$userData = $_SESSION['2fa_user_data'];
|
||||
$code = trim($_POST['code'] ?? '');
|
||||
|
||||
if (empty($code)) {
|
||||
FlashMessage::error('Please enter the 2FA code.');
|
||||
$this->view('auth/2fa-verify');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user data to verify 2FA
|
||||
$user = $this->userModel->find($userId);
|
||||
if (!$user || !$user['two_factor_enabled']) {
|
||||
FlashMessage::error('2FA is not properly configured. Please contact support.');
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
|
||||
$isValid = false;
|
||||
|
||||
// First try to verify as regular 2FA code
|
||||
if ($this->twoFactorService->verifyCode($user['two_factor_secret'], $code)) {
|
||||
$isValid = true;
|
||||
} else {
|
||||
// Try to verify as backup code
|
||||
$backupResult = $this->twoFactorService->verifyBackupCode($user['two_factor_backup_codes'], $code);
|
||||
if ($backupResult['valid']) {
|
||||
$isValid = true;
|
||||
// Update remaining backup codes
|
||||
$this->userModel->updateBackupCodes($userId, $backupResult['remaining_codes']);
|
||||
|
||||
AppLogger::info('Backup code used for 2FA', [
|
||||
'user_id' => $userId,
|
||||
'email' => $user['email']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($isValid) {
|
||||
// Complete login process
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user'] = $userData;
|
||||
|
||||
// Clean up 2FA session data
|
||||
unset($_SESSION['2fa_user_id']);
|
||||
unset($_SESSION['2fa_user_data']);
|
||||
unset($_SESSION['login_attempts']);
|
||||
|
||||
AppLogger::info('User logged in successfully with 2FA', [
|
||||
'user_id' => $userId,
|
||||
'email' => $user['email'],
|
||||
'role' => $user['role']
|
||||
]);
|
||||
|
||||
FlashMessage::success('Welcome back, ' . htmlspecialchars($userData['name']) . '!');
|
||||
header('Location: /');
|
||||
exit();
|
||||
} else {
|
||||
$this->recordLoginAttempt();
|
||||
AppLogger::warning('Failed 2FA verification', [
|
||||
'user_id' => $userId,
|
||||
'email' => $user['email']
|
||||
]);
|
||||
FlashMessage::error('Invalid 2FA code. Please try again.');
|
||||
$this->view('auth/2fa-verify');
|
||||
}
|
||||
}
|
||||
}
|
||||
519
app/Controllers/BankAccountController.php
Normal file
519
app/Controllers/BankAccountController.php
Normal file
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/BankAccount.php';
|
||||
|
||||
class BankAccountController extends Controller {
|
||||
|
||||
private $bankAccountModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->bankAccountModel = new BankAccount($db);
|
||||
}
|
||||
|
||||
private function checkAuthentication() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['user']['id'])) {
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public function index() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Show all bank accounts in centralized system - no user filtering
|
||||
$bankAccounts = $this->bankAccountModel->getAllWithUserInfo();
|
||||
|
||||
$this->view('dashboard/bank-accounts/index', [
|
||||
'bankAccounts' => $bankAccounts,
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#bank-accounts-table'
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$this->view('dashboard/bank-accounts/create', [
|
||||
'accountTypes' => BankAccount::getAccountTypes(),
|
||||
'currencies' => BankAccount::getSupportedCurrencies()
|
||||
]);
|
||||
}
|
||||
|
||||
public function store() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Validate input
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$bankName = trim($_POST['bank_name'] ?? '');
|
||||
$accountType = trim($_POST['account_type'] ?? 'checking');
|
||||
$accountNumber = trim($_POST['account_number'] ?? '');
|
||||
$routingNumber = trim($_POST['routing_number'] ?? '');
|
||||
$currency = trim($_POST['currency'] ?? 'USD');
|
||||
$countryCode = trim($_POST['country_code'] ?? '');
|
||||
$iban = trim($_POST['iban'] ?? '');
|
||||
$swiftBic = trim($_POST['swift_bic'] ?? '');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Account name is required.');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (empty($bankName)) {
|
||||
FlashMessage::error('Bank name is required.');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (empty($accountNumber)) {
|
||||
FlashMessage::error('Account number is required.');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate account number format
|
||||
if (!BankAccount::validateAccountNumber($accountNumber)) {
|
||||
FlashMessage::error('Please enter a valid account number (8-17 digits).');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate routing number if provided
|
||||
if (!empty($routingNumber) && !BankAccount::validateRoutingNumber($routingNumber)) {
|
||||
FlashMessage::error('Please enter a valid routing/sort code (6-15 digits).');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate IBAN if provided
|
||||
if (!empty($iban) && !BankAccount::validateIban($iban)) {
|
||||
FlashMessage::error('Please enter a valid IBAN format.');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate SWIFT/BIC if provided
|
||||
if (!empty($swiftBic) && !BankAccount::validateSwiftBic($swiftBic)) {
|
||||
FlashMessage::error('Please enter a valid SWIFT/BIC code format.');
|
||||
header('Location: /bank-accounts/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate account type
|
||||
$validTypes = array_keys(BankAccount::getAccountTypes());
|
||||
if (!in_array($accountType, $validTypes)) {
|
||||
$accountType = 'checking';
|
||||
}
|
||||
|
||||
// Validate currency
|
||||
$validCurrencies = array_keys(BankAccount::getSupportedCurrencies());
|
||||
if (!in_array($currency, $validCurrencies)) {
|
||||
$currency = 'USD';
|
||||
}
|
||||
|
||||
// Store only last 4 digits of account number
|
||||
$accountNumberLast4 = substr(preg_replace('/[\s\-]/', '', $accountNumber), -4);
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
'bank_name' => $bankName,
|
||||
'account_type' => $accountType,
|
||||
'account_number_last4' => $accountNumberLast4,
|
||||
'routing_number' => $routingNumber,
|
||||
'currency' => $currency,
|
||||
'country_code' => $countryCode,
|
||||
'iban' => $iban,
|
||||
'swift_bic' => $swiftBic,
|
||||
'notes' => $notes,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$accountId = $this->bankAccountModel->create($data);
|
||||
|
||||
if ($accountId) {
|
||||
AppLogger::info('Bank account created', [
|
||||
'user_id' => $userId,
|
||||
'account_id' => $accountId,
|
||||
'name' => $name,
|
||||
'bank_name' => $bankName
|
||||
]);
|
||||
FlashMessage::success('Bank account created successfully!');
|
||||
header('Location: /bank-accounts');
|
||||
} else {
|
||||
FlashMessage::error('Failed to create bank account. Please try again.');
|
||||
header('Location: /bank-accounts/create');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to create bank account', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to create bank account. Please try again.');
|
||||
header('Location: /bank-accounts/create');
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
public function edit($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$account = $this->bankAccountModel->find($id);
|
||||
|
||||
if (!$account) {
|
||||
FlashMessage::error('Bank account not found.');
|
||||
header('Location: /bank-accounts');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/bank-accounts/edit', [
|
||||
'account' => $account,
|
||||
'accountTypes' => BankAccount::getAccountTypes(),
|
||||
'currencies' => BankAccount::getSupportedCurrencies()
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /bank-accounts/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$account = $this->bankAccountModel->find($id);
|
||||
|
||||
if (!$account) {
|
||||
FlashMessage::error('Bank account not found.');
|
||||
header('Location: /bank-accounts');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate input
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$bankName = trim($_POST['bank_name'] ?? '');
|
||||
$accountType = trim($_POST['account_type'] ?? 'checking');
|
||||
$routingNumber = trim($_POST['routing_number'] ?? '');
|
||||
$currency = trim($_POST['currency'] ?? 'USD');
|
||||
$countryCode = trim($_POST['country_code'] ?? '');
|
||||
$iban = trim($_POST['iban'] ?? '');
|
||||
$swiftBic = trim($_POST['swift_bic'] ?? '');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Account name is required.');
|
||||
header('Location: /bank-accounts/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (empty($bankName)) {
|
||||
FlashMessage::error('Bank name is required.');
|
||||
header('Location: /bank-accounts/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate routing number if provided
|
||||
if (!empty($routingNumber) && !BankAccount::validateRoutingNumber($routingNumber)) {
|
||||
FlashMessage::error('Please enter a valid routing/sort code (6-15 digits).');
|
||||
header('Location: /bank-accounts/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate IBAN if provided
|
||||
if (!empty($iban) && !BankAccount::validateIban($iban)) {
|
||||
FlashMessage::error('Please enter a valid IBAN format.');
|
||||
header('Location: /bank-accounts/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate SWIFT/BIC if provided
|
||||
if (!empty($swiftBic) && !BankAccount::validateSwiftBic($swiftBic)) {
|
||||
FlashMessage::error('Please enter a valid SWIFT/BIC code format.');
|
||||
header('Location: /bank-accounts/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate account type
|
||||
$validTypes = array_keys(BankAccount::getAccountTypes());
|
||||
if (!in_array($accountType, $validTypes)) {
|
||||
$accountType = 'checking';
|
||||
}
|
||||
|
||||
// Validate currency
|
||||
$validCurrencies = array_keys(BankAccount::getSupportedCurrencies());
|
||||
if (!in_array($currency, $validCurrencies)) {
|
||||
$currency = 'USD';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'bank_name' => $bankName,
|
||||
'account_type' => $accountType,
|
||||
'routing_number' => $routingNumber,
|
||||
'currency' => $currency,
|
||||
'country_code' => $countryCode,
|
||||
'iban' => $iban,
|
||||
'swift_bic' => $swiftBic,
|
||||
'notes' => $notes,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$result = $this->bankAccountModel->update($id, $data);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Bank account updated', [
|
||||
'user_id' => $userId,
|
||||
'account_id' => $id,
|
||||
'name' => $name
|
||||
]);
|
||||
FlashMessage::success('Bank account updated successfully!');
|
||||
} else {
|
||||
FlashMessage::error('No changes were made.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to update bank account', [
|
||||
'user_id' => $userId,
|
||||
'account_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to update bank account. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /bank-accounts');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /bank-accounts');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$account = $this->bankAccountModel->find($id);
|
||||
|
||||
if (!$account) {
|
||||
FlashMessage::error('Bank account not found.');
|
||||
header('Location: /bank-accounts');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if account is used in expenses or subscriptions
|
||||
$usedInExpenses = $this->bankAccountModel->isUsedInExpenses($id);
|
||||
$usedInSubscriptions = $this->bankAccountModel->isUsedInSubscriptions($id);
|
||||
|
||||
if ($usedInExpenses || $usedInSubscriptions) {
|
||||
$usageTypes = [];
|
||||
if ($usedInExpenses) $usageTypes[] = 'expenses';
|
||||
if ($usedInSubscriptions) $usageTypes[] = 'subscriptions';
|
||||
|
||||
FlashMessage::error('Cannot delete bank account that is used in ' . implode(' and ', $usageTypes) . '. Please reassign or delete them first.');
|
||||
header('Location: /bank-accounts');
|
||||
exit();
|
||||
}
|
||||
|
||||
$result = $this->bankAccountModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Bank account deleted', [
|
||||
'user_id' => $userId,
|
||||
'account_id' => $id,
|
||||
'name' => $account['name']
|
||||
]);
|
||||
FlashMessage::success('Bank account deleted successfully!');
|
||||
} else {
|
||||
FlashMessage::error('Failed to delete bank account. Please try again.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to delete bank account', [
|
||||
'user_id' => $userId,
|
||||
'account_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to delete bank account. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /bank-accounts');
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bank accounts for AJAX requests (for expense/subscription forms)
|
||||
*/
|
||||
public function ajaxList() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Return all bank accounts for centralized system
|
||||
$currency = $_GET['currency'] ?? null;
|
||||
|
||||
if ($currency) {
|
||||
// Note: This would need to be updated to search all accounts by currency
|
||||
$accounts = $this->bankAccountModel->getAllWithUserInfo();
|
||||
// Filter by currency
|
||||
$accounts = array_filter($accounts, function($account) use ($currency) {
|
||||
return $account['currency'] === $currency;
|
||||
});
|
||||
} else {
|
||||
$accounts = $this->bankAccountModel->getAllWithUserInfo();
|
||||
}
|
||||
|
||||
// Add masked account number for display
|
||||
foreach ($accounts as &$account) {
|
||||
$account['account_number_masked'] = BankAccount::getMaskedAccountNumber($account['account_number_last4']);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'accounts' => $accounts
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account details (AJAX endpoint)
|
||||
*/
|
||||
public function details($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$account = $this->bankAccountModel->find($id);
|
||||
|
||||
if (!$account) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Account not found']);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Get usage statistics
|
||||
$stats = $this->bankAccountModel->getUsageStats($id);
|
||||
$account['stats'] = $stats;
|
||||
$account['account_number_masked'] = BankAccount::getMaskedAccountNumber($account['account_number_last4']);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'account' => $account
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search bank accounts (AJAX endpoint)
|
||||
*/
|
||||
public function search() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$query = trim($_GET['q'] ?? '');
|
||||
|
||||
if (empty($query)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true, 'accounts' => []]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$accounts = $this->bankAccountModel->search($userId, $query);
|
||||
|
||||
// Add masked account number for display
|
||||
foreach ($accounts as &$account) {
|
||||
$account['account_number_masked'] = BankAccount::getMaskedAccountNumber($account['account_number_last4']);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'accounts' => $accounts
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounts by currency (AJAX endpoint)
|
||||
*/
|
||||
public function byCurrency($currency) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Validate currency
|
||||
$validCurrencies = array_keys(BankAccount::getSupportedCurrencies());
|
||||
if (!in_array($currency, $validCurrencies)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid currency']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$accounts = $this->bankAccountModel->getByCurrency($userId, $currency);
|
||||
|
||||
// Add masked account number for display
|
||||
foreach ($accounts as &$account) {
|
||||
$account['account_number_masked'] = BankAccount::getMaskedAccountNumber($account['account_number_last4']);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'accounts' => $accounts
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate account number (AJAX endpoint)
|
||||
*/
|
||||
public function validateAccount() {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$accountNumber = trim($_POST['account_number'] ?? '');
|
||||
$routingNumber = trim($_POST['routing_number'] ?? '');
|
||||
|
||||
$response = [
|
||||
'account_valid' => false,
|
||||
'routing_valid' => false,
|
||||
'messages' => []
|
||||
];
|
||||
|
||||
if (!empty($accountNumber)) {
|
||||
$response['account_valid'] = BankAccount::validateAccountNumber($accountNumber);
|
||||
if (!$response['account_valid']) {
|
||||
$response['messages'][] = 'Account number must be 8-17 digits';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($routingNumber)) {
|
||||
$response['routing_valid'] = BankAccount::validateRoutingNumber($routingNumber);
|
||||
if (!$response['routing_valid']) {
|
||||
$response['messages'][] = 'Routing number must be 6-15 digits (international format)';
|
||||
}
|
||||
}
|
||||
|
||||
$response['success'] = $response['account_valid'] && ($response['routing_valid'] || empty($routingNumber));
|
||||
|
||||
echo json_encode($response);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
391
app/Controllers/CategoryController.php
Normal file
391
app/Controllers/CategoryController.php
Normal file
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/Category.php';
|
||||
|
||||
class CategoryController extends Controller {
|
||||
|
||||
private $categoryModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->categoryModel = new Category($db);
|
||||
}
|
||||
|
||||
private function checkAuthentication() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['user']['id'])) {
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public function index() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Show all categories in centralized system - no user filtering
|
||||
$categories = $this->categoryModel->getAllCategoriesWithExpenseCountAndUser();
|
||||
|
||||
$this->view('dashboard/categories/index', [
|
||||
'categories' => $categories,
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#categories-table'
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$this->view('dashboard/categories/create', [
|
||||
'defaultCategories' => Category::getDefaultCategories()
|
||||
]);
|
||||
}
|
||||
|
||||
public function store() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /categories/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Validate input
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$color = trim($_POST['color'] ?? '#3B82F6');
|
||||
$icon = trim($_POST['icon'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Category name is required.');
|
||||
header('Location: /categories/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if category name already exists for user
|
||||
if ($this->categoryModel->nameExistsForUser($name, $userId)) {
|
||||
FlashMessage::error('A category with this name already exists.');
|
||||
header('Location: /categories/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate color format
|
||||
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
|
||||
$color = '#3B82F6'; // Default color
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'color' => $color,
|
||||
'icon' => $icon,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$categoryId = $this->categoryModel->create($data);
|
||||
|
||||
if ($categoryId) {
|
||||
AppLogger::info('Category created', [
|
||||
'user_id' => $userId,
|
||||
'category_id' => $categoryId,
|
||||
'name' => $name
|
||||
]);
|
||||
FlashMessage::success('Category created successfully!');
|
||||
header('Location: /categories');
|
||||
} else {
|
||||
FlashMessage::error('Failed to create category. Please try again.');
|
||||
header('Location: /categories/create');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to create category', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to create category. Please try again.');
|
||||
header('Location: /categories/create');
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
public function edit($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$category = $this->categoryModel->find($id);
|
||||
|
||||
if (!$category) {
|
||||
FlashMessage::error('Category not found.');
|
||||
header('Location: /categories');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/categories/edit', [
|
||||
'category' => $category
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /categories/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$category = $this->categoryModel->find($id);
|
||||
|
||||
if (!$category) {
|
||||
FlashMessage::error('Category not found.');
|
||||
header('Location: /categories');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate input
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$color = trim($_POST['color'] ?? '#3B82F6');
|
||||
$icon = trim($_POST['icon'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Category name is required.');
|
||||
header('Location: /categories/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if category name already exists for user (excluding current category)
|
||||
if ($this->categoryModel->nameExistsForUser($name, $userId, $id)) {
|
||||
FlashMessage::error('A category with this name already exists.');
|
||||
header('Location: /categories/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate color format
|
||||
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
|
||||
$color = '#3B82F6'; // Default color
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'color' => $color,
|
||||
'icon' => $icon,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$result = $this->categoryModel->update($id, $data);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Category updated', [
|
||||
'user_id' => $userId,
|
||||
'category_id' => $id,
|
||||
'name' => $name
|
||||
]);
|
||||
FlashMessage::success('Category updated successfully!');
|
||||
} else {
|
||||
FlashMessage::error('No changes were made.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to update category', [
|
||||
'user_id' => $userId,
|
||||
'category_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to update category. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /categories');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /categories');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$category = $this->categoryModel->find($id);
|
||||
|
||||
if (!$category) {
|
||||
FlashMessage::error('Category not found.');
|
||||
header('Location: /categories');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if category is used in expenses
|
||||
require_once __DIR__ . '/../Models/Expense.php';
|
||||
$expenseModel = new Expense($this->categoryModel->getDB());
|
||||
$expensesInCategory = $expenseModel->getByCategoryId($id);
|
||||
|
||||
if (!empty($expensesInCategory)) {
|
||||
FlashMessage::error('Cannot delete category that is used in expenses. Please reassign or delete the expenses first.');
|
||||
header('Location: /categories');
|
||||
exit();
|
||||
}
|
||||
|
||||
$result = $this->categoryModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Category deleted', [
|
||||
'user_id' => $userId,
|
||||
'category_id' => $id,
|
||||
'name' => $category['name']
|
||||
]);
|
||||
FlashMessage::success('Category deleted successfully!');
|
||||
} else {
|
||||
FlashMessage::error('Failed to delete category. Please try again.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to delete category', [
|
||||
'user_id' => $userId,
|
||||
'category_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to delete category. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /categories');
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default categories for user (AJAX endpoint)
|
||||
*/
|
||||
public function createDefaults() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid security token']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
try {
|
||||
$created = $this->categoryModel->createDefaultCategories($userId);
|
||||
|
||||
if ($created > 0) {
|
||||
AppLogger::info('Default categories created', [
|
||||
'user_id' => $userId,
|
||||
'count' => $created
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "Created default 'General' category successfully!",
|
||||
'count' => $created
|
||||
]);
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'No categories were created']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to create default categories', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Failed to create default categories']);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories for AJAX requests (for expense forms)
|
||||
*/
|
||||
public function ajaxList() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Return all categories for centralized system
|
||||
$categories = $this->categoryModel->getAllWithUserInfo();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'categories' => $categories
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick create category (AJAX endpoint for expense forms)
|
||||
*/
|
||||
public function quickCreate() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid security token']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$color = trim($_POST['color'] ?? '#3B82F6');
|
||||
|
||||
if (empty($name)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Category name is required']);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if category already exists
|
||||
if ($this->categoryModel->nameExistsForUser($name, $userId)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Category already exists']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'color' => $color,
|
||||
'icon' => 'fas fa-folder',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$categoryId = $this->categoryModel->create($data);
|
||||
|
||||
if ($categoryId) {
|
||||
$category = $this->categoryModel->find($categoryId);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Category created successfully!',
|
||||
'category' => $category
|
||||
]);
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Failed to create category']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Failed to create category']);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
}
|
||||
65
app/Controllers/Controller.php
Normal file
65
app/Controllers/Controller.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../Services/FlashMessage.php';
|
||||
|
||||
class Controller {
|
||||
protected function view($view, $data = []) {
|
||||
// Ensure CSRF token is available in all views
|
||||
if (!isset($data['csrf_token'])) {
|
||||
$data['csrf_token'] = $this->getCsrfToken();
|
||||
}
|
||||
|
||||
// Make flash messages available in all views
|
||||
$data['flash_messages'] = FlashMessage::getAll();
|
||||
|
||||
extract($data);
|
||||
$view_data = $data;
|
||||
require_once __DIR__ . "/../Views/{$view}.php";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate CSRF token
|
||||
*/
|
||||
protected function getCsrfToken() {
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Generate token if not exists
|
||||
if (empty($_SESSION['_token'])) {
|
||||
$_SESSION['_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
return $_SESSION['_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSRF token
|
||||
*/
|
||||
protected function validateCsrfToken() {
|
||||
$sessionToken = $_SESSION['_token'] ?? '';
|
||||
$postToken = $_POST['_token'] ?? '';
|
||||
|
||||
if (empty($sessionToken) || empty($postToken) || !hash_equals($sessionToken, $postToken)) {
|
||||
AppLogger::warning('CSRF token validation failed', [
|
||||
'session_token_exists' => !empty($sessionToken),
|
||||
'post_token_exists' => !empty($postToken),
|
||||
'session_id' => session_id(),
|
||||
'session_token_length' => strlen($sessionToken),
|
||||
'post_token_length' => strlen($postToken),
|
||||
'session_token_preview' => substr($sessionToken, 0, 8) . '...',
|
||||
'post_token_preview' => substr($postToken, 0, 8) . '...',
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown'
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
AppLogger::debug('CSRF token validation successful', [
|
||||
'session_id' => session_id(),
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
159
app/Controllers/CreditCardController.php
Normal file
159
app/Controllers/CreditCardController.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/CreditCard.php';
|
||||
|
||||
class CreditCardController extends Controller {
|
||||
|
||||
private $creditCardModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->creditCardModel = new CreditCard($db);
|
||||
}
|
||||
|
||||
public function index() {
|
||||
// For now, get all records since DataTables handles pagination client-side
|
||||
// In the future, this could be optimized with server-side pagination for very large datasets
|
||||
$creditCards = $this->creditCardModel->getAllWithUserInfo();
|
||||
$this->view('dashboard/credit_cards/index', [
|
||||
'credit_cards' => $creditCards,
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#credit-cards-table'
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$this->view('dashboard/credit_cards/create');
|
||||
}
|
||||
|
||||
public function store() {
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /credit-cards/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (empty($_POST['name']) || empty($_POST['card_number']) || empty($_POST['expiry_month']) || empty($_POST['expiry_year'])) {
|
||||
FlashMessage::error('Please fill in all required fields.');
|
||||
header('Location: /credit-cards/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate card number (basic check for digits and length)
|
||||
$cardNumber = preg_replace('/\s+/', '', $_POST['card_number']);
|
||||
if (!ctype_digit($cardNumber) || strlen($cardNumber) < 13 || strlen($cardNumber) > 19) {
|
||||
FlashMessage::error('Please enter a valid credit card number.');
|
||||
header('Location: /credit-cards/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'name' => $_POST['name'],
|
||||
'card_number_last4' => substr($cardNumber, -4),
|
||||
'expiry_month' => $_POST['expiry_month'],
|
||||
'expiry_year' => $_POST['expiry_year'],
|
||||
];
|
||||
|
||||
try {
|
||||
$this->creditCardModel->create($data);
|
||||
FlashMessage::success('Credit card added successfully!');
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to add credit card. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /credit-cards');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function edit($id) {
|
||||
$creditCard = $this->creditCardModel->find($id);
|
||||
|
||||
if (!$creditCard) {
|
||||
FlashMessage::error('Credit card not found.');
|
||||
header('Location: /credit-cards');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/credit_cards/edit', ['credit_card' => $creditCard]);
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /credit-cards/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if credit card exists
|
||||
$creditCard = $this->creditCardModel->find($id);
|
||||
if (!$creditCard) {
|
||||
FlashMessage::error('Credit card not found.');
|
||||
header('Location: /credit-cards');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (empty($_POST['name']) || empty($_POST['card_number']) || empty($_POST['expiry_month']) || empty($_POST['expiry_year'])) {
|
||||
FlashMessage::error('Please fill in all required fields.');
|
||||
header('Location: /credit-cards/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate card number (basic check for digits and length)
|
||||
$cardNumber = preg_replace('/\s+/', '', $_POST['card_number']);
|
||||
if (!ctype_digit($cardNumber) || strlen($cardNumber) < 13 || strlen($cardNumber) > 19) {
|
||||
FlashMessage::error('Please enter a valid credit card number.');
|
||||
header('Location: /credit-cards/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $_POST['name'],
|
||||
'card_number_last4' => substr($cardNumber, -4),
|
||||
'expiry_month' => $_POST['expiry_month'],
|
||||
'expiry_year' => $_POST['expiry_year'],
|
||||
];
|
||||
|
||||
try {
|
||||
$this->creditCardModel->update($id, $data);
|
||||
FlashMessage::success('Credit card updated successfully!');
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to update credit card. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /credit-cards');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /credit-cards');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if credit card exists
|
||||
$creditCard = $this->creditCardModel->find($id);
|
||||
if (!$creditCard) {
|
||||
FlashMessage::error('Credit card not found.');
|
||||
header('Location: /credit-cards');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->creditCardModel->delete($id);
|
||||
FlashMessage::success('Credit card deleted successfully!');
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to delete credit card. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /credit-cards');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
519
app/Controllers/CryptoWalletController.php
Normal file
519
app/Controllers/CryptoWalletController.php
Normal file
@@ -0,0 +1,519 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/CryptoWallet.php';
|
||||
|
||||
class CryptoWalletController extends Controller {
|
||||
|
||||
private $cryptoWalletModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->cryptoWalletModel = new CryptoWallet($db);
|
||||
}
|
||||
|
||||
private function checkAuthentication() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['user']['id'])) {
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public function index() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Show all crypto wallets in centralized system - no user filtering
|
||||
$wallets = $this->cryptoWalletModel->getAllWithUserInfo();
|
||||
|
||||
$this->view('dashboard/crypto-wallets/index', [
|
||||
'wallets' => $wallets,
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#crypto-wallets-table'
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$this->view('dashboard/crypto-wallets/create', [
|
||||
'networks' => CryptoWallet::getSupportedNetworks(),
|
||||
'currencies' => CryptoWallet::getSupportedCurrencies()
|
||||
]);
|
||||
}
|
||||
|
||||
public function store() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Validate input
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$network = trim($_POST['network'] ?? '');
|
||||
$currency = trim($_POST['currency'] ?? '');
|
||||
$address = trim($_POST['address'] ?? '');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Wallet name is required.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (empty($network)) {
|
||||
FlashMessage::error('Network is required.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (empty($currency)) {
|
||||
FlashMessage::error('Currency is required.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (empty($address)) {
|
||||
FlashMessage::error('Wallet address is required.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate network
|
||||
$validNetworks = array_keys(CryptoWallet::getSupportedNetworks());
|
||||
if (!in_array($network, $validNetworks)) {
|
||||
FlashMessage::error('Invalid network selected.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate currency and network combination
|
||||
// Since currency is now a free text input, we don't validate predefined combinations
|
||||
// Users can enter any currency symbol they want
|
||||
|
||||
// Validate address format
|
||||
if (!CryptoWallet::validateAddress($address, $network)) {
|
||||
FlashMessage::error('Invalid wallet address format for the selected network.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if address already exists for user
|
||||
if ($this->cryptoWalletModel->addressExistsForUser($address, $userId)) {
|
||||
FlashMessage::error('A wallet with this address already exists.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
'network' => $network,
|
||||
'currency' => $currency,
|
||||
'address' => $address,
|
||||
'notes' => $notes,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$walletId = $this->cryptoWalletModel->create($data);
|
||||
|
||||
if ($walletId) {
|
||||
AppLogger::info('Crypto wallet created', [
|
||||
'user_id' => $userId,
|
||||
'wallet_id' => $walletId,
|
||||
'name' => $name,
|
||||
'network' => $network,
|
||||
'currency' => $currency
|
||||
]);
|
||||
FlashMessage::success('Crypto wallet created successfully!');
|
||||
header('Location: /crypto-wallets');
|
||||
} else {
|
||||
FlashMessage::error('Failed to create crypto wallet. Please try again.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to create crypto wallet', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to create crypto wallet. Please try again.');
|
||||
header('Location: /crypto-wallets/create');
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
public function edit($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$wallet = $this->cryptoWalletModel->find($id);
|
||||
|
||||
if (!$wallet) {
|
||||
FlashMessage::error('Crypto wallet not found.');
|
||||
header('Location: /crypto-wallets');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/crypto-wallets/edit', [
|
||||
'wallet' => $wallet,
|
||||
'networks' => CryptoWallet::getSupportedNetworks(),
|
||||
'currencies' => CryptoWallet::getSupportedCurrencies()
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /crypto-wallets/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$wallet = $this->cryptoWalletModel->find($id);
|
||||
|
||||
if (!$wallet) {
|
||||
FlashMessage::error('Crypto wallet not found.');
|
||||
header('Location: /crypto-wallets');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate only editable fields (name and notes)
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$notes = trim($_POST['notes'] ?? '');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Wallet name is required.');
|
||||
header('Location: /crypto-wallets/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Use existing values for disabled fields (network, currency, address)
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'notes' => $notes
|
||||
];
|
||||
|
||||
try {
|
||||
$result = $this->cryptoWalletModel->update($id, $data);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Crypto wallet updated', [
|
||||
'user_id' => $userId,
|
||||
'wallet_id' => $id,
|
||||
'name' => $name
|
||||
]);
|
||||
FlashMessage::success('Crypto wallet updated successfully!');
|
||||
} else {
|
||||
FlashMessage::error('No changes were made.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to update crypto wallet', [
|
||||
'user_id' => $userId,
|
||||
'wallet_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to update crypto wallet. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /crypto-wallets');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /crypto-wallets');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$wallet = $this->cryptoWalletModel->find($id);
|
||||
|
||||
if (!$wallet) {
|
||||
FlashMessage::error('Crypto wallet not found.');
|
||||
header('Location: /crypto-wallets');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if wallet is used in expenses or subscriptions
|
||||
$usedInExpenses = $this->cryptoWalletModel->isUsedInExpenses($id);
|
||||
$usedInSubscriptions = $this->cryptoWalletModel->isUsedInSubscriptions($id);
|
||||
|
||||
if ($usedInExpenses || $usedInSubscriptions) {
|
||||
$usageTypes = [];
|
||||
if ($usedInExpenses) $usageTypes[] = 'expenses';
|
||||
if ($usedInSubscriptions) $usageTypes[] = 'subscriptions';
|
||||
|
||||
FlashMessage::error('Cannot delete crypto wallet that is used in ' . implode(' and ', $usageTypes) . '. Please reassign or delete them first.');
|
||||
header('Location: /crypto-wallets');
|
||||
exit();
|
||||
}
|
||||
|
||||
$result = $this->cryptoWalletModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Crypto wallet deleted', [
|
||||
'user_id' => $userId,
|
||||
'wallet_id' => $id,
|
||||
'name' => $wallet['name']
|
||||
]);
|
||||
FlashMessage::success('Crypto wallet deleted successfully!');
|
||||
} else {
|
||||
FlashMessage::error('Failed to delete crypto wallet. Please try again.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to delete crypto wallet', [
|
||||
'user_id' => $userId,
|
||||
'wallet_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to delete crypto wallet. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /crypto-wallets');
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get crypto wallets for AJAX requests (for expense/subscription forms)
|
||||
*/
|
||||
public function ajaxList() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Return all crypto wallets for centralized system
|
||||
$currency = $_GET['currency'] ?? null;
|
||||
$network = $_GET['network'] ?? null;
|
||||
|
||||
$wallets = $this->cryptoWalletModel->getAllWithUserInfo();
|
||||
|
||||
// Filter by currency and/or network if provided
|
||||
if ($currency || $network) {
|
||||
$wallets = array_filter($wallets, function($wallet) use ($currency, $network) {
|
||||
$currencyMatch = !$currency || $wallet['currency'] === $currency;
|
||||
$networkMatch = !$network || $wallet['network'] === $network;
|
||||
return $currencyMatch && $networkMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// Add masked address for display
|
||||
foreach ($wallets as &$wallet) {
|
||||
$wallet['address_masked'] = CryptoWallet::getMaskedAddress($wallet['address'] ?? $wallet['address_short']);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'wallets' => $wallets
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet details (AJAX endpoint)
|
||||
*/
|
||||
public function details($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$wallet = $this->cryptoWalletModel->find($id);
|
||||
|
||||
if (!$wallet) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Wallet not found']);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Get usage statistics
|
||||
$stats = $this->cryptoWalletModel->getUsageStats($id);
|
||||
$wallet['stats'] = $stats;
|
||||
$wallet['address_masked'] = CryptoWallet::getMaskedAddress($wallet['address']);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'wallet' => $wallet
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search crypto wallets (AJAX endpoint)
|
||||
*/
|
||||
public function search() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$query = trim($_GET['q'] ?? '');
|
||||
|
||||
if (empty($query)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true, 'wallets' => []]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$wallets = $this->cryptoWalletModel->search($userId, $query);
|
||||
|
||||
// Add masked address for display
|
||||
foreach ($wallets as &$wallet) {
|
||||
$wallet['address_masked'] = CryptoWallet::getMaskedAddress($wallet['address']);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'wallets' => $wallets
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallets by currency (AJAX endpoint)
|
||||
*/
|
||||
public function byCurrency($currency) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Validate currency
|
||||
$validCurrencies = CryptoWallet::getSupportedCurrencies();
|
||||
if (!isset($validCurrencies[$currency])) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid currency']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$wallets = $this->cryptoWalletModel->getByCurrency($userId, $currency);
|
||||
|
||||
// Add masked address for display
|
||||
foreach ($wallets as &$wallet) {
|
||||
$wallet['address_masked'] = CryptoWallet::getMaskedAddress($wallet['address']);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'wallets' => $wallets
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallets by network (AJAX endpoint)
|
||||
*/
|
||||
public function byNetwork($network) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Validate network
|
||||
$validNetworks = array_keys(CryptoWallet::getSupportedNetworks());
|
||||
if (!in_array($network, $validNetworks)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid network']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$wallets = $this->cryptoWalletModel->getByNetwork($userId, $network);
|
||||
|
||||
// Add masked address for display
|
||||
foreach ($wallets as &$wallet) {
|
||||
$wallet['address_masked'] = CryptoWallet::getMaskedAddress($wallet['address']);
|
||||
}
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'wallets' => $wallets
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currencies for selected network (AJAX endpoint)
|
||||
*/
|
||||
public function getCurrenciesForNetwork($network) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$validNetworks = array_keys(CryptoWallet::getSupportedNetworks());
|
||||
if (!in_array($network, $validNetworks)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid network']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$currencies = CryptoWallet::getCurrenciesForNetwork($network);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'currencies' => $currencies
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get networks for selected currency (AJAX endpoint)
|
||||
*/
|
||||
public function getNetworksForCurrency($currency) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$validCurrencies = CryptoWallet::getSupportedCurrencies();
|
||||
if (!isset($validCurrencies[$currency])) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid currency']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$networks = $validCurrencies[$currency]['networks'];
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'networks' => $networks
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate wallet address (AJAX endpoint)
|
||||
*/
|
||||
public function validateAddress() {
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$address = trim($_POST['address'] ?? '');
|
||||
$network = trim($_POST['network'] ?? '');
|
||||
|
||||
$response = [
|
||||
'valid' => false,
|
||||
'message' => ''
|
||||
];
|
||||
|
||||
if (empty($address) || empty($network)) {
|
||||
$response['message'] = 'Address and network are required';
|
||||
} else {
|
||||
$response['valid'] = CryptoWallet::validateAddress($address, $network);
|
||||
if (!$response['valid']) {
|
||||
$response['message'] = 'Invalid address format for the selected network';
|
||||
} else {
|
||||
$response['message'] = 'Address is valid';
|
||||
}
|
||||
}
|
||||
|
||||
$response['success'] = true;
|
||||
echo json_encode($response);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
1286
app/Controllers/DashboardController.php
Normal file
1286
app/Controllers/DashboardController.php
Normal file
File diff suppressed because it is too large
Load Diff
215
app/Controllers/ErrorController.php
Normal file
215
app/Controllers/ErrorController.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
|
||||
class ErrorController extends Controller {
|
||||
public function __construct() {
|
||||
// No longer need logger injection - using static AppLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 404 Not Found errors
|
||||
*/
|
||||
public function notFound() {
|
||||
http_response_code(404);
|
||||
|
||||
AppLogger::warning('404 Not Found', [
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'referer' => $_SERVER['HTTP_REFERER'] ?? 'none',
|
||||
'ip' => $this->getClientIp()
|
||||
]);
|
||||
|
||||
$this->clearOutputBuffer();
|
||||
$this->view('errors/404');
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 500 Internal Server errors
|
||||
*/
|
||||
public function serverError($message = 'An unexpected error occurred.', $exception = null) {
|
||||
http_response_code(500);
|
||||
|
||||
// Log the detailed error message
|
||||
$logData = [
|
||||
'message' => $message,
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
|
||||
'ip' => $this->getClientIp(),
|
||||
'timestamp' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
// Add exception details if provided
|
||||
if ($exception instanceof Throwable) {
|
||||
$logData['exception'] = [
|
||||
'class' => get_class($exception),
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $exception->getTraceAsString()
|
||||
];
|
||||
}
|
||||
|
||||
AppLogger::error('Server Error', $logData);
|
||||
|
||||
$this->clearOutputBuffer();
|
||||
|
||||
// Determine what to show based on environment
|
||||
$isDebug = Config::get('debug', false);
|
||||
$env = Config::get('env', 'production');
|
||||
|
||||
if ($isDebug || $env === 'development') {
|
||||
// Show detailed error in development
|
||||
$this->view('errors/500-debug', [
|
||||
'message' => $message,
|
||||
'exception' => $exception,
|
||||
'env' => $env
|
||||
]);
|
||||
} else {
|
||||
// Show generic error in production
|
||||
$this->view('errors/500', [
|
||||
'message' => 'We are currently experiencing some technical difficulties. Please try again later.'
|
||||
]);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 403 Forbidden errors
|
||||
*/
|
||||
public function forbidden($message = 'Access denied.') {
|
||||
http_response_code(403);
|
||||
|
||||
AppLogger::warning('403 Forbidden', [
|
||||
'message' => $message,
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
|
||||
'session_id' => session_id(),
|
||||
'ip' => $this->getClientIp()
|
||||
]);
|
||||
|
||||
$this->clearOutputBuffer();
|
||||
$this->view('errors/403', ['message' => $message]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle database connection errors
|
||||
*/
|
||||
public function databaseError($message = 'Database connection failed.') {
|
||||
http_response_code(503);
|
||||
|
||||
AppLogger::critical('Database Error', [
|
||||
'message' => $message,
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'ip' => $this->getClientIp()
|
||||
]);
|
||||
|
||||
$this->clearOutputBuffer();
|
||||
|
||||
$isDebug = Config::get('debug', false);
|
||||
$env = Config::get('env', 'production');
|
||||
|
||||
if ($isDebug || $env === 'development') {
|
||||
$this->view('errors/503-debug', ['message' => $message]);
|
||||
} else {
|
||||
$this->view('errors/503', [
|
||||
'message' => 'The service is temporarily unavailable. Please try again later.'
|
||||
]);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle maintenance mode
|
||||
*/
|
||||
public function maintenance() {
|
||||
http_response_code(503);
|
||||
|
||||
AppLogger::info('Maintenance mode accessed', [
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'ip' => $this->getClientIp()
|
||||
]);
|
||||
|
||||
$this->clearOutputBuffer();
|
||||
$this->view('errors/maintenance');
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle general application errors with proper logging
|
||||
*/
|
||||
public function handleException($exception) {
|
||||
// Determine error type and handle accordingly
|
||||
if ($exception instanceof PDOException) {
|
||||
$this->databaseError($exception->getMessage());
|
||||
} else {
|
||||
$this->serverError($exception->getMessage(), $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any existing output buffer
|
||||
*/
|
||||
private function clearOutputBuffer() {
|
||||
while (ob_get_level()) {
|
||||
ob_end_clean();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*/
|
||||
private function getClientIp() {
|
||||
$ipKeys = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP', 'REMOTE_ADDR'];
|
||||
|
||||
foreach ($ipKeys as $key) {
|
||||
if (!empty($_SERVER[$key])) {
|
||||
$ip = $_SERVER[$key];
|
||||
// Handle comma-separated IPs (X-Forwarded-For)
|
||||
if (strpos($ip, ',') !== false) {
|
||||
$ip = trim(explode(',', $ip)[0]);
|
||||
}
|
||||
// Validate IP
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX errors
|
||||
*/
|
||||
public function ajaxError($message = 'An error occurred', $code = 500) {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
|
||||
AppLogger::error('AJAX Error', [
|
||||
'message' => $message,
|
||||
'code' => $code,
|
||||
'uri' => $_SERVER['REQUEST_URI'] ?? 'unknown',
|
||||
'ip' => $this->getClientIp()
|
||||
]);
|
||||
|
||||
$this->clearOutputBuffer();
|
||||
|
||||
$isDebug = Config::get('debug', false);
|
||||
$env = Config::get('env', 'production');
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'error' => true,
|
||||
'message' => ($isDebug || $env === 'development') ? $message : 'An error occurred'
|
||||
];
|
||||
|
||||
echo json_encode($response);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
1052
app/Controllers/ExpenseController.php
Normal file
1052
app/Controllers/ExpenseController.php
Normal file
File diff suppressed because it is too large
Load Diff
487
app/Controllers/ProfileController.php
Normal file
487
app/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,487 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/User.php';
|
||||
require_once __DIR__ . '/../Models/ApiKey.php';
|
||||
require_once __DIR__ . '/../Services/TwoFactorService.php';
|
||||
|
||||
class ProfileController extends Controller {
|
||||
|
||||
private $userModel;
|
||||
private $apiKeyModel;
|
||||
private $twoFactorService;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->userModel = new User($db);
|
||||
$this->apiKeyModel = new ApiKey($db);
|
||||
$this->twoFactorService = new TwoFactorService();
|
||||
}
|
||||
|
||||
private function checkAuthentication() {
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Check if user is logged in
|
||||
if (!isset($_SESSION['user']['id'])) {
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
private function checkSuperAdminAccess() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Check if user is superadmin
|
||||
if (!isset($_SESSION['user']['role']) || $_SESSION['user']['role'] !== 'superadmin') {
|
||||
FlashMessage::error('Access denied. API key management is restricted to super administrators.');
|
||||
header('Location: /');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public function edit() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
FlashMessage::error('User profile not found.');
|
||||
header('Location: /');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/profile/edit', ['user' => $user]);
|
||||
}
|
||||
|
||||
public function update() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Get current user data for password verification
|
||||
$currentUser = $this->userModel->find($userId);
|
||||
if (!$currentUser) {
|
||||
FlashMessage::error('User profile not found.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
AppLogger::info('Profile update attempt', [
|
||||
'user_id' => $userId,
|
||||
'has_new_password' => !empty($_POST['new_password']),
|
||||
'has_current_password' => !empty($_POST['current_password']),
|
||||
'post_data_keys' => array_keys($_POST)
|
||||
]);
|
||||
|
||||
// Basic validation
|
||||
if (empty($_POST['name']) || empty($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
FlashMessage::error('Please fill in all required fields with valid information.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Verify current password (required for any profile changes)
|
||||
if (empty($_POST['current_password'])) {
|
||||
FlashMessage::error('Current password is required to save changes.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!password_verify($_POST['current_password'], $currentUser['password'])) {
|
||||
AppLogger::warning('Profile update failed - incorrect current password', [
|
||||
'user_id' => $userId,
|
||||
'email' => $currentUser['email']
|
||||
]);
|
||||
FlashMessage::error('Current password is incorrect.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
$existingUser = $this->userModel->findByEmail($_POST['email']);
|
||||
if ($existingUser && $existingUser['id'] != $userId) {
|
||||
FlashMessage::error('This email address is already in use by another account.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => trim($_POST['name']),
|
||||
'email' => trim($_POST['email'])
|
||||
];
|
||||
|
||||
// Handle password change if new password is provided
|
||||
if (!empty($_POST['new_password'])) {
|
||||
if (strlen($_POST['new_password']) < 6) {
|
||||
FlashMessage::error('New password must be at least 6 characters long.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
if ($_POST['new_password'] !== $_POST['confirm_password']) {
|
||||
FlashMessage::error('New password confirmation does not match.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if new password is different from current password
|
||||
if (password_verify($_POST['new_password'], $currentUser['password'])) {
|
||||
FlashMessage::error('New password must be different from your current password.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$data['password'] = $_POST['new_password']; // Model handles hashing
|
||||
|
||||
AppLogger::info('Password change requested', [
|
||||
'user_id' => $userId,
|
||||
'email' => $currentUser['email']
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$updateResult = $this->userModel->update($userId, $data);
|
||||
|
||||
AppLogger::info('Profile update database result', [
|
||||
'user_id' => $userId,
|
||||
'update_result' => $updateResult,
|
||||
'data_keys' => array_keys($data),
|
||||
'has_password_change' => isset($data['password'])
|
||||
]);
|
||||
|
||||
// Update session data
|
||||
$_SESSION['user']['name'] = $data['name'];
|
||||
$_SESSION['user']['email'] = $data['email'];
|
||||
|
||||
if (isset($data['password'])) {
|
||||
AppLogger::info('Password updated successfully', [
|
||||
'user_id' => $userId,
|
||||
'email' => $data['email']
|
||||
]);
|
||||
FlashMessage::success('Profile and password updated successfully!');
|
||||
} else {
|
||||
FlashMessage::success('Profile updated successfully!');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Profile update failed', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
FlashMessage::error('Failed to update profile. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function setup2FA() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
FlashMessage::error('User profile not found.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Generate new secret if not exists or if user wants to reset
|
||||
$secret = $this->twoFactorService->generateSecretKey();
|
||||
$qrCodeImage = $this->twoFactorService->getQRCodeImage($user, $secret);
|
||||
|
||||
$this->view('dashboard/profile/2fa-setup', [
|
||||
'user' => $user,
|
||||
'secret' => $secret,
|
||||
'qrCodeImage' => $qrCodeImage
|
||||
]);
|
||||
}
|
||||
|
||||
public function enable2FA() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /profile/2fa/setup');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$secret = trim($_POST['secret'] ?? '');
|
||||
$code = trim($_POST['code'] ?? '');
|
||||
|
||||
if (empty($secret) || empty($code)) {
|
||||
FlashMessage::error('Please provide both secret and verification code.');
|
||||
header('Location: /profile/2fa/setup');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Verify the code
|
||||
if (!$this->twoFactorService->verifyCode($secret, $code)) {
|
||||
FlashMessage::error('Invalid verification code. Please try again.');
|
||||
header('Location: /profile/2fa/setup');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Generate backup codes
|
||||
$backupCodes = $this->twoFactorService->generateBackupCodes();
|
||||
|
||||
// Enable 2FA for user
|
||||
try {
|
||||
$this->userModel->enable2FA($userId, $secret, $backupCodes);
|
||||
|
||||
AppLogger::info('2FA enabled for user', [
|
||||
'user_id' => $userId,
|
||||
'email' => $_SESSION['user']['email']
|
||||
]);
|
||||
|
||||
// Show backup codes to user
|
||||
$this->view('dashboard/profile/2fa-backup-codes', [
|
||||
'backupCodes' => $backupCodes
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to enable 2FA', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to enable 2FA. Please try again.');
|
||||
header('Location: /profile/2fa/setup');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public function disable2FA() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$currentPassword = trim($_POST['current_password'] ?? '');
|
||||
|
||||
if (empty($currentPassword)) {
|
||||
FlashMessage::error('Current password is required to disable 2FA.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
$user = $this->userModel->find($userId);
|
||||
if (!$user || !password_verify($currentPassword, $user['password'])) {
|
||||
FlashMessage::error('Current password is incorrect.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userModel->disable2FA($userId);
|
||||
|
||||
AppLogger::info('2FA disabled for user', [
|
||||
'user_id' => $userId,
|
||||
'email' => $user['email']
|
||||
]);
|
||||
|
||||
FlashMessage::success('Two-factor authentication has been disabled.');
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to disable 2FA', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to disable 2FA. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function regenerateBackupCodes() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user || !$user['two_factor_enabled']) {
|
||||
FlashMessage::error('2FA is not enabled for your account.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Generate new backup codes
|
||||
$backupCodes = $this->twoFactorService->generateBackupCodes();
|
||||
|
||||
try {
|
||||
$this->userModel->updateBackupCodes($userId, json_encode($backupCodes));
|
||||
|
||||
AppLogger::info('Backup codes regenerated for user', [
|
||||
'user_id' => $userId,
|
||||
'email' => $user['email']
|
||||
]);
|
||||
|
||||
// Show new backup codes to user
|
||||
$this->view('dashboard/profile/2fa-backup-codes', [
|
||||
'backupCodes' => $backupCodes,
|
||||
'regenerated' => true
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to regenerate backup codes', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to regenerate backup codes. Please try again.');
|
||||
header('Location: /profile/edit');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// API Key Management Methods
|
||||
|
||||
public function apiKeys() {
|
||||
$this->checkSuperAdminAccess();
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$apiKeys = $this->apiKeyModel->getUserApiKeys($userId);
|
||||
|
||||
$this->view('dashboard/profile/api-keys', [
|
||||
'apiKeys' => $apiKeys
|
||||
]);
|
||||
}
|
||||
|
||||
public function createApiKey() {
|
||||
$this->checkSuperAdminAccess();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /profile/api-keys');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$permissions = $_POST['permissions'] ?? [];
|
||||
$rateLimitPerMinute = (int)($_POST['rate_limit_per_minute'] ?? 60);
|
||||
$expiresAt = trim($_POST['expires_at'] ?? '');
|
||||
|
||||
// Validate input
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('API key name is required.');
|
||||
header('Location: /profile/api-keys');
|
||||
exit();
|
||||
}
|
||||
|
||||
if ($rateLimitPerMinute < 1 || $rateLimitPerMinute > 1000) {
|
||||
FlashMessage::error('Rate limit must be between 1 and 1000 requests per minute.');
|
||||
header('Location: /profile/api-keys');
|
||||
exit();
|
||||
}
|
||||
|
||||
$expiresAtFormatted = null;
|
||||
if (!empty($expiresAt)) {
|
||||
$expiresAtTimestamp = strtotime($expiresAt);
|
||||
if ($expiresAtTimestamp === false || $expiresAtTimestamp <= time()) {
|
||||
FlashMessage::error('Expiration date must be a valid future date.');
|
||||
header('Location: /profile/api-keys');
|
||||
exit();
|
||||
}
|
||||
$expiresAtFormatted = date('Y-m-d H:i:s', $expiresAtTimestamp);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->apiKeyModel->generateApiKey(
|
||||
$name,
|
||||
$userId,
|
||||
!empty($permissions) ? $permissions : null,
|
||||
$rateLimitPerMinute,
|
||||
$expiresAtFormatted
|
||||
);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('API key created', [
|
||||
'user_id' => $userId,
|
||||
'api_key_id' => $result['id'],
|
||||
'name' => $name
|
||||
]);
|
||||
|
||||
// Show the API key only once
|
||||
$this->view('dashboard/profile/api-key-created', [
|
||||
'apiKey' => $result
|
||||
]);
|
||||
return;
|
||||
} else {
|
||||
FlashMessage::error('Failed to create API key. Please try again.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to create API key', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to create API key. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /profile/api-keys');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function deleteApiKey($keyId) {
|
||||
$this->checkSuperAdminAccess();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /profile/api-keys');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
try {
|
||||
$result = $this->apiKeyModel->deleteKey($keyId, $userId);
|
||||
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
AppLogger::info('API key deleted permanently', [
|
||||
'user_id' => $userId,
|
||||
'api_key_id' => $keyId
|
||||
]);
|
||||
FlashMessage::success('API key has been permanently deleted.');
|
||||
} else {
|
||||
FlashMessage::error('API key not found or could not be deleted.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to delete API key', [
|
||||
'user_id' => $userId,
|
||||
'api_key_id' => $keyId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to delete API key. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /profile/api-keys');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
226
app/Controllers/ReportController.php
Normal file
226
app/Controllers/ReportController.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/Subscription.php';
|
||||
require_once __DIR__ . '/../Models/Transaction.php';
|
||||
require_once __DIR__ . '/../Models/Expense.php';
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
class ReportController extends Controller {
|
||||
|
||||
private $subscriptionModel;
|
||||
private $transactionModel;
|
||||
private $expenseModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->subscriptionModel = new Subscription($db);
|
||||
$this->transactionModel = new Transaction($db);
|
||||
$this->expenseModel = new Expense($db);
|
||||
}
|
||||
|
||||
public function index() {
|
||||
// Parse query parameters directly from REQUEST_URI since $_GET is corrupted
|
||||
$query_params = [];
|
||||
if (strpos($_SERVER['REQUEST_URI'], '?') !== false) {
|
||||
$query_string = substr($_SERVER['REQUEST_URI'], strpos($_SERVER['REQUEST_URI'], '?') + 1);
|
||||
parse_str($query_string, $query_params);
|
||||
}
|
||||
|
||||
// Date filter - get from parsed query parameters
|
||||
$from_date = $query_params['from'] ?? null;
|
||||
$to_date = $query_params['to'] ?? null;
|
||||
|
||||
// Store original values for form display
|
||||
$original_from = $from_date;
|
||||
$original_to = $to_date;
|
||||
|
||||
// Only apply date filtering if both dates are provided
|
||||
$applyDateFilter = $from_date && $to_date;
|
||||
|
||||
if ($applyDateFilter) {
|
||||
// Validate dates
|
||||
$from_date = $this->validateDate($from_date) ? $from_date : null;
|
||||
$to_date = $this->validateDate($to_date) ? $to_date : null;
|
||||
$applyDateFilter = $from_date && $to_date;
|
||||
}
|
||||
|
||||
// Get unified transaction data based on whether filtering is applied
|
||||
if ($applyDateFilter) {
|
||||
$transactions = $this->transactionModel->getAllWithCompleteInfoAndDateFilter($from_date, $to_date);
|
||||
$transactionStats = $this->transactionModel->getTransactionStats($from_date, $to_date);
|
||||
} else {
|
||||
// Show all data without date filtering
|
||||
$transactions = $this->transactionModel->getAllWithCompleteInfo();
|
||||
$transactionStats = $this->transactionModel->getTransactionStats();
|
||||
}
|
||||
|
||||
// Process transactions to format data for display
|
||||
$processedTransactions = $this->processTransactionsForDisplay($transactions);
|
||||
|
||||
$this->view('dashboard/reports/index', [
|
||||
'transactions' => $processedTransactions,
|
||||
'transaction_stats' => $transactionStats,
|
||||
'filter_dates' => ['from' => $original_from ?? '', 'to' => $original_to ?? ''],
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#transactions-table'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process transactions to format data for unified display
|
||||
*/
|
||||
private function processTransactionsForDisplay($transactions) {
|
||||
$processed = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$processed[] = [
|
||||
'id' => $transaction['id'],
|
||||
'user_name' => $transaction['user_name'],
|
||||
'user_email' => $transaction['user_email'],
|
||||
'transaction_type' => $transaction['transaction_type'],
|
||||
'item_name' => $transaction['transaction_type'] === 'subscription'
|
||||
? $transaction['subscription_name']
|
||||
: $transaction['expense_title'],
|
||||
'vendor' => $transaction['transaction_type'] === 'expense'
|
||||
? $transaction['expense_vendor']
|
||||
: null,
|
||||
'category_name' => $transaction['category_name'],
|
||||
'amount' => $transaction['amount'],
|
||||
'currency' => $transaction['currency'],
|
||||
'status' => $transaction['status'],
|
||||
'transaction_date' => $transaction['transaction_date'],
|
||||
'payment_method_type' => $transaction['payment_method_type'],
|
||||
'payment_method_name' => $this->getPaymentMethodName($transaction),
|
||||
'billing_cycle' => $transaction['billing_cycle'], // Only for subscriptions
|
||||
'reference_number' => $transaction['reference_number'],
|
||||
'description' => $transaction['description'],
|
||||
'notes' => $transaction['notes']
|
||||
];
|
||||
}
|
||||
|
||||
return $processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment method name based on type
|
||||
*/
|
||||
private function getPaymentMethodName($transaction) {
|
||||
switch ($transaction['payment_method_type']) {
|
||||
case 'credit_card':
|
||||
return $transaction['credit_card_name'];
|
||||
case 'bank_account':
|
||||
return $transaction['bank_account_name'];
|
||||
case 'crypto_wallet':
|
||||
return $transaction['crypto_wallet_name'];
|
||||
default:
|
||||
return ucfirst($transaction['payment_method_type'] ?? 'Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate date format
|
||||
*/
|
||||
private function validateDate($date) {
|
||||
$d = DateTime::createFromFormat('Y-m-d', $date);
|
||||
return $d && $d->format('Y-m-d') === $date;
|
||||
}
|
||||
|
||||
public function export() {
|
||||
try {
|
||||
// Parse query parameters directly from REQUEST_URI
|
||||
$query_params = [];
|
||||
if (strpos($_SERVER['REQUEST_URI'], '?') !== false) {
|
||||
$query_string = substr($_SERVER['REQUEST_URI'], strpos($_SERVER['REQUEST_URI'], '?') + 1);
|
||||
parse_str($query_string, $query_params);
|
||||
}
|
||||
|
||||
// Apply same date filtering as index page
|
||||
$from_date = $query_params['from'] ?? null;
|
||||
$to_date = $query_params['to'] ?? null;
|
||||
|
||||
// Only apply date filtering if both dates are provided
|
||||
$applyDateFilter = $from_date && $to_date;
|
||||
|
||||
if ($applyDateFilter) {
|
||||
// Validate dates
|
||||
$from_date = $this->validateDate($from_date) ? $from_date : null;
|
||||
$to_date = $this->validateDate($to_date) ? $to_date : null;
|
||||
$applyDateFilter = $from_date && $to_date;
|
||||
}
|
||||
|
||||
// Get unified transactions based on filtering
|
||||
if ($applyDateFilter) {
|
||||
$transactions = $this->transactionModel->getAllWithCompleteInfoAndDateFilter($from_date, $to_date);
|
||||
$filename = "unified_transactions_report_{$from_date}_to_{$to_date}.xlsx";
|
||||
} else {
|
||||
$transactions = $this->transactionModel->getAllWithCompleteInfo();
|
||||
$filename = "unified_transactions_report_" . date('Y-m-d') . ".xlsx";
|
||||
}
|
||||
|
||||
if (empty($transactions)) {
|
||||
FlashMessage::warning('No transactions found to export for the selected date range.');
|
||||
header('Location: /reports');
|
||||
exit();
|
||||
}
|
||||
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// Set headers for unified transaction report
|
||||
$sheet->setCellValue('A1', 'Transaction ID');
|
||||
$sheet->setCellValue('B1', 'Type');
|
||||
$sheet->setCellValue('C1', 'User Name');
|
||||
$sheet->setCellValue('D1', 'User Email');
|
||||
$sheet->setCellValue('E1', 'Item/Service Name');
|
||||
$sheet->setCellValue('F1', 'Vendor');
|
||||
$sheet->setCellValue('G1', 'Category');
|
||||
$sheet->setCellValue('H1', 'Amount');
|
||||
$sheet->setCellValue('I1', 'Currency');
|
||||
$sheet->setCellValue('J1', 'Status');
|
||||
$sheet->setCellValue('K1', 'Payment Method');
|
||||
$sheet->setCellValue('L1', 'Payment Method Name');
|
||||
$sheet->setCellValue('M1', 'Transaction Date');
|
||||
$sheet->setCellValue('N1', 'Billing Cycle');
|
||||
$sheet->setCellValue('O1', 'Reference Number');
|
||||
$sheet->setCellValue('P1', 'Description');
|
||||
|
||||
// Process and set data
|
||||
$processedTransactions = $this->processTransactionsForDisplay($transactions);
|
||||
$row = 2;
|
||||
foreach ($processedTransactions as $transaction) {
|
||||
$sheet->setCellValue('A' . $row, $transaction['id']);
|
||||
$sheet->setCellValue('B' . $row, ucfirst($transaction['transaction_type']));
|
||||
$sheet->setCellValue('C' . $row, $transaction['user_name']);
|
||||
$sheet->setCellValue('D' . $row, $transaction['user_email']);
|
||||
$sheet->setCellValue('E' . $row, $transaction['item_name']);
|
||||
$sheet->setCellValue('F' . $row, $transaction['vendor'] ?? '');
|
||||
$sheet->setCellValue('G' . $row, $transaction['category_name'] ?? '');
|
||||
$sheet->setCellValue('H' . $row, $transaction['amount']);
|
||||
$sheet->setCellValue('I' . $row, $transaction['currency']);
|
||||
$sheet->setCellValue('J' . $row, ucfirst($transaction['status']));
|
||||
$sheet->setCellValue('K' . $row, ucfirst($transaction['payment_method_type'] ?? ''));
|
||||
$sheet->setCellValue('L' . $row, $transaction['payment_method_name']);
|
||||
$sheet->setCellValue('M' . $row, $transaction['transaction_date']);
|
||||
$sheet->setCellValue('N' . $row, ucfirst($transaction['billing_cycle'] ?? ''));
|
||||
$sheet->setCellValue('O' . $row, $transaction['reference_number']);
|
||||
$sheet->setCellValue('P' . $row, $transaction['description']);
|
||||
$row++;
|
||||
}
|
||||
|
||||
$writer = new Xlsx($spreadsheet);
|
||||
|
||||
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
header('Content-Disposition: attachment;filename="' . $filename . '"');
|
||||
header('Cache-Control: max-age=0');
|
||||
|
||||
$writer->save('php://output');
|
||||
exit();
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to export report. Please try again.');
|
||||
header('Location: /reports');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
275
app/Controllers/SubscriptionController.php
Normal file
275
app/Controllers/SubscriptionController.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/Subscription.php';
|
||||
require_once __DIR__ . '/../Models/CreditCard.php';
|
||||
require_once __DIR__ . '/../Services/TransactionService.php';
|
||||
|
||||
class SubscriptionController extends Controller {
|
||||
|
||||
private $subscriptionModel;
|
||||
private $creditCardModel;
|
||||
private $transactionService;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->subscriptionModel = new Subscription($db);
|
||||
$this->creditCardModel = new CreditCard($db);
|
||||
$this->transactionService = new TransactionService($db);
|
||||
}
|
||||
|
||||
public function index() {
|
||||
// For now, get all records since DataTables handles pagination client-side
|
||||
// In the future, this could be optimized with server-side pagination for very large datasets
|
||||
$subscriptions = $this->subscriptionModel->getAllWithUserInfo();
|
||||
$this->view('dashboard/subscriptions/index', [
|
||||
'subscriptions' => $subscriptions,
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#subscriptions-table'
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$creditCards = $this->creditCardModel->getAll('*', ['ORDER' => ['created_at' => 'DESC']]);
|
||||
|
||||
if (empty($creditCards)) {
|
||||
FlashMessage::warning('You need to add at least one credit card before creating a subscription.');
|
||||
header('Location: /credit-cards/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/subscriptions/create', ['credit_cards' => $creditCards]);
|
||||
}
|
||||
|
||||
public function store() {
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /subscriptions/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (empty($_POST['name']) || empty($_POST['amount']) || empty($_POST['currency']) ||
|
||||
empty($_POST['billing_cycle']) || empty($_POST['credit_card_id'])) {
|
||||
FlashMessage::error('Please fill in all required fields.');
|
||||
header('Location: /subscriptions/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate amount is numeric and positive
|
||||
if (!is_numeric($_POST['amount']) || $_POST['amount'] <= 0) {
|
||||
FlashMessage::error('Please enter a valid amount greater than 0.');
|
||||
header('Location: /subscriptions/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate next payment date only for recurring subscriptions
|
||||
if ($_POST['billing_cycle'] !== 'one-time') {
|
||||
if (empty($_POST['next_payment_date'])) {
|
||||
FlashMessage::error('Next payment date is required for recurring subscriptions.');
|
||||
header('Location: /subscriptions/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!strtotime($_POST['next_payment_date'])) {
|
||||
FlashMessage::error('Please enter a valid next payment date.');
|
||||
header('Location: /subscriptions/create');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Verify the credit card exists
|
||||
$creditCard = $this->creditCardModel->find($_POST['credit_card_id']);
|
||||
if (!$creditCard) {
|
||||
FlashMessage::error('Invalid credit card selected.');
|
||||
header('Location: /subscriptions/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Prepare data for subscription creation
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'credit_card_id' => $_POST['credit_card_id'],
|
||||
'name' => $_POST['name'],
|
||||
'description' => $_POST['description'] ?? '',
|
||||
'amount' => $_POST['amount'],
|
||||
'currency' => $_POST['currency'],
|
||||
'billing_cycle' => $_POST['billing_cycle'],
|
||||
'status' => $_POST['status'] ?? 'active',
|
||||
];
|
||||
|
||||
// Set next payment date only for recurring subscriptions
|
||||
if ($_POST['billing_cycle'] === 'one-time') {
|
||||
$data['next_payment_date'] = null;
|
||||
} else {
|
||||
$data['next_payment_date'] = $_POST['next_payment_date'];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->subscriptionModel->create($data);
|
||||
if ($result) {
|
||||
// If it's a one-time payment, process the transaction immediately
|
||||
if ($_POST['billing_cycle'] === 'one-time') {
|
||||
$transactionResult = $this->transactionService->processOneTimePayment($result);
|
||||
if ($transactionResult['success']) {
|
||||
FlashMessage::success('One-time payment processed successfully!');
|
||||
} else {
|
||||
FlashMessage::warning('Subscription created but payment failed: ' . $transactionResult['message']);
|
||||
}
|
||||
} else {
|
||||
FlashMessage::success('Subscription created successfully!');
|
||||
}
|
||||
} else {
|
||||
FlashMessage::error('Failed to create subscription. Please try again.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to create subscription: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
header('Location: /subscriptions');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function edit($id) {
|
||||
$subscription = $this->subscriptionModel->find($id);
|
||||
|
||||
if (!$subscription) {
|
||||
FlashMessage::error('Subscription not found.');
|
||||
header('Location: /subscriptions');
|
||||
exit();
|
||||
}
|
||||
|
||||
$creditCards = $this->creditCardModel->getAll('*', ['ORDER' => ['created_at' => 'DESC']]);
|
||||
|
||||
if (empty($creditCards)) {
|
||||
FlashMessage::warning('You need to have at least one credit card to edit subscriptions.');
|
||||
header('Location: /credit-cards/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/subscriptions/edit', [
|
||||
'subscription' => $subscription,
|
||||
'credit_cards' => $creditCards
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /subscriptions/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if subscription exists
|
||||
$subscription = $this->subscriptionModel->find($id);
|
||||
if (!$subscription) {
|
||||
FlashMessage::error('Subscription not found.');
|
||||
header('Location: /subscriptions');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (empty($_POST['name']) || empty($_POST['amount']) || empty($_POST['currency']) ||
|
||||
empty($_POST['billing_cycle']) || empty($_POST['credit_card_id'])) {
|
||||
FlashMessage::error('Please fill in all required fields.');
|
||||
header('Location: /subscriptions/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate amount is numeric and positive
|
||||
if (!is_numeric($_POST['amount']) || $_POST['amount'] <= 0) {
|
||||
FlashMessage::error('Please enter a valid amount greater than 0.');
|
||||
header('Location: /subscriptions/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate next payment date only for recurring subscriptions
|
||||
if ($_POST['billing_cycle'] !== 'one-time') {
|
||||
if (empty($_POST['next_payment_date'])) {
|
||||
FlashMessage::error('Next payment date is required for recurring subscriptions.');
|
||||
header('Location: /subscriptions/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!strtotime($_POST['next_payment_date'])) {
|
||||
FlashMessage::error('Please enter a valid next payment date.');
|
||||
header('Location: /subscriptions/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the credit card exists
|
||||
$creditCard = $this->creditCardModel->find($_POST['credit_card_id']);
|
||||
if (!$creditCard) {
|
||||
FlashMessage::error('Invalid credit card selected.');
|
||||
header('Location: /subscriptions/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Prepare data for subscription update
|
||||
$data = [
|
||||
'credit_card_id' => $_POST['credit_card_id'],
|
||||
'name' => $_POST['name'],
|
||||
'description' => $_POST['description'] ?? '',
|
||||
'amount' => $_POST['amount'],
|
||||
'currency' => $_POST['currency'],
|
||||
'billing_cycle' => $_POST['billing_cycle'],
|
||||
'status' => $_POST['status'] ?? 'active',
|
||||
];
|
||||
|
||||
// Set next payment date only for recurring subscriptions
|
||||
if ($_POST['billing_cycle'] === 'one-time') {
|
||||
$data['next_payment_date'] = null;
|
||||
} else {
|
||||
$data['next_payment_date'] = $_POST['next_payment_date'];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->subscriptionModel->update($id, $data);
|
||||
if ($result !== false) {
|
||||
FlashMessage::success('Subscription updated successfully!');
|
||||
} else {
|
||||
FlashMessage::error('Failed to update subscription. No changes were made.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to update subscription: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
header('Location: /subscriptions');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /subscriptions');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if subscription exists
|
||||
$subscription = $this->subscriptionModel->find($id);
|
||||
if (!$subscription) {
|
||||
FlashMessage::error('Subscription not found.');
|
||||
header('Location: /subscriptions');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->subscriptionModel->delete($id);
|
||||
if ($result !== false) {
|
||||
FlashMessage::success('Subscription deleted successfully!');
|
||||
} else {
|
||||
FlashMessage::error('Failed to delete subscription. No changes were made.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to delete subscription: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
header('Location: /subscriptions');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
430
app/Controllers/TagController.php
Normal file
430
app/Controllers/TagController.php
Normal file
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/Tag.php';
|
||||
|
||||
class TagController extends Controller {
|
||||
|
||||
private $tagModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->tagModel = new Tag($db);
|
||||
}
|
||||
|
||||
private function checkAuthentication() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['user']['id'])) {
|
||||
header('Location: /login');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public function index() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Show all tags in centralized system - no user filtering
|
||||
$tags = $this->tagModel->getAllTagsWithExpenseCountAndUser();
|
||||
|
||||
$this->view('dashboard/tags/index', [
|
||||
'tags' => $tags,
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#tags-table'
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$this->view('dashboard/tags/create', [
|
||||
'defaultTags' => Tag::getDefaultTags()
|
||||
]);
|
||||
}
|
||||
|
||||
public function store() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /tags/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
// Validate input
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$color = trim($_POST['color'] ?? '#10B981');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Tag name is required.');
|
||||
header('Location: /tags/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if tag name already exists for user
|
||||
if ($this->tagModel->nameExistsForUser($name, $userId)) {
|
||||
FlashMessage::error('A tag with this name already exists.');
|
||||
header('Location: /tags/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate color format
|
||||
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
|
||||
$color = '#10B981'; // Default color
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'color' => $color,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$tagId = $this->tagModel->create($data);
|
||||
|
||||
if ($tagId) {
|
||||
AppLogger::info('Tag created', [
|
||||
'user_id' => $userId,
|
||||
'tag_id' => $tagId,
|
||||
'name' => $name
|
||||
]);
|
||||
FlashMessage::success('Tag created successfully!');
|
||||
header('Location: /tags');
|
||||
} else {
|
||||
FlashMessage::error('Failed to create tag. Please try again.');
|
||||
header('Location: /tags/create');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to create tag', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to create tag. Please try again.');
|
||||
header('Location: /tags/create');
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
public function edit($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$tag = $this->tagModel->find($id);
|
||||
|
||||
if (!$tag) {
|
||||
FlashMessage::error('Tag not found.');
|
||||
header('Location: /tags');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/tags/edit', [
|
||||
'tag' => $tag
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /tags/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$tag = $this->tagModel->find($id);
|
||||
|
||||
if (!$tag) {
|
||||
FlashMessage::error('Tag not found.');
|
||||
header('Location: /tags');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate input
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$color = trim($_POST['color'] ?? '#10B981');
|
||||
|
||||
if (empty($name)) {
|
||||
FlashMessage::error('Tag name is required.');
|
||||
header('Location: /tags/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if tag name already exists for user (excluding current tag)
|
||||
if ($this->tagModel->nameExistsForUser($name, $userId, $id)) {
|
||||
FlashMessage::error('A tag with this name already exists.');
|
||||
header('Location: /tags/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Validate color format
|
||||
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
|
||||
$color = '#10B981'; // Default color
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'color' => $color,
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$result = $this->tagModel->update($id, $data);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Tag updated', [
|
||||
'user_id' => $userId,
|
||||
'tag_id' => $id,
|
||||
'name' => $name
|
||||
]);
|
||||
FlashMessage::success('Tag updated successfully!');
|
||||
} else {
|
||||
FlashMessage::error('No changes were made.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to update tag', [
|
||||
'user_id' => $userId,
|
||||
'tag_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to update tag. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /tags');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /tags');
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$tag = $this->tagModel->find($id);
|
||||
|
||||
if (!$tag) {
|
||||
FlashMessage::error('Tag not found.');
|
||||
header('Location: /tags');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->tagModel->delete($id);
|
||||
|
||||
if ($result) {
|
||||
AppLogger::info('Tag deleted', [
|
||||
'user_id' => $userId,
|
||||
'tag_id' => $id,
|
||||
'name' => $tag['name']
|
||||
]);
|
||||
FlashMessage::success('Tag deleted successfully! All associated expense tags have been removed.');
|
||||
} else {
|
||||
FlashMessage::error('Failed to delete tag. Please try again.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to delete tag', [
|
||||
'user_id' => $userId,
|
||||
'tag_id' => $id,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
FlashMessage::error('Failed to delete tag. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /tags');
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default tags for user (AJAX endpoint)
|
||||
*/
|
||||
public function createDefaults() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid security token']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
|
||||
try {
|
||||
$created = $this->tagModel->createDefaultTags($userId);
|
||||
|
||||
if ($created > 0) {
|
||||
AppLogger::info('Default tags created', [
|
||||
'user_id' => $userId,
|
||||
'count' => $created
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => "Created default 'General' tag successfully!",
|
||||
'count' => $created
|
||||
]);
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'No tags were created']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Failed to create default tags', [
|
||||
'user_id' => $userId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Failed to create default tags']);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags for AJAX requests (for expense forms)
|
||||
*/
|
||||
public function ajaxList() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
// Return all tags for centralized system
|
||||
$tags = $this->tagModel->getAllWithUserInfo();
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'tags' => $tags
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search tags (AJAX endpoint)
|
||||
*/
|
||||
public function search() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$query = trim($_GET['q'] ?? '');
|
||||
|
||||
if (empty($query)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true, 'tags' => []]);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Search all tags for centralized system
|
||||
$tags = $this->tagModel->searchByName(null, $query, 10);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'tags' => $tags
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick create tag (AJAX endpoint for expense forms)
|
||||
*/
|
||||
public function quickCreate() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
if (!$this->validateCsrfToken()) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Invalid security token']);
|
||||
exit();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['id'];
|
||||
$name = trim($_POST['name'] ?? '');
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
$color = trim($_POST['color'] ?? '#10B981');
|
||||
|
||||
if (empty($name)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Tag name is required']);
|
||||
exit();
|
||||
}
|
||||
|
||||
// Check if tag already exists
|
||||
if ($this->tagModel->nameExistsForUser($name, $userId)) {
|
||||
// If exists, return the existing tag
|
||||
$existingTags = $this->tagModel->getByUserId($userId);
|
||||
$existingTag = array_filter($existingTags, function($tag) use ($name) {
|
||||
return strtolower($tag['name']) === strtolower($name);
|
||||
});
|
||||
$existingTag = reset($existingTag);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Tag already exists',
|
||||
'tag' => $existingTag
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'color' => $color,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
|
||||
try {
|
||||
$tagId = $this->tagModel->create($data);
|
||||
|
||||
if ($tagId) {
|
||||
$tag = $this->tagModel->find($tagId);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Tag created successfully!',
|
||||
'tag' => $tag
|
||||
]);
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Failed to create tag']);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'message' => 'Failed to create tag']);
|
||||
}
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular tags (AJAX endpoint)
|
||||
*/
|
||||
public function popular() {
|
||||
$this->checkAuthentication();
|
||||
|
||||
$limit = min(20, max(5, (int)($_GET['limit'] ?? 10)));
|
||||
|
||||
// Get popular tags from all users for centralized system
|
||||
$tags = $this->tagModel->getPopularTags(null, $limit);
|
||||
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'tags' => $tags
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
154
app/Controllers/UserController.php
Normal file
154
app/Controllers/UserController.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Controller.php';
|
||||
require_once __DIR__ . '/../Models/User.php';
|
||||
|
||||
class UserController extends Controller {
|
||||
|
||||
private $userModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->userModel = new User($db);
|
||||
// Role-based access control moved to individual methods
|
||||
// since session may not be available during constructor
|
||||
}
|
||||
|
||||
private function checkSuperAdminAccess() {
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Role-based access control
|
||||
if (!isset($_SESSION['user']['role']) || $_SESSION['user']['role'] !== 'superadmin') {
|
||||
header('Location: /'); // Redirect non-superadmins to dashboard
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
public function index() {
|
||||
$this->checkSuperAdminAccess();
|
||||
// For now, get all records since DataTables handles pagination client-side
|
||||
// In the future, this could be optimized with server-side pagination for very large datasets
|
||||
$users = $this->userModel->getAll(['id', 'name', 'email', 'created_at', 'role']);
|
||||
$this->view('dashboard/users/index', [
|
||||
'users' => $users,
|
||||
'load_datatable' => true,
|
||||
'datatable_target' => '#users-table'
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() {
|
||||
$this->checkSuperAdminAccess();
|
||||
$this->view('dashboard/users/create');
|
||||
}
|
||||
|
||||
public function store() {
|
||||
$this->checkSuperAdminAccess();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /users/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (empty($_POST['name']) || empty($_POST['email']) || empty($_POST['password']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
FlashMessage::error('Please fill in all required fields with valid information.');
|
||||
header('Location: /users/create');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userModel->createUser($_POST['name'], $_POST['email'], $_POST['password'], $_POST['role']);
|
||||
FlashMessage::success('Admin user created successfully!');
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to create admin user. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /users');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function edit($id) {
|
||||
$this->checkSuperAdminAccess();
|
||||
$user = $this->userModel->find($id);
|
||||
|
||||
if (!$user) {
|
||||
FlashMessage::error('Admin user not found.');
|
||||
header('Location: /users');
|
||||
exit();
|
||||
}
|
||||
|
||||
$this->view('dashboard/users/edit', ['user' => $user]);
|
||||
}
|
||||
|
||||
public function update($id) {
|
||||
$this->checkSuperAdminAccess();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /users/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Basic validation
|
||||
if (empty($_POST['name']) || empty($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL)) {
|
||||
FlashMessage::error('Please fill in all required fields with valid information.');
|
||||
header('Location: /users/' . $id . '/edit');
|
||||
exit();
|
||||
}
|
||||
|
||||
$data = [
|
||||
'name' => $_POST['name'],
|
||||
'email' => $_POST['email'],
|
||||
'password' => $_POST['password'], // Model handles empty check
|
||||
'role' => $_POST['role']
|
||||
];
|
||||
|
||||
try {
|
||||
$this->userModel->update($id, $data);
|
||||
FlashMessage::success('Admin user updated successfully!');
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to update admin user. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /users');
|
||||
exit();
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
$this->checkSuperAdminAccess();
|
||||
|
||||
// Validate CSRF token
|
||||
if (!$this->validateCsrfToken()) {
|
||||
FlashMessage::error('Invalid security token. Please try again.');
|
||||
header('Location: /users');
|
||||
exit();
|
||||
}
|
||||
|
||||
// Prevent user from deleting themselves
|
||||
if ($id == $_SESSION['user']['id']) {
|
||||
FlashMessage::warning('You cannot delete your own account.');
|
||||
header('Location: /users');
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->userModel->find($id);
|
||||
if (!$user) {
|
||||
FlashMessage::error('Admin user not found.');
|
||||
} else {
|
||||
$this->userModel->delete($id);
|
||||
FlashMessage::success('Admin user deleted successfully!');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
FlashMessage::error('Failed to delete admin user. Please try again.');
|
||||
}
|
||||
|
||||
header('Location: /users');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
398
app/Models/ApiKey.php
Normal file
398
app/Models/ApiKey.php
Normal file
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class ApiKey extends Model {
|
||||
|
||||
protected $table = 'api_keys';
|
||||
|
||||
/**
|
||||
* Generate a new API key
|
||||
*/
|
||||
public function generateApiKey($name, $userId, $permissions = null, $rateLimitPerMinute = 60, $expiresAt = null) {
|
||||
// Generate a secure random API key
|
||||
$rawKey = 'ak_' . bin2hex(random_bytes(32)); // 64 character key with prefix
|
||||
$keyPrefix = substr($rawKey, 0, 8); // First 8 characters for identification
|
||||
$hashedKey = hash('sha256', $rawKey);
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'api_key' => $hashedKey,
|
||||
'api_key_prefix' => $keyPrefix,
|
||||
'user_id' => $userId,
|
||||
'permissions' => $permissions ? json_encode($permissions) : null,
|
||||
'rate_limit_per_minute' => $rateLimitPerMinute,
|
||||
'expires_at' => $expiresAt,
|
||||
'is_active' => true,
|
||||
'failed_attempts' => 0,
|
||||
'blocked_until' => null
|
||||
];
|
||||
|
||||
$result = $this->db->insert($this->table, $data);
|
||||
|
||||
if ($result->rowCount() > 0) {
|
||||
$keyId = $this->db->id();
|
||||
return [
|
||||
'id' => $keyId,
|
||||
'raw_key' => $rawKey, // Return raw key only once
|
||||
'prefix' => $keyPrefix,
|
||||
'name' => $name
|
||||
];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API key and return key data if valid
|
||||
*/
|
||||
public function validateApiKey($rawKey) {
|
||||
if (empty($rawKey) || !str_starts_with($rawKey, 'ak_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hashedKey = hash('sha256', $rawKey);
|
||||
$keyPrefix = substr($rawKey, 0, 8);
|
||||
|
||||
$apiKey = $this->db->get($this->table, '*', [
|
||||
'api_key' => $hashedKey,
|
||||
'api_key_prefix' => $keyPrefix,
|
||||
'is_active' => true
|
||||
]);
|
||||
|
||||
if (!$apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if key is expired
|
||||
if ($apiKey['expires_at'] && strtotime($apiKey['expires_at']) < time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if key is currently blocked
|
||||
if ($apiKey['blocked_until'] && strtotime($apiKey['blocked_until']) > time()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record successful API key usage
|
||||
*/
|
||||
public function recordUsage($keyId) {
|
||||
return $this->db->update($this->table, [
|
||||
'last_used_at' => date('Y-m-d H:i:s'),
|
||||
'failed_attempts' => 0 // Reset failed attempts on successful use
|
||||
], [
|
||||
'id' => $keyId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record failed API key attempt
|
||||
*/
|
||||
public function recordFailedAttempt($keyId) {
|
||||
$apiKey = $this->find($keyId);
|
||||
if (!$apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$failedAttempts = $apiKey['failed_attempts'] + 1;
|
||||
$updateData = ['failed_attempts' => $failedAttempts];
|
||||
|
||||
// Block key if too many failed attempts (similar to login attempts)
|
||||
$maxAttempts = Config::get('api.max_failed_attempts', 5);
|
||||
$blockDuration = Config::get('api.block_duration', 300); // 5 minutes
|
||||
|
||||
if ($failedAttempts >= $maxAttempts) {
|
||||
$updateData['blocked_until'] = date('Y-m-d H:i:s', time() + $blockDuration);
|
||||
}
|
||||
|
||||
return $this->db->update($this->table, $updateData, [
|
||||
'id' => $keyId
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys for a user
|
||||
*/
|
||||
public function getUserApiKeys($userId) {
|
||||
return $this->db->select($this->table, [
|
||||
'id',
|
||||
'name',
|
||||
'api_key_prefix',
|
||||
'permissions',
|
||||
'rate_limit_per_minute',
|
||||
'last_used_at',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'failed_attempts',
|
||||
'blocked_until',
|
||||
'created_at'
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate an API key
|
||||
*/
|
||||
public function deactivateKey($keyId, $userId) {
|
||||
return $this->db->update($this->table, [
|
||||
'is_active' => false
|
||||
], [
|
||||
'id' => $keyId,
|
||||
'user_id' => $userId // Ensure user can only deactivate their own keys
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete an API key
|
||||
*/
|
||||
public function deleteKey($keyId, $userId) {
|
||||
// First verify the key belongs to the user
|
||||
$apiKey = $this->db->get($this->table, ['id', 'user_id'], [
|
||||
'id' => $keyId,
|
||||
'user_id' => $userId
|
||||
]);
|
||||
|
||||
if (!$apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up rate limit cache files for this key
|
||||
$cachePattern = __DIR__ . "/../../sessions/rate_limit_api_rate_limit_{$keyId}.json";
|
||||
@unlink($cachePattern);
|
||||
|
||||
// Delete the API key from database
|
||||
return $this->db->delete($this->table, [
|
||||
'id' => $keyId,
|
||||
'user_id' => $userId // Ensure user can only delete their own keys
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API key has specific permission
|
||||
*/
|
||||
public function hasPermission($apiKey, $permission) {
|
||||
if (empty($apiKey['permissions'])) {
|
||||
return true; // No restrictions means full access
|
||||
}
|
||||
|
||||
$permissions = json_decode($apiKey['permissions'], true);
|
||||
if (!is_array($permissions)) {
|
||||
return true; // Invalid permissions JSON means full access
|
||||
}
|
||||
|
||||
return in_array($permission, $permissions) || in_array('*', $permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys with user information (for admin access)
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select($this->table, [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'api_keys.id',
|
||||
'api_keys.name',
|
||||
'api_keys.api_key_prefix',
|
||||
'api_keys.permissions',
|
||||
'api_keys.rate_limit_per_minute',
|
||||
'api_keys.last_used_at',
|
||||
'api_keys.expires_at',
|
||||
'api_keys.is_active',
|
||||
'api_keys.failed_attempts',
|
||||
'api_keys.blocked_until',
|
||||
'api_keys.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'ORDER' => ['api_keys.created_at' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys with filters (admin access)
|
||||
*/
|
||||
public function getAllWithFilters($filters = [], $page = 1, $limit = 20) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($filters, $page, $limit) {
|
||||
$conditions = [];
|
||||
$joins = [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
];
|
||||
|
||||
// Apply filters
|
||||
if (isset($filters['is_active'])) {
|
||||
$conditions['api_keys.is_active'] = $filters['is_active'];
|
||||
}
|
||||
|
||||
if (!empty($filters['user_id'])) {
|
||||
$conditions['api_keys.user_id'] = $filters['user_id'];
|
||||
}
|
||||
|
||||
if (!empty($filters['expires_before'])) {
|
||||
$conditions['api_keys.expires_at[<=]'] = $filters['expires_before'];
|
||||
}
|
||||
|
||||
if (!empty($filters['expires_after'])) {
|
||||
$conditions['api_keys.expires_at[>=]'] = $filters['expires_after'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (!empty($filters['search'])) {
|
||||
$conditions['OR'] = [
|
||||
'api_keys.name[~]' => $filters['search'],
|
||||
'api_keys.api_key_prefix[~]' => $filters['search']
|
||||
];
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$offset = ($page - 1) * $limit;
|
||||
$conditions['LIMIT'] = [$offset, $limit];
|
||||
$conditions['ORDER'] = ['api_keys.created_at' => 'DESC'];
|
||||
|
||||
$columns = [
|
||||
'api_keys.id',
|
||||
'api_keys.name',
|
||||
'api_keys.api_key_prefix',
|
||||
'api_keys.permissions',
|
||||
'api_keys.rate_limit_per_minute',
|
||||
'api_keys.last_used_at',
|
||||
'api_keys.expires_at',
|
||||
'api_keys.is_active',
|
||||
'api_keys.failed_attempts',
|
||||
'api_keys.blocked_until',
|
||||
'api_keys.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
];
|
||||
|
||||
return $this->db->select($this->table, $joins, $columns, $conditions);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate API key (admin access - any user)
|
||||
*/
|
||||
public function adminDeactivateKey($keyId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($keyId) {
|
||||
return $this->db->update($this->table, [
|
||||
'is_active' => false
|
||||
], [
|
||||
'id' => $keyId
|
||||
]);
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete API key (admin access - any user)
|
||||
*/
|
||||
public function adminDeleteKey($keyId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($keyId) {
|
||||
// Clean up rate limit cache files for this key
|
||||
$cachePattern = __DIR__ . "/../../sessions/rate_limit_api_rate_limit_{$keyId}.json";
|
||||
@unlink($cachePattern);
|
||||
|
||||
// Delete the API key from database
|
||||
return $this->db->delete($this->table, [
|
||||
'id' => $keyId
|
||||
]);
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key statistics (admin access)
|
||||
*/
|
||||
public function getApiKeyStats() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
$totalKeys = $this->db->count($this->table);
|
||||
$activeKeys = $this->db->count($this->table, ['is_active' => true]);
|
||||
$inactiveKeys = $this->db->count($this->table, ['is_active' => false]);
|
||||
$expiredKeys = $this->db->count($this->table, [
|
||||
'expires_at[<=]' => date('Y-m-d H:i:s'),
|
||||
'expires_at[!]' => null
|
||||
]);
|
||||
|
||||
return [
|
||||
'total_keys' => $totalKeys,
|
||||
'active_keys' => $activeKeys,
|
||||
'inactive_keys' => $inactiveKeys,
|
||||
'expired_keys' => $expiredKeys
|
||||
];
|
||||
}, [
|
||||
'total_keys' => 0,
|
||||
'active_keys' => 0,
|
||||
'inactive_keys' => 0,
|
||||
'expired_keys' => 0
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check rate limit for API key
|
||||
*/
|
||||
public function checkRateLimit($keyId) {
|
||||
$cacheKey = "api_rate_limit_{$keyId}";
|
||||
$rateLimitData = $this->getCachedRateLimit($cacheKey);
|
||||
|
||||
if (!$rateLimitData) {
|
||||
// First request in this minute
|
||||
$this->setCachedRateLimit($cacheKey, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
$apiKey = $this->find($keyId);
|
||||
if (!$apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = $apiKey['rate_limit_per_minute'];
|
||||
|
||||
if ($rateLimitData['count'] >= $limit) {
|
||||
return false; // Rate limit exceeded
|
||||
}
|
||||
|
||||
// Increment counter
|
||||
$this->setCachedRateLimit($cacheKey, $rateLimitData['count'] + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple file-based rate limit cache (you can replace with Redis/Memcached)
|
||||
*/
|
||||
private function getCachedRateLimit($key) {
|
||||
$cacheFile = __DIR__ . "/../../sessions/rate_limit_{$key}.json";
|
||||
|
||||
if (!file_exists($cacheFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode(file_get_contents($cacheFile), true);
|
||||
|
||||
// Check if cache is still valid (within current minute)
|
||||
if ($data && $data['expires'] > time()) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// Clean up expired cache
|
||||
@unlink($cacheFile);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set rate limit cache
|
||||
*/
|
||||
private function setCachedRateLimit($key, $count) {
|
||||
$cacheFile = __DIR__ . "/../../sessions/rate_limit_{$key}.json";
|
||||
$data = [
|
||||
'count' => $count,
|
||||
'expires' => strtotime('+1 minute', mktime(date('H'), date('i'), 0)) // Next minute boundary
|
||||
];
|
||||
|
||||
file_put_contents($cacheFile, json_encode($data));
|
||||
}
|
||||
}
|
||||
372
app/Models/BankAccount.php
Normal file
372
app/Models/BankAccount.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class BankAccount extends Model {
|
||||
|
||||
protected $table = 'bank_accounts';
|
||||
|
||||
public function getByUserId($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('bank_accounts', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
return $this->db->get('bank_accounts', '*', ['id' => $id]);
|
||||
}, null);
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($data) {
|
||||
$result = $this->db->insert('bank_accounts', $data);
|
||||
if ($result) {
|
||||
return $this->db->id();
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id, $data) {
|
||||
$result = $this->db->update('bank_accounts', $data, ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
$result = $this->db->delete('bank_accounts', ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bank accounts with user information (for admin access)
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('bank_accounts', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'bank_accounts.id',
|
||||
'bank_accounts.name',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_type',
|
||||
'bank_accounts.account_number_last4',
|
||||
'bank_accounts.currency',
|
||||
'bank_accounts.created_at',
|
||||
'bank_accounts.updated_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'ORDER' => ['bank_accounts.created_at' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bank accounts by currency (admin access - all users)
|
||||
*/
|
||||
public function getAllByCurrency($currency) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($currency) {
|
||||
return $this->db->select('bank_accounts', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'bank_accounts.id',
|
||||
'bank_accounts.name',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_type',
|
||||
'bank_accounts.account_number_last4',
|
||||
'bank_accounts.currency',
|
||||
'bank_accounts.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'bank_accounts.currency' => $currency,
|
||||
'ORDER' => ['bank_accounts.name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bank accounts by type (admin access - all users)
|
||||
*/
|
||||
public function getAllByType($accountType) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($accountType) {
|
||||
return $this->db->select('bank_accounts', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'bank_accounts.id',
|
||||
'bank_accounts.name',
|
||||
'bank_accounts.bank_name',
|
||||
'bank_accounts.account_type',
|
||||
'bank_accounts.account_number_last4',
|
||||
'bank_accounts.currency',
|
||||
'bank_accounts.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'bank_accounts.account_type' => $accountType,
|
||||
'ORDER' => ['bank_accounts.name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bank accounts by currency
|
||||
*/
|
||||
public function getByCurrency($userId, $currency) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $currency) {
|
||||
return $this->db->select('bank_accounts', '*', [
|
||||
'user_id' => $userId,
|
||||
'currency' => $currency,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bank accounts by type
|
||||
*/
|
||||
public function getByType($userId, $accountType) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $accountType) {
|
||||
return $this->db->select('bank_accounts', '*', [
|
||||
'user_id' => $userId,
|
||||
'account_type' => $accountType,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bank account is used in expenses
|
||||
*/
|
||||
public function isUsedInExpenses($accountId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($accountId) {
|
||||
$count = $this->db->count('expenses', ['bank_account_id' => $accountId]);
|
||||
return $count > 0;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bank account is used in subscriptions
|
||||
*/
|
||||
public function isUsedInSubscriptions($accountId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($accountId) {
|
||||
$count = $this->db->count('subscriptions', ['bank_account_id' => $accountId]);
|
||||
return $count > 0;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage statistics for a bank account
|
||||
*/
|
||||
public function getUsageStats($accountId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($accountId) {
|
||||
$expenseCount = $this->db->count('expenses', ['bank_account_id' => $accountId]);
|
||||
$subscriptionCount = $this->db->count('subscriptions', ['bank_account_id' => $accountId]);
|
||||
$transactionCount = $this->db->count('transactions', ['bank_account_id' => $accountId]);
|
||||
|
||||
// Get total spent through this account
|
||||
$totalExpenses = $this->db->sum('expenses', 'amount', ['bank_account_id' => $accountId]) ?: 0;
|
||||
$totalSubscriptions = $this->db->sum('subscriptions', 'amount', ['bank_account_id' => $accountId]) ?: 0;
|
||||
|
||||
return [
|
||||
'expense_count' => $expenseCount,
|
||||
'subscription_count' => $subscriptionCount,
|
||||
'transaction_count' => $transactionCount,
|
||||
'total_expenses' => floatval($totalExpenses),
|
||||
'total_subscriptions' => floatval($totalSubscriptions),
|
||||
'total_amount' => floatval($totalExpenses + $totalSubscriptions)
|
||||
];
|
||||
}, [
|
||||
'expense_count' => 0,
|
||||
'subscription_count' => 0,
|
||||
'transaction_count' => 0,
|
||||
'total_expenses' => 0,
|
||||
'total_subscriptions' => 0,
|
||||
'total_amount' => 0
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular account types
|
||||
*/
|
||||
public function getPopularAccountTypes($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('bank_accounts', [
|
||||
'account_type',
|
||||
'count' => 'COUNT(*)'
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'GROUP' => 'account_type',
|
||||
'ORDER' => ['count' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate account number format (basic validation)
|
||||
*/
|
||||
public static function validateAccountNumber($accountNumber) {
|
||||
// Remove spaces and hyphens
|
||||
$cleaned = preg_replace('/[\s\-]/', '', $accountNumber);
|
||||
|
||||
// Check if it's all numeric and reasonable length
|
||||
return preg_match('/^\d{8,17}$/', $cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate routing number (global format - more flexible)
|
||||
*/
|
||||
public static function validateRoutingNumber($routingNumber) {
|
||||
// Remove spaces and hyphens
|
||||
$cleaned = preg_replace('/[\s\-]/', '', $routingNumber);
|
||||
|
||||
// Allow routing numbers between 6-15 digits for international support
|
||||
// US: 9 digits, UK: 6 digits (sort code), EU: varies (IBAN routing), etc.
|
||||
if (!preg_match('/^\d{6,15}$/', $cleaned)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For now, just validate format - specific country validation could be added later
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked account number for display
|
||||
*/
|
||||
public static function getMaskedAccountNumber($last4) {
|
||||
return '****' . $last4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account type options
|
||||
*/
|
||||
public static function getAccountTypes() {
|
||||
return [
|
||||
'checking' => 'Checking Account',
|
||||
'savings' => 'Savings Account',
|
||||
'business' => 'Business Account',
|
||||
'money_market' => 'Money Market Account',
|
||||
'cd' => 'Certificate of Deposit',
|
||||
'other' => 'Other'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported currencies
|
||||
*/
|
||||
public static function getSupportedCurrencies() {
|
||||
return [
|
||||
'USD' => 'US Dollar',
|
||||
'EUR' => 'Euro',
|
||||
'GBP' => 'British Pound',
|
||||
'CAD' => 'Canadian Dollar',
|
||||
'AUD' => 'Australian Dollar',
|
||||
'JPY' => 'Japanese Yen',
|
||||
'CHF' => 'Swiss Franc',
|
||||
'CNY' => 'Chinese Yuan',
|
||||
'SEK' => 'Swedish Krona',
|
||||
'NOK' => 'Norwegian Krone',
|
||||
'DKK' => 'Danish Krone',
|
||||
'SGD' => 'Singapore Dollar',
|
||||
'HKD' => 'Hong Kong Dollar'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported countries
|
||||
*/
|
||||
public static function getSupportedCountries() {
|
||||
return [
|
||||
'US' => 'United States',
|
||||
'GB' => 'United Kingdom',
|
||||
'DE' => 'Germany',
|
||||
'FR' => 'France',
|
||||
'CA' => 'Canada',
|
||||
'AU' => 'Australia',
|
||||
'NL' => 'Netherlands',
|
||||
'CH' => 'Switzerland',
|
||||
'SE' => 'Sweden',
|
||||
'NO' => 'Norway',
|
||||
'DK' => 'Denmark',
|
||||
'JP' => 'Japan',
|
||||
'SG' => 'Singapore',
|
||||
'HK' => 'Hong Kong'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate IBAN format (basic validation)
|
||||
*/
|
||||
public static function validateIban($iban) {
|
||||
// Remove spaces and convert to uppercase
|
||||
$iban = strtoupper(preg_replace('/\s+/', '', $iban));
|
||||
|
||||
// Basic IBAN format validation (2 letter country code + 2 check digits + up to 30 alphanumeric)
|
||||
if (!preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$/', $iban)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Length validation per country (basic check)
|
||||
$lengths = [
|
||||
'AD' => 24, 'AE' => 23, 'AL' => 28, 'AT' => 20, 'AZ' => 28, 'BA' => 20, 'BE' => 16,
|
||||
'BG' => 22, 'BH' => 22, 'BR' => 29, 'CH' => 21, 'CR' => 22, 'CY' => 28, 'CZ' => 24,
|
||||
'DE' => 22, 'DK' => 18, 'DO' => 28, 'EE' => 20, 'ES' => 24, 'FI' => 18, 'FO' => 18,
|
||||
'FR' => 27, 'GB' => 22, 'GE' => 22, 'GI' => 23, 'GL' => 18, 'GR' => 27, 'GT' => 28,
|
||||
'HR' => 21, 'HU' => 28, 'IE' => 22, 'IL' => 23, 'IS' => 26, 'IT' => 27, 'JO' => 30,
|
||||
'KW' => 30, 'KZ' => 20, 'LB' => 28, 'LI' => 21, 'LT' => 20, 'LU' => 20, 'LV' => 21,
|
||||
'MC' => 27, 'MD' => 24, 'ME' => 22, 'MK' => 19, 'MR' => 27, 'MT' => 31, 'MU' => 30,
|
||||
'NL' => 18, 'NO' => 15, 'PK' => 24, 'PL' => 28, 'PS' => 29, 'PT' => 25, 'QA' => 29,
|
||||
'RO' => 24, 'RS' => 22, 'SA' => 24, 'SE' => 24, 'SI' => 19, 'SK' => 24, 'SM' => 27,
|
||||
'TN' => 24, 'TR' => 26, 'UA' => 29, 'VG' => 24, 'XK' => 20
|
||||
];
|
||||
|
||||
$countryCode = substr($iban, 0, 2);
|
||||
if (isset($lengths[$countryCode]) && strlen($iban) !== $lengths[$countryCode]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SWIFT/BIC code format
|
||||
*/
|
||||
public static function validateSwiftBic($swiftBic) {
|
||||
// Remove spaces and convert to uppercase
|
||||
$swiftBic = strtoupper(preg_replace('/\s+/', '', $swiftBic));
|
||||
|
||||
// SWIFT/BIC format: 4 char bank code + 2 char country + 2 char location + optional 3 char branch
|
||||
return preg_match('/^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/', $swiftBic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search bank accounts
|
||||
*/
|
||||
public function search($userId, $query) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $query) {
|
||||
return $this->db->select('bank_accounts', '*', [
|
||||
'user_id' => $userId,
|
||||
'OR' => [
|
||||
'name[~]' => $query,
|
||||
'bank_name[~]' => $query,
|
||||
'account_type[~]' => $query
|
||||
],
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
242
app/Models/Category.php
Normal file
242
app/Models/Category.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class Category extends Model {
|
||||
|
||||
protected $table = 'categories';
|
||||
|
||||
public function getByUserId($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('categories', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
return $this->db->get('categories', '*', ['id' => $id]);
|
||||
}, null);
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($data) {
|
||||
$result = $this->db->insert('categories', $data);
|
||||
if ($result) {
|
||||
return $this->db->id();
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id, $data) {
|
||||
$result = $this->db->update('categories', $data, ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
$result = $this->db->delete('categories', ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if category name exists for user
|
||||
*/
|
||||
public function nameExistsForUser($name, $userId, $excludeId = null) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($name, $userId, $excludeId) {
|
||||
$conditions = [
|
||||
'name' => $name,
|
||||
'user_id' => $userId
|
||||
];
|
||||
|
||||
if ($excludeId) {
|
||||
$conditions['id[!]'] = $excludeId;
|
||||
}
|
||||
|
||||
$category = $this->db->get('categories', 'id', $conditions);
|
||||
return !empty($category);
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories with expense count
|
||||
*/
|
||||
public function getCategoriesWithExpenseCount($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
// First get all categories for the user
|
||||
$categories = $this->db->select('categories', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
|
||||
// Then add expense count for each category
|
||||
foreach ($categories as &$category) {
|
||||
$expenseCount = $this->db->count('expenses', [
|
||||
'category_id' => $category['id']
|
||||
]);
|
||||
$category['expense_count'] = $expenseCount;
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search categories by name (admin access - across all users)
|
||||
*/
|
||||
public function searchByName($query, $userId = null, $limit = 10) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($query, $userId, $limit) {
|
||||
$conditions = [
|
||||
'categories.name[~]' => $query,
|
||||
'ORDER' => ['categories.name' => 'ASC'],
|
||||
'LIMIT' => $limit
|
||||
];
|
||||
|
||||
$joins = ['[>]users' => ['user_id' => 'id']];
|
||||
$columns = [
|
||||
'categories.id',
|
||||
'categories.name',
|
||||
'categories.description',
|
||||
'categories.color',
|
||||
'categories.icon',
|
||||
'categories.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
];
|
||||
|
||||
// For centralized admin system, search all categories by default
|
||||
// Only filter by user if specifically requested (legacy support)
|
||||
if ($userId !== null) {
|
||||
$conditions['categories.user_id'] = $userId;
|
||||
}
|
||||
|
||||
return $this->db->select('categories', $joins, $columns, $conditions);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular categories (most used)
|
||||
*/
|
||||
public function getPopularCategories($userId, $limit = 5) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $limit) {
|
||||
// Get categories with their expense counts
|
||||
$categories = $this->getCategoriesWithExpenseCount($userId);
|
||||
|
||||
// Filter out categories with no expenses and sort by count
|
||||
$categories = array_filter($categories, function($category) {
|
||||
return $category['expense_count'] > 0;
|
||||
});
|
||||
|
||||
// Sort by expense count descending
|
||||
usort($categories, function($a, $b) {
|
||||
return $b['expense_count'] - $a['expense_count'];
|
||||
});
|
||||
|
||||
// Limit results
|
||||
return array_slice($categories, 0, $limit);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default categories for new users
|
||||
*/
|
||||
public static function getDefaultCategories() {
|
||||
return [
|
||||
['name' => 'General', 'description' => 'General expenses', 'color' => '#3B82F6', 'icon' => 'fas fa-folder']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default categories for a user
|
||||
*/
|
||||
public function createDefaultCategories($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
$defaultCategories = self::getDefaultCategories();
|
||||
$created = 0;
|
||||
|
||||
foreach ($defaultCategories as $category) {
|
||||
$categoryData = array_merge($category, [
|
||||
'user_id' => $userId,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$result = $this->db->insert('categories', $categoryData);
|
||||
if ($result) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
return $created;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories with expense count and user info (for centralized system)
|
||||
*/
|
||||
public function getAllCategoriesWithExpenseCountAndUser() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
// First get all categories with user info
|
||||
$categories = $this->db->select('categories', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'categories.id',
|
||||
'categories.name',
|
||||
'categories.description',
|
||||
'categories.color',
|
||||
'categories.icon',
|
||||
'categories.created_at',
|
||||
'categories.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], [
|
||||
'ORDER' => ['categories.name' => 'ASC']
|
||||
]);
|
||||
|
||||
// Then add expense count for each category
|
||||
foreach ($categories as &$category) {
|
||||
$expenseCount = $this->db->count('expenses', [
|
||||
'category_id' => $category['id']
|
||||
]);
|
||||
$category['expense_count'] = $expenseCount;
|
||||
}
|
||||
|
||||
return $categories;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all categories with user info (for admin access and AJAX endpoints)
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('categories', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'categories.id',
|
||||
'categories.name',
|
||||
'categories.description',
|
||||
'categories.color',
|
||||
'categories.icon',
|
||||
'categories.created_at',
|
||||
'categories.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], [
|
||||
'ORDER' => ['categories.name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
100
app/Models/CreditCard.php
Normal file
100
app/Models/CreditCard.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class CreditCard extends Model {
|
||||
|
||||
protected $table = 'credit_cards';
|
||||
|
||||
public function getByUserId($userId) {
|
||||
return $this->db->select('credit_cards', '*', ['user_id' => $userId]);
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return $this->db->get('credit_cards', '*', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
return $this->db->insert('credit_cards', $data);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return $this->db->update('credit_cards', $data, ['id' => $id]);
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return $this->db->delete('credit_cards', ['id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credit cards with user information (for admin access)
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('credit_cards', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'credit_cards.id',
|
||||
'credit_cards.name',
|
||||
'credit_cards.card_number_last4',
|
||||
'credit_cards.expiry_month',
|
||||
'credit_cards.expiry_year',
|
||||
'credit_cards.created_at',
|
||||
'credit_cards.updated_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'ORDER' => ['credit_cards.created_at' => 'DESC']
|
||||
]);
|
||||
}, []); // Return empty array as fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credit cards with filters (admin access)
|
||||
*/
|
||||
public function getAllWithFilters($filters = [], $page = 1, $limit = 20) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($filters, $page, $limit) {
|
||||
$conditions = [];
|
||||
$joins = [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
];
|
||||
|
||||
// Apply filters
|
||||
if (!empty($filters['expiry_year'])) {
|
||||
$conditions['credit_cards.expiry_year'] = $filters['expiry_year'];
|
||||
}
|
||||
|
||||
if (!empty($filters['expiry_month'])) {
|
||||
$conditions['credit_cards.expiry_month'] = $filters['expiry_month'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (!empty($filters['search'])) {
|
||||
$conditions['OR'] = [
|
||||
'credit_cards.name[~]' => $filters['search'],
|
||||
'credit_cards.card_number_last4[~]' => $filters['search']
|
||||
];
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$offset = ($page - 1) * $limit;
|
||||
$conditions['LIMIT'] = [$offset, $limit];
|
||||
$conditions['ORDER'] = ['credit_cards.created_at' => 'DESC'];
|
||||
|
||||
$columns = [
|
||||
'credit_cards.id',
|
||||
'credit_cards.name',
|
||||
'credit_cards.card_number_last4',
|
||||
'credit_cards.expiry_month',
|
||||
'credit_cards.expiry_year',
|
||||
'credit_cards.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
];
|
||||
|
||||
return $this->db->select('credit_cards', $joins, $columns, $conditions);
|
||||
}, []);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
417
app/Models/CryptoWallet.php
Normal file
417
app/Models/CryptoWallet.php
Normal file
@@ -0,0 +1,417 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class CryptoWallet extends Model {
|
||||
|
||||
protected $table = 'crypto_wallets';
|
||||
|
||||
public function getByUserId($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('crypto_wallets', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
return $this->db->get('crypto_wallets', '*', ['id' => $id]);
|
||||
}, null);
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($data) {
|
||||
// Auto-generate shortened address for display
|
||||
if (isset($data['address'])) {
|
||||
$data['address_short'] = $this->generateShortAddress($data['address']);
|
||||
}
|
||||
|
||||
$result = $this->db->insert('crypto_wallets', $data);
|
||||
if ($result) {
|
||||
return $this->db->id();
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id, $data) {
|
||||
// Update shortened address if full address is being updated
|
||||
if (isset($data['address'])) {
|
||||
$data['address_short'] = $this->generateShortAddress($data['address']);
|
||||
}
|
||||
|
||||
$result = $this->db->update('crypto_wallets', $data, ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
$result = $this->db->delete('crypto_wallets', ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all crypto wallets with user information
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('crypto_wallets', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'crypto_wallets.id',
|
||||
'crypto_wallets.name',
|
||||
'crypto_wallets.currency',
|
||||
'crypto_wallets.network',
|
||||
'crypto_wallets.address_short',
|
||||
'crypto_wallets.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'ORDER' => ['crypto_wallets.created_at' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallets by currency
|
||||
*/
|
||||
public function getByCurrency($userId, $currency) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $currency) {
|
||||
return $this->db->select('crypto_wallets', '*', [
|
||||
'user_id' => $userId,
|
||||
'currency' => $currency,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallets by network
|
||||
*/
|
||||
public function getByNetwork($userId, $network) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $network) {
|
||||
return $this->db->select('crypto_wallets', '*', [
|
||||
'user_id' => $userId,
|
||||
'network' => $network,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all wallets by currency (admin access - all users)
|
||||
*/
|
||||
public function getAllByCurrency($currency) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($currency) {
|
||||
return $this->db->select('crypto_wallets', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'crypto_wallets.id',
|
||||
'crypto_wallets.name',
|
||||
'crypto_wallets.currency',
|
||||
'crypto_wallets.network',
|
||||
'crypto_wallets.address_short',
|
||||
'crypto_wallets.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'crypto_wallets.currency' => $currency,
|
||||
'ORDER' => ['crypto_wallets.name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all wallets by network (admin access - all users)
|
||||
*/
|
||||
public function getAllByNetwork($network) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($network) {
|
||||
return $this->db->select('crypto_wallets', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'crypto_wallets.id',
|
||||
'crypto_wallets.name',
|
||||
'crypto_wallets.currency',
|
||||
'crypto_wallets.network',
|
||||
'crypto_wallets.address_short',
|
||||
'crypto_wallets.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'crypto_wallets.network' => $network,
|
||||
'ORDER' => ['crypto_wallets.name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if wallet is used in expenses
|
||||
*/
|
||||
public function isUsedInExpenses($walletId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($walletId) {
|
||||
$count = $this->db->count('expenses', ['crypto_wallet_id' => $walletId]);
|
||||
return $count > 0;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if wallet is used in subscriptions
|
||||
*/
|
||||
public function isUsedInSubscriptions($walletId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($walletId) {
|
||||
$count = $this->db->count('subscriptions', ['crypto_wallet_id' => $walletId]);
|
||||
return $count > 0;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage statistics for a crypto wallet
|
||||
*/
|
||||
public function getUsageStats($walletId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($walletId) {
|
||||
$expenseCount = $this->db->count('expenses', ['crypto_wallet_id' => $walletId]);
|
||||
$subscriptionCount = $this->db->count('subscriptions', ['crypto_wallet_id' => $walletId]);
|
||||
$transactionCount = $this->db->count('transactions', ['crypto_wallet_id' => $walletId]);
|
||||
|
||||
// Get total spent through this wallet
|
||||
$totalExpenses = $this->db->sum('expenses', 'amount', ['crypto_wallet_id' => $walletId]) ?: 0;
|
||||
$totalSubscriptions = $this->db->sum('subscriptions', 'amount', ['crypto_wallet_id' => $walletId]) ?: 0;
|
||||
|
||||
return [
|
||||
'expense_count' => $expenseCount,
|
||||
'subscription_count' => $subscriptionCount,
|
||||
'transaction_count' => $transactionCount,
|
||||
'total_expenses' => floatval($totalExpenses),
|
||||
'total_subscriptions' => floatval($totalSubscriptions),
|
||||
'total_amount' => floatval($totalExpenses + $totalSubscriptions)
|
||||
];
|
||||
}, [
|
||||
'expense_count' => 0,
|
||||
'subscription_count' => 0,
|
||||
'transaction_count' => 0,
|
||||
'total_expenses' => 0,
|
||||
'total_subscriptions' => 0,
|
||||
'total_amount' => 0
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular currencies
|
||||
*/
|
||||
public function getPopularCurrencies($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('crypto_wallets', [
|
||||
'currency',
|
||||
'count' => 'COUNT(*)'
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'GROUP' => 'currency',
|
||||
'ORDER' => ['count' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular networks
|
||||
*/
|
||||
public function getPopularNetworks($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('crypto_wallets', [
|
||||
'network',
|
||||
'count' => 'COUNT(*)'
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'GROUP' => 'network',
|
||||
'ORDER' => ['count' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate shortened address for display
|
||||
*/
|
||||
private function generateShortAddress($address) {
|
||||
if (strlen($address) <= 20) {
|
||||
return $address;
|
||||
}
|
||||
|
||||
return substr($address, 0, 8) . '...' . substr($address, -8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate crypto address format (basic validation)
|
||||
*/
|
||||
public static function validateAddress($address, $network = null) {
|
||||
// Remove spaces
|
||||
$address = trim($address);
|
||||
|
||||
if (empty($address)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Basic length validation
|
||||
if (strlen($address) < 20 || strlen($address) > 100) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Network-specific validation
|
||||
switch (strtoupper($network)) {
|
||||
case 'TRC20':
|
||||
// TRON addresses start with 'T' and are 34 characters
|
||||
return preg_match('/^T[A-Za-z0-9]{33}$/', $address);
|
||||
|
||||
case 'BEP20':
|
||||
case 'ERC20':
|
||||
// Ethereum-style addresses start with '0x' and are 42 characters
|
||||
return preg_match('/^0x[a-fA-F0-9]{40}$/', $address);
|
||||
|
||||
case 'BTC':
|
||||
// Bitcoin addresses can start with 1, 3, or bc1
|
||||
return preg_match('/^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^bc1[a-z0-9]{39,59}$/', $address);
|
||||
|
||||
default:
|
||||
// Generic validation - alphanumeric with some special characters
|
||||
return preg_match('/^[a-zA-Z0-9\-_\.]+$/', $address);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported cryptocurrencies
|
||||
*/
|
||||
public static function getSupportedCurrencies() {
|
||||
return [
|
||||
'USDT' => 'Tether USD',
|
||||
'USDC' => 'USD Coin',
|
||||
'BTC' => 'Bitcoin',
|
||||
'ETH' => 'Ethereum',
|
||||
'TRX' => 'TRON',
|
||||
'BNB' => 'Binance Coin',
|
||||
'BUSD' => 'Binance USD',
|
||||
'DAI' => 'Dai Stablecoin',
|
||||
'LINK' => 'Chainlink',
|
||||
'ADA' => 'Cardano',
|
||||
'DOT' => 'Polkadot',
|
||||
'UNI' => 'Uniswap'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported networks
|
||||
*/
|
||||
public static function getSupportedNetworks() {
|
||||
return [
|
||||
'TRC20' => 'TRON (TRC-20)',
|
||||
'BEP20' => 'Binance Smart Chain (BEP-20)',
|
||||
'ERC20' => 'Ethereum (ERC-20)',
|
||||
'BTC' => 'Bitcoin Network',
|
||||
'ETH' => 'Ethereum Native',
|
||||
'TRX' => 'TRON Native',
|
||||
'BNB' => 'Binance Smart Chain Native',
|
||||
'POLYGON' => 'Polygon Network',
|
||||
'ARBITRUM' => 'Arbitrum Network',
|
||||
'OPTIMISM' => 'Optimism Network'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network currency compatibility
|
||||
*/
|
||||
public static function getNetworkCurrencyCompatibility() {
|
||||
return [
|
||||
'TRC20' => ['USDT', 'USDC', 'TRX'],
|
||||
'BEP20' => ['USDT', 'USDC', 'BNB', 'BUSD'],
|
||||
'ERC20' => ['USDT', 'USDC', 'ETH', 'DAI', 'LINK', 'UNI'],
|
||||
'BTC' => ['BTC'],
|
||||
'ETH' => ['ETH'],
|
||||
'TRX' => ['TRX'],
|
||||
'BNB' => ['BNB'],
|
||||
'POLYGON' => ['USDT', 'USDC', 'DAI'],
|
||||
'ARBITRUM' => ['USDT', 'USDC', 'ETH'],
|
||||
'OPTIMISM' => ['USDT', 'USDC', 'ETH']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currency is compatible with network
|
||||
*/
|
||||
public static function isCurrencyCompatibleWithNetwork($currency, $network) {
|
||||
$compatibility = self::getNetworkCurrencyCompatibility();
|
||||
return isset($compatibility[$network]) && in_array($currency, $compatibility[$network]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search crypto wallets
|
||||
*/
|
||||
public function search($userId, $query) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $query) {
|
||||
return $this->db->select('crypto_wallets', '*', [
|
||||
'user_id' => $userId,
|
||||
'OR' => [
|
||||
'name[~]' => $query,
|
||||
'currency[~]' => $query,
|
||||
'network[~]' => $query,
|
||||
'address[~]' => $query
|
||||
],
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet statistics grouped by currency
|
||||
*/
|
||||
public function getCurrencyStats($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('crypto_wallets', [
|
||||
'currency',
|
||||
'network',
|
||||
'wallet_count' => 'COUNT(*)'
|
||||
], [
|
||||
'user_id' => $userId,
|
||||
'GROUP' => ['currency', 'network'],
|
||||
'ORDER' => ['currency' => 'ASC', 'network' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if wallet address already exists for user
|
||||
*/
|
||||
public function addressExistsForUser($address, $userId, $excludeId = null) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($address, $userId, $excludeId) {
|
||||
$conditions = [
|
||||
'address' => $address,
|
||||
'user_id' => $userId
|
||||
];
|
||||
|
||||
if ($excludeId) {
|
||||
$conditions['id[!]'] = $excludeId;
|
||||
}
|
||||
|
||||
$wallet = $this->db->get('crypto_wallets', 'id', $conditions);
|
||||
return !empty($wallet);
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get masked address for display
|
||||
*/
|
||||
public static function getMaskedAddress($address) {
|
||||
if (strlen($address) <= 20) {
|
||||
return $address;
|
||||
}
|
||||
|
||||
return substr($address, 0, 8) . '...' . substr($address, -8);
|
||||
}
|
||||
}
|
||||
1495
app/Models/Expense.php
Normal file
1495
app/Models/Expense.php
Normal file
File diff suppressed because it is too large
Load Diff
48
app/Models/Model.php
Normal file
48
app/Models/Model.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Medoo\Medoo;
|
||||
|
||||
class Model {
|
||||
protected $db;
|
||||
protected $table;
|
||||
|
||||
public function __construct(Medoo $db) {
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
public function getDB() {
|
||||
return $this->db;
|
||||
}
|
||||
|
||||
public function getAll($columns = '*', $where = []) {
|
||||
return $this->db->select($this->table, $columns, $where);
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return $this->db->get($this->table, '*', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function count($where = []) {
|
||||
return $this->db->count($this->table, $where);
|
||||
}
|
||||
|
||||
public function sum($column, $where = []) {
|
||||
return $this->db->sum($this->table, $column, $where);
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
$this->db->insert($this->table, $data);
|
||||
return $this->db->id();
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return $this->db->update($this->table, $data, ['id' => $id]);
|
||||
}
|
||||
|
||||
public function getPaginated($page, $limit, $where = [], $order = ['id' => 'DESC']) {
|
||||
$offset = ($page - 1) * $limit;
|
||||
$where['LIMIT'] = [$offset, $limit];
|
||||
$where['ORDER'] = $order;
|
||||
return $this->db->select($this->table, '*', $where);
|
||||
}
|
||||
}
|
||||
243
app/Models/Subscription.php
Normal file
243
app/Models/Subscription.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class Subscription extends Model {
|
||||
|
||||
protected $table = 'subscriptions';
|
||||
|
||||
public function getByUserId($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('subscriptions', '*', ['user_id' => $userId]);
|
||||
}, []); // Return empty array as fallback
|
||||
}
|
||||
|
||||
public function getByUserIdWithDateFilter($userId, $from_date, $to_date) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $from_date, $to_date) {
|
||||
return $this->db->select('subscriptions', '*', [
|
||||
'user_id' => $userId,
|
||||
'created_at[>=]' => $from_date . ' 00:00:00',
|
||||
'created_at[<=]' => $to_date . ' 23:59:59',
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
}, []); // Return empty array as fallback
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
return $this->db->get('subscriptions', '*', ['id' => $id]);
|
||||
}, null); // Return null as fallback
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($data) {
|
||||
$result = $this->db->insert('subscriptions', $data);
|
||||
// Medoo returns PDOStatement for insert operations
|
||||
// Get the last inserted ID if successful
|
||||
if ($result) {
|
||||
return $this->db->id();
|
||||
}
|
||||
return false;
|
||||
}, false); // Return false as fallback
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id, $data) {
|
||||
$result = $this->db->update('subscriptions', $data, ['id' => $id]);
|
||||
// Medoo returns PDOStatement for update operations
|
||||
// Check if any rows were affected
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false); // Return false as fallback
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
$result = $this->db->delete('subscriptions', ['id' => $id]);
|
||||
// Medoo returns PDOStatement for delete operations
|
||||
// Check if any rows were affected
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false); // Return false as fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscriptions with pagination
|
||||
*/
|
||||
public function getAllPaginated($limit = 10, $offset = 0) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($limit, $offset) {
|
||||
return $this->db->select('subscriptions', '*', [
|
||||
'LIMIT' => [$offset, $limit],
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total subscriptions
|
||||
*/
|
||||
public function count($where = []) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($where) {
|
||||
return $this->db->count('subscriptions', $where);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriptions by status
|
||||
*/
|
||||
public function getByStatus($status) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($status) {
|
||||
return $this->db->select('subscriptions', '*', ['status' => $status]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscriptions with date filter (no user filtering)
|
||||
*/
|
||||
public function getAllWithDateFilter($from_date, $to_date) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($from_date, $to_date) {
|
||||
return $this->db->select('subscriptions', '*', [
|
||||
'created_at[>=]' => $from_date . ' 00:00:00',
|
||||
'created_at[<=]' => $to_date . ' 23:59:59',
|
||||
'ORDER' => ['created_at' => 'DESC']
|
||||
]);
|
||||
}, []); // Return empty array as fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscriptions with user information (for admin access)
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('subscriptions', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'subscriptions.id',
|
||||
'subscriptions.name',
|
||||
'subscriptions.description',
|
||||
'subscriptions.amount',
|
||||
'subscriptions.currency',
|
||||
'subscriptions.billing_cycle',
|
||||
'subscriptions.next_payment_date',
|
||||
'subscriptions.status',
|
||||
'subscriptions.created_at',
|
||||
'subscriptions.updated_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'ORDER' => ['subscriptions.created_at' => 'DESC']
|
||||
]);
|
||||
}, []); // Return empty array as fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all subscriptions with filters (admin access)
|
||||
*/
|
||||
public function getAllWithFilters($filters = [], $page = 1, $limit = 20) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($filters, $page, $limit) {
|
||||
$conditions = [];
|
||||
$joins = [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
];
|
||||
|
||||
// Apply filters
|
||||
if (!empty($filters['status'])) {
|
||||
$conditions['subscriptions.status'] = $filters['status'];
|
||||
}
|
||||
|
||||
if (!empty($filters['billing_cycle'])) {
|
||||
$conditions['subscriptions.billing_cycle'] = $filters['billing_cycle'];
|
||||
}
|
||||
|
||||
if (!empty($filters['currency'])) {
|
||||
$conditions['subscriptions.currency'] = $filters['currency'];
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
$conditions['subscriptions.next_payment_date[>=]'] = $filters['date_from'];
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
$conditions['subscriptions.next_payment_date[<=]'] = $filters['date_to'];
|
||||
}
|
||||
|
||||
if (!empty($filters['amount_min'])) {
|
||||
$conditions['subscriptions.amount[>=]'] = $filters['amount_min'];
|
||||
}
|
||||
|
||||
if (!empty($filters['amount_max'])) {
|
||||
$conditions['subscriptions.amount[<=]'] = $filters['amount_max'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (!empty($filters['search'])) {
|
||||
$conditions['OR'] = [
|
||||
'subscriptions.name[~]' => $filters['search'],
|
||||
'subscriptions.description[~]' => $filters['search']
|
||||
];
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$offset = ($page - 1) * $limit;
|
||||
$conditions['LIMIT'] = [$offset, $limit];
|
||||
$conditions['ORDER'] = ['subscriptions.created_at' => 'DESC'];
|
||||
|
||||
$columns = [
|
||||
'subscriptions.id',
|
||||
'subscriptions.name',
|
||||
'subscriptions.description',
|
||||
'subscriptions.amount',
|
||||
'subscriptions.currency',
|
||||
'subscriptions.billing_cycle',
|
||||
'subscriptions.next_payment_date',
|
||||
'subscriptions.status',
|
||||
'subscriptions.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
];
|
||||
|
||||
return $this->db->select('subscriptions', $joins, $columns, $conditions);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscription statistics across all users (admin access)
|
||||
*/
|
||||
public function getAllSubscriptionStats() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
$totalSubscriptions = $this->db->count('subscriptions');
|
||||
$activeSubscriptions = $this->db->count('subscriptions', ['status' => 'active']);
|
||||
$cancelledSubscriptions = $this->db->count('subscriptions', ['status' => 'cancelled']);
|
||||
$totalAmount = $this->db->sum('subscriptions', 'amount', ['status' => 'active']) ?: 0;
|
||||
|
||||
// Get subscription counts by billing cycle
|
||||
$cycleStats = $this->db->select('subscriptions', [
|
||||
'billing_cycle',
|
||||
'count' => 'COUNT(*)'
|
||||
], [
|
||||
'GROUP' => 'billing_cycle',
|
||||
'ORDER' => ['count' => 'DESC']
|
||||
]);
|
||||
|
||||
return [
|
||||
'total_subscriptions' => $totalSubscriptions,
|
||||
'active_subscriptions' => $activeSubscriptions,
|
||||
'cancelled_subscriptions' => $cancelledSubscriptions,
|
||||
'total_monthly_value' => floatval($totalAmount),
|
||||
'cycle_breakdown' => $cycleStats
|
||||
];
|
||||
}, [
|
||||
'total_subscriptions' => 0,
|
||||
'active_subscriptions' => 0,
|
||||
'cancelled_subscriptions' => 0,
|
||||
'total_monthly_value' => 0,
|
||||
'cycle_breakdown' => []
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
335
app/Models/Tag.php
Normal file
335
app/Models/Tag.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class Tag extends Model {
|
||||
|
||||
protected $table = 'tags';
|
||||
|
||||
public function getByUserId($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('tags', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
return $this->db->get('tags', '*', ['id' => $id]);
|
||||
}, null);
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($data) {
|
||||
$result = $this->db->insert('tags', $data);
|
||||
if ($result) {
|
||||
return $this->db->id();
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id, $data) {
|
||||
$result = $this->db->update('tags', $data, ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
// First remove tag associations
|
||||
$this->db->delete('expense_tags', ['tag_id' => $id]);
|
||||
|
||||
// Then delete the tag
|
||||
$result = $this->db->delete('tags', ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tag name exists for user
|
||||
*/
|
||||
public function nameExistsForUser($name, $userId, $excludeId = null) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($name, $userId, $excludeId) {
|
||||
$conditions = [
|
||||
'name' => $name,
|
||||
'user_id' => $userId
|
||||
];
|
||||
|
||||
if ($excludeId) {
|
||||
$conditions['id[!]'] = $excludeId;
|
||||
}
|
||||
|
||||
$tag = $this->db->get('tags', 'id', $conditions);
|
||||
return !empty($tag);
|
||||
}, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags with expense count
|
||||
*/
|
||||
public function getTagsWithExpenseCount($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
// First get all tags for the user
|
||||
$tags = $this->db->select('tags', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
|
||||
// Then add expense count for each tag
|
||||
foreach ($tags as &$tag) {
|
||||
$expenseCount = $this->db->count('expense_tags', [
|
||||
'tag_id' => $tag['id']
|
||||
]);
|
||||
$tag['expense_count'] = $expenseCount;
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular tags (most used)
|
||||
*/
|
||||
public function getPopularTags($userId, $limit = 10) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $limit) {
|
||||
if ($userId) {
|
||||
// Get tags with their expense counts for specific user
|
||||
$tags = $this->getTagsWithExpenseCount($userId);
|
||||
} else {
|
||||
// Get all tags with their expense counts for centralized system
|
||||
$tags = $this->getAllTagsWithExpenseCountAndUser();
|
||||
}
|
||||
|
||||
// Filter out tags with no expenses and sort by count
|
||||
$tags = array_filter($tags, function($tag) {
|
||||
return $tag['expense_count'] > 0;
|
||||
});
|
||||
|
||||
// Sort by expense count descending
|
||||
usort($tags, function($a, $b) {
|
||||
return $b['expense_count'] - $a['expense_count'];
|
||||
});
|
||||
|
||||
// Limit results
|
||||
return array_slice($tags, 0, $limit);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags for a specific expense
|
||||
*/
|
||||
public function getTagsForExpense($expenseId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($expenseId) {
|
||||
// First get the tag IDs for this expense
|
||||
$tagIds = $this->db->select('expense_tags', 'tag_id', [
|
||||
'expense_id' => $expenseId
|
||||
]);
|
||||
|
||||
if (empty($tagIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Then get the tag details
|
||||
return $this->db->select('tags', [
|
||||
'id',
|
||||
'name',
|
||||
'color',
|
||||
'description'
|
||||
], [
|
||||
'id' => $tagIds,
|
||||
'ORDER' => ['name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach tags to an expense
|
||||
*/
|
||||
public function attachToExpense($expenseId, $tagIds) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($expenseId, $tagIds) {
|
||||
// First remove existing tags
|
||||
$this->db->delete('expense_tags', ['expense_id' => $expenseId]);
|
||||
|
||||
// Add new tags
|
||||
$attached = 0;
|
||||
foreach ($tagIds as $tagId) {
|
||||
$result = $this->db->insert('expense_tags', [
|
||||
'expense_id' => $expenseId,
|
||||
'tag_id' => $tagId
|
||||
]);
|
||||
if ($result) {
|
||||
$attached++;
|
||||
}
|
||||
}
|
||||
|
||||
return $attached;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach tags from an expense
|
||||
*/
|
||||
public function detachFromExpense($expenseId, $tagIds = null) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($expenseId, $tagIds) {
|
||||
$conditions = ['expense_id' => $expenseId];
|
||||
|
||||
if ($tagIds !== null) {
|
||||
$conditions['tag_id'] = $tagIds;
|
||||
}
|
||||
|
||||
$result = $this->db->delete('expense_tags', $conditions);
|
||||
return $result ? $result->rowCount() : 0;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default tags for new users
|
||||
*/
|
||||
public static function getDefaultTags() {
|
||||
return [
|
||||
['name' => 'General', 'description' => 'General expenses', 'color' => '#3B82F6']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default tags for a user
|
||||
*/
|
||||
public function createDefaultTags($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
$defaultTags = self::getDefaultTags();
|
||||
$created = 0;
|
||||
|
||||
foreach ($defaultTags as $tag) {
|
||||
$tagData = array_merge($tag, [
|
||||
'user_id' => $userId,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
$result = $this->db->insert('tags', $tagData);
|
||||
if ($result) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
return $created;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search tags by name
|
||||
*/
|
||||
public function searchByName($userId, $query, $limit = 10) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $query, $limit) {
|
||||
$conditions = [
|
||||
'name[~]' => $query,
|
||||
'ORDER' => ['name' => 'ASC'],
|
||||
'LIMIT' => $limit
|
||||
];
|
||||
|
||||
// If userId is provided, filter by user, otherwise search all for centralized system
|
||||
if ($userId) {
|
||||
$conditions['user_id'] = $userId;
|
||||
}
|
||||
|
||||
return $this->db->select('tags', '*', $conditions);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags with expense count (admin access across all users)
|
||||
*/
|
||||
public function getAllTagsWithExpenseCount() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
$tags = $this->db->select('tags', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.description',
|
||||
'tags.color',
|
||||
'tags.created_at',
|
||||
'tags.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], [
|
||||
'ORDER' => ['tags.name' => 'ASC']
|
||||
]);
|
||||
|
||||
// Add expense count for each tag
|
||||
foreach ($tags as &$tag) {
|
||||
$expenseCount = $this->db->count('expense_tags', [
|
||||
'tag_id' => $tag['id']
|
||||
]);
|
||||
$tag['expense_count'] = $expenseCount;
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags with expense count and user info (for centralized system)
|
||||
*/
|
||||
public function getAllTagsWithExpenseCountAndUser() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
// First get all tags with user info
|
||||
$tags = $this->db->select('tags', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.description',
|
||||
'tags.color',
|
||||
'tags.created_at',
|
||||
'tags.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], [
|
||||
'ORDER' => ['tags.name' => 'ASC']
|
||||
]);
|
||||
|
||||
// Then add expense count for each tag
|
||||
foreach ($tags as &$tag) {
|
||||
$expenseCount = $this->db->count('expense_tags', [
|
||||
'tag_id' => $tag['id']
|
||||
]);
|
||||
$tag['expense_count'] = $expenseCount;
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags with user info (for admin access and AJAX endpoints)
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('tags', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'tags.id',
|
||||
'tags.name',
|
||||
'tags.description',
|
||||
'tags.color',
|
||||
'tags.created_at',
|
||||
'tags.updated_at',
|
||||
'users.name(creator_name)',
|
||||
'users.email(creator_email)'
|
||||
], [
|
||||
'ORDER' => ['tags.name' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
442
app/Models/Transaction.php
Normal file
442
app/Models/Transaction.php
Normal file
@@ -0,0 +1,442 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
require_once __DIR__ . '/../Services/ErrorHandler.php';
|
||||
|
||||
class Transaction extends Model {
|
||||
|
||||
protected $table = 'transactions';
|
||||
|
||||
public function getByUserId($userId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId) {
|
||||
return $this->db->select('transactions', '*', [
|
||||
'user_id' => $userId,
|
||||
'ORDER' => ['transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function getBySubscriptionId($subscriptionId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($subscriptionId) {
|
||||
return $this->db->select('transactions', '*', [
|
||||
'subscription_id' => $subscriptionId,
|
||||
'ORDER' => ['transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function getByExpenseId($expenseId) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($expenseId) {
|
||||
return $this->db->select('transactions', '*', [
|
||||
'expense_id' => $expenseId,
|
||||
'ORDER' => ['transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function getByUserIdWithDateFilter($userId, $from_date, $to_date) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($userId, $from_date, $to_date) {
|
||||
return $this->db->select('transactions', '*', [
|
||||
'user_id' => $userId,
|
||||
'transaction_date[>=]' => $from_date . ' 00:00:00',
|
||||
'transaction_date[<=]' => $to_date . ' 23:59:59',
|
||||
'ORDER' => ['transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
public function getAllWithDateFilter($from_date, $to_date) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($from_date, $to_date) {
|
||||
return $this->db->select('transactions', '*', [
|
||||
'transaction_date[>=]' => $from_date . ' 00:00:00',
|
||||
'transaction_date[<=]' => $to_date . ' 23:59:59',
|
||||
'ORDER' => ['transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions with complete information (unified for both expenses and subscriptions)
|
||||
*/
|
||||
public function getAllWithCompleteInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('transactions', [
|
||||
'[>]users' => ['user_id' => 'id'],
|
||||
'[>]subscriptions' => ['subscription_id' => 'id'],
|
||||
'[>]expenses' => ['expense_id' => 'id'],
|
||||
'[>]categories' => ['expenses.category_id' => 'id'],
|
||||
'[>]credit_cards' => ['credit_card_id' => 'id'],
|
||||
'[>]bank_accounts' => ['bank_account_id' => 'id'],
|
||||
'[>]crypto_wallets' => ['crypto_wallet_id' => 'id']
|
||||
], [
|
||||
'transactions.id',
|
||||
'transactions.amount',
|
||||
'transactions.currency',
|
||||
'transactions.transaction_date',
|
||||
'transactions.status',
|
||||
'transactions.transaction_type',
|
||||
'transactions.payment_method_type',
|
||||
'transactions.reference_number',
|
||||
'transactions.description',
|
||||
'transactions.notes',
|
||||
'transactions.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)',
|
||||
'subscriptions.name(subscription_name)',
|
||||
'subscriptions.billing_cycle',
|
||||
'expenses.title(expense_title)',
|
||||
'expenses.vendor(expense_vendor)',
|
||||
'categories.name(category_name)',
|
||||
'categories.color(category_color)',
|
||||
'credit_cards.name(credit_card_name)',
|
||||
'bank_accounts.name(bank_account_name)',
|
||||
'crypto_wallets.name(crypto_wallet_name)'
|
||||
], [
|
||||
'ORDER' => ['transactions.transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions with complete information filtered by date
|
||||
*/
|
||||
public function getAllWithCompleteInfoAndDateFilter($from_date, $to_date) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($from_date, $to_date) {
|
||||
return $this->db->select('transactions', [
|
||||
'[>]users' => ['user_id' => 'id'],
|
||||
'[>]subscriptions' => ['subscription_id' => 'id'],
|
||||
'[>]expenses' => ['expense_id' => 'id'],
|
||||
'[>]categories' => ['expenses.category_id' => 'id'],
|
||||
'[>]credit_cards' => ['credit_card_id' => 'id'],
|
||||
'[>]bank_accounts' => ['bank_account_id' => 'id'],
|
||||
'[>]crypto_wallets' => ['crypto_wallet_id' => 'id']
|
||||
], [
|
||||
'transactions.id',
|
||||
'transactions.amount',
|
||||
'transactions.currency',
|
||||
'transactions.transaction_date',
|
||||
'transactions.status',
|
||||
'transactions.transaction_type',
|
||||
'transactions.payment_method_type',
|
||||
'transactions.reference_number',
|
||||
'transactions.description',
|
||||
'transactions.notes',
|
||||
'transactions.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)',
|
||||
'subscriptions.name(subscription_name)',
|
||||
'subscriptions.billing_cycle',
|
||||
'expenses.title(expense_title)',
|
||||
'expenses.vendor(expense_vendor)',
|
||||
'categories.name(category_name)',
|
||||
'categories.color(category_color)',
|
||||
'credit_cards.name(credit_card_name)',
|
||||
'bank_accounts.name(bank_account_name)',
|
||||
'crypto_wallets.name(crypto_wallet_name)'
|
||||
], [
|
||||
'transactions.transaction_date[>=]' => $from_date . ' 00:00:00',
|
||||
'transactions.transaction_date[<=]' => $to_date . ' 23:59:59',
|
||||
'ORDER' => ['transactions.transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unified transaction statistics for both expenses and subscriptions
|
||||
*/
|
||||
public function getTransactionStats($from_date = null, $to_date = null) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($from_date, $to_date) {
|
||||
$conditions = [];
|
||||
|
||||
if ($from_date && $to_date) {
|
||||
$conditions['transaction_date[>=]'] = $from_date . ' 00:00:00';
|
||||
$conditions['transaction_date[<=]'] = $to_date . ' 23:59:59';
|
||||
}
|
||||
|
||||
// Total transactions
|
||||
$totalTransactions = $this->db->count('transactions', $conditions);
|
||||
|
||||
// Successful transactions
|
||||
$successfulConditions = array_merge($conditions, ['status' => 'successful']);
|
||||
$successfulTransactions = $this->db->count('transactions', $successfulConditions);
|
||||
|
||||
// Failed transactions
|
||||
$failedConditions = array_merge($conditions, ['status' => 'failed']);
|
||||
$failedTransactions = $this->db->count('transactions', $failedConditions);
|
||||
|
||||
// Pending transactions
|
||||
$pendingConditions = array_merge($conditions, ['status' => 'pending']);
|
||||
$pendingTransactions = $this->db->count('transactions', $pendingConditions);
|
||||
|
||||
// Total revenue (successful transactions only)
|
||||
$totalRevenue = (float) $this->db->sum('transactions', 'amount', $successfulConditions);
|
||||
|
||||
// Calculate success rate
|
||||
$successRate = $totalTransactions > 0 ? round(($successfulTransactions / $totalTransactions) * 100, 2) : 0;
|
||||
|
||||
// Transaction type breakdown
|
||||
$subscriptionTransactions = $this->db->count('transactions', array_merge($conditions, ['transaction_type' => 'subscription']));
|
||||
$expenseTransactions = $this->db->count('transactions', array_merge($conditions, ['transaction_type' => 'expense']));
|
||||
|
||||
// Revenue by type
|
||||
$subscriptionRevenue = (float) $this->db->sum('transactions', 'amount', array_merge($successfulConditions, ['transaction_type' => 'subscription']));
|
||||
$expenseRevenue = (float) $this->db->sum('transactions', 'amount', array_merge($successfulConditions, ['transaction_type' => 'expense']));
|
||||
|
||||
return [
|
||||
'total_transactions' => $totalTransactions,
|
||||
'successful_transactions' => $successfulTransactions,
|
||||
'failed_transactions' => $failedTransactions,
|
||||
'pending_transactions' => $pendingTransactions,
|
||||
'total_revenue' => $totalRevenue,
|
||||
'success_rate' => $successRate,
|
||||
'subscription_transactions' => $subscriptionTransactions,
|
||||
'expense_transactions' => $expenseTransactions,
|
||||
'subscription_revenue' => $subscriptionRevenue,
|
||||
'expense_revenue' => $expenseRevenue
|
||||
];
|
||||
}, [
|
||||
'total_transactions' => 0,
|
||||
'successful_transactions' => 0,
|
||||
'failed_transactions' => 0,
|
||||
'pending_transactions' => 0,
|
||||
'total_revenue' => 0,
|
||||
'success_rate' => 0,
|
||||
'subscription_transactions' => 0,
|
||||
'expense_transactions' => 0,
|
||||
'subscription_revenue' => 0,
|
||||
'expense_revenue' => 0
|
||||
]);
|
||||
}
|
||||
|
||||
public function getMonthlyTransactionData($year = null) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($year) {
|
||||
$year = $year ?: date('Y');
|
||||
|
||||
$transactions = $this->db->select('transactions', [
|
||||
'amount',
|
||||
'transaction_date',
|
||||
'status',
|
||||
'transaction_type'
|
||||
], [
|
||||
'transaction_date[>=]' => $year . '-01-01 00:00:00',
|
||||
'transaction_date[<=]' => $year . '-12-31 23:59:59',
|
||||
'status' => 'successful'
|
||||
]);
|
||||
|
||||
$monthlyData = [
|
||||
'subscriptions' => array_fill(1, 12, 0),
|
||||
'expenses' => array_fill(1, 12, 0),
|
||||
'total' => array_fill(1, 12, 0)
|
||||
];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$month = (int)date('n', strtotime($transaction['transaction_date']));
|
||||
$amount = floatval($transaction['amount']);
|
||||
|
||||
$monthlyData['total'][$month] += $amount;
|
||||
|
||||
if ($transaction['transaction_type'] === 'subscription') {
|
||||
$monthlyData['subscriptions'][$month] += $amount;
|
||||
} else {
|
||||
$monthlyData['expenses'][$month] += $amount;
|
||||
}
|
||||
}
|
||||
|
||||
return $monthlyData;
|
||||
}, [
|
||||
'subscriptions' => array_fill(1, 12, 0),
|
||||
'expenses' => array_fill(1, 12, 0),
|
||||
'total' => array_fill(1, 12, 0)
|
||||
]);
|
||||
}
|
||||
|
||||
public function find($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
return $this->db->get('transactions', '*', ['id' => $id]);
|
||||
}, null);
|
||||
}
|
||||
|
||||
public function create($data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($data) {
|
||||
$result = $this->db->insert('transactions', $data);
|
||||
if ($result) {
|
||||
return $this->db->id();
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id, $data) {
|
||||
$result = $this->db->update('transactions', $data, ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($id) {
|
||||
$result = $this->db->delete('transactions', ['id' => $id]);
|
||||
if ($result && $result->rowCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
}
|
||||
|
||||
public function count($where = []) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($where) {
|
||||
return $this->db->count('transactions', $where);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions with user information (for admin access)
|
||||
*/
|
||||
public function getAllWithUserInfo() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->select('transactions', [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
], [
|
||||
'transactions.id',
|
||||
'transactions.amount',
|
||||
'transactions.currency',
|
||||
'transactions.transaction_date',
|
||||
'transactions.description',
|
||||
'transactions.reference_number',
|
||||
'transactions.status',
|
||||
'transactions.transaction_type',
|
||||
'transactions.payment_method_type',
|
||||
'transactions.created_at',
|
||||
'transactions.updated_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
], [
|
||||
'ORDER' => ['transactions.transaction_date' => 'DESC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all transactions with filters (admin access)
|
||||
*/
|
||||
public function getAllWithFilters($filters = [], $page = 1, $limit = 50) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($filters, $page, $limit) {
|
||||
$conditions = [];
|
||||
$joins = [
|
||||
'[>]users' => ['user_id' => 'id']
|
||||
];
|
||||
|
||||
// Apply filters
|
||||
if (!empty($filters['transaction_type'])) {
|
||||
$conditions['transactions.transaction_type'] = $filters['transaction_type'];
|
||||
}
|
||||
|
||||
if (!empty($filters['status'])) {
|
||||
$conditions['transactions.status'] = $filters['status'];
|
||||
}
|
||||
|
||||
if (!empty($filters['payment_method_type'])) {
|
||||
$conditions['transactions.payment_method_type'] = $filters['payment_method_type'];
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
$conditions['transactions.transaction_date[>=]'] = $filters['date_from'] . ' 00:00:00';
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
$conditions['transactions.transaction_date[<=]'] = $filters['date_to'] . ' 23:59:59';
|
||||
}
|
||||
|
||||
if (!empty($filters['amount_min'])) {
|
||||
$conditions['transactions.amount[>=]'] = $filters['amount_min'];
|
||||
}
|
||||
|
||||
if (!empty($filters['amount_max'])) {
|
||||
$conditions['transactions.amount[<=]'] = $filters['amount_max'];
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (!empty($filters['search'])) {
|
||||
$conditions['OR'] = [
|
||||
'transactions.description[~]' => $filters['search'],
|
||||
'transactions.reference_number[~]' => $filters['search'],
|
||||
'transactions.notes[~]' => $filters['search']
|
||||
];
|
||||
}
|
||||
|
||||
// Pagination
|
||||
$offset = ($page - 1) * $limit;
|
||||
$conditions['LIMIT'] = [$offset, $limit];
|
||||
$conditions['ORDER'] = ['transactions.transaction_date' => 'DESC'];
|
||||
|
||||
$columns = [
|
||||
'transactions.id',
|
||||
'transactions.amount',
|
||||
'transactions.currency',
|
||||
'transactions.transaction_date',
|
||||
'transactions.description',
|
||||
'transactions.reference_number',
|
||||
'transactions.status',
|
||||
'transactions.transaction_type',
|
||||
'transactions.payment_method_type',
|
||||
'transactions.created_at',
|
||||
'users.name(user_name)',
|
||||
'users.email(user_email)'
|
||||
];
|
||||
|
||||
return $this->db->select('transactions', $joins, $columns, $conditions);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create transaction for subscription payment
|
||||
*/
|
||||
public function createSubscriptionTransaction($subscriptionData, $amount, $status = 'successful') {
|
||||
$data = [
|
||||
'user_id' => $subscriptionData['user_id'],
|
||||
'subscription_id' => $subscriptionData['id'],
|
||||
'expense_id' => null,
|
||||
'credit_card_id' => $subscriptionData['credit_card_id'],
|
||||
'bank_account_id' => $subscriptionData['bank_account_id'] ?? null,
|
||||
'crypto_wallet_id' => $subscriptionData['crypto_wallet_id'] ?? null,
|
||||
'amount' => $amount,
|
||||
'currency' => $subscriptionData['currency'],
|
||||
'transaction_date' => date('Y-m-d H:i:s'),
|
||||
'status' => $status,
|
||||
'payment_method_type' => $subscriptionData['payment_method_type'] ?? 'credit_card',
|
||||
'transaction_type' => 'subscription',
|
||||
'description' => 'Subscription payment: ' . $subscriptionData['name'],
|
||||
'reference_number' => 'SUB-' . $subscriptionData['id'] . '-' . date('YmdHis')
|
||||
];
|
||||
|
||||
return $this->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create transaction for expense payment
|
||||
*/
|
||||
public function createExpenseTransaction($expenseData, $status = 'successful') {
|
||||
$data = [
|
||||
'user_id' => $expenseData['user_id'],
|
||||
'subscription_id' => null,
|
||||
'expense_id' => $expenseData['id'],
|
||||
'credit_card_id' => $expenseData['credit_card_id'],
|
||||
'bank_account_id' => $expenseData['bank_account_id'],
|
||||
'crypto_wallet_id' => $expenseData['crypto_wallet_id'],
|
||||
'amount' => $expenseData['amount'],
|
||||
'currency' => $expenseData['currency'],
|
||||
'transaction_date' => $expenseData['expense_date'] . ' ' . date('H:i:s'),
|
||||
'status' => $status,
|
||||
'payment_method_type' => $expenseData['payment_method_type'],
|
||||
'transaction_type' => 'expense',
|
||||
'description' => 'Expense payment: ' . $expenseData['title'],
|
||||
'reference_number' => 'EXP-' . $expenseData['id'] . '-' . date('YmdHis'),
|
||||
'notes' => $expenseData['notes']
|
||||
];
|
||||
|
||||
return $this->create($data);
|
||||
}
|
||||
}
|
||||
81
app/Models/User.php
Normal file
81
app/Models/User.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/Model.php';
|
||||
|
||||
class User extends Model {
|
||||
|
||||
protected $table = 'users';
|
||||
|
||||
public function findByEmail($email) {
|
||||
return $this->db->get('users', '*', ['email' => $email]);
|
||||
}
|
||||
|
||||
public function createUser($name, $email, $password, $role = 'admin') {
|
||||
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
|
||||
return $this->db->insert('users', [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $hashedPassword,
|
||||
'role' => $role
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
$originalData = $data;
|
||||
|
||||
if (isset($data['password']) && !empty($data['password'])) {
|
||||
$plainPassword = $data['password'];
|
||||
$data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
|
||||
AppLogger::info('Password hashing in User model', [
|
||||
'user_id' => $id,
|
||||
'plain_password_length' => strlen($plainPassword),
|
||||
'hashed_password_length' => strlen($data['password']),
|
||||
'hash_starts_with' => substr($data['password'], 0, 10)
|
||||
]);
|
||||
} else {
|
||||
unset($data['password']);
|
||||
AppLogger::info('No password update in User model', [
|
||||
'user_id' => $id,
|
||||
'original_data_keys' => array_keys($originalData)
|
||||
]);
|
||||
}
|
||||
|
||||
$result = $this->db->update('users', $data, ['id' => $id]);
|
||||
|
||||
AppLogger::info('Database update result in User model', [
|
||||
'user_id' => $id,
|
||||
'update_result' => $result,
|
||||
'affected_rows' => $this->db->info()['affected_rows'] ?? 'unknown',
|
||||
'data_keys' => array_keys($data)
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
return $this->db->delete('users', ['id' => $id]);
|
||||
}
|
||||
|
||||
public function enable2FA($id, $secret, $backupCodes) {
|
||||
return $this->db->update('users', [
|
||||
'two_factor_secret' => $secret,
|
||||
'two_factor_enabled' => true,
|
||||
'two_factor_backup_codes' => json_encode($backupCodes)
|
||||
], ['id' => $id]);
|
||||
}
|
||||
|
||||
public function disable2FA($id) {
|
||||
return $this->db->update('users', [
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_enabled' => false,
|
||||
'two_factor_backup_codes' => null
|
||||
], ['id' => $id]);
|
||||
}
|
||||
|
||||
public function updateBackupCodes($id, $backupCodes) {
|
||||
return $this->db->update('users', [
|
||||
'two_factor_backup_codes' => $backupCodes
|
||||
], ['id' => $id]);
|
||||
}
|
||||
}
|
||||
420
app/Routes/api.php
Normal file
420
app/Routes/api.php
Normal file
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
if (!defined('APP_RAN')) {
|
||||
die('Direct access not allowed');
|
||||
}
|
||||
|
||||
// Load API controllers
|
||||
require_once __DIR__ . '/../Controllers/Api/ApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/Schemas.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/UsersApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/TransactionsApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/CreditCardsApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/SubscriptionsApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/ExpensesApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/CategoriesApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/TagsApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/BankAccountsApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/CryptoWalletsApiController.php';
|
||||
require_once __DIR__ . '/../Controllers/Api/ReportsApiController.php';
|
||||
require_once __DIR__ . '/../Services/ApiMiddleware.php';
|
||||
|
||||
// Initialize API middleware
|
||||
ApiMiddleware::init($database);
|
||||
|
||||
// Instantiate API controllers
|
||||
$usersApiController = new UsersApiController($database);
|
||||
$transactionsApiController = new TransactionsApiController($database);
|
||||
$creditCardsApiController = new CreditCardsApiController($database);
|
||||
$subscriptionsApiController = new SubscriptionsApiController($database);
|
||||
$expensesApiController = new ExpensesApiController($database);
|
||||
$categoriesApiController = new CategoriesApiController($database);
|
||||
$tagsApiController = new TagsApiController($database);
|
||||
$bankAccountsApiController = new BankAccountsApiController($database);
|
||||
$cryptoWalletsApiController = new CryptoWalletsApiController($database);
|
||||
$reportsApiController = new ReportsApiController($database);
|
||||
|
||||
// API Health Check (no authentication required)
|
||||
$router->get('/api/health', function() {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'status' => 'healthy',
|
||||
'timestamp' => date('c'),
|
||||
'version' => '1.0.0'
|
||||
]);
|
||||
});
|
||||
|
||||
// API middleware for all API v1 routes (excluding health check)
|
||||
$router->before('GET|POST|PUT|DELETE|OPTIONS', '/api/v1/.*', function() {
|
||||
$request = ApiMiddleware::getCurrentRequest();
|
||||
|
||||
AppLogger::info('API Request received', [
|
||||
'method' => $request['method'],
|
||||
'uri' => $request['uri'],
|
||||
'user_agent' => $request['headers']['User-Agent'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
// Handle API middleware
|
||||
$middlewares = ['cors', 'securityHeaders', 'authenticate'];
|
||||
$result = ApiMiddleware::handle($middlewares, $request);
|
||||
|
||||
if (!$result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store request data globally for controllers to access
|
||||
$GLOBALS['api_request'] = $request;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// API v1 Routes
|
||||
|
||||
// Users API
|
||||
$router->get('/api/v1/users', function() use ($usersApiController) {
|
||||
$usersApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/users/(\d+)', function($id) use ($usersApiController) {
|
||||
$usersApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/users', function() use ($usersApiController) {
|
||||
$usersApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/users/(\d+)', function($id) use ($usersApiController) {
|
||||
$usersApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/users/(\d+)', function($id) use ($usersApiController) {
|
||||
$usersApiController->delete($id);
|
||||
});
|
||||
|
||||
// Transactions API
|
||||
$router->get('/api/v1/transactions', function() use ($transactionsApiController) {
|
||||
$transactionsApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/transactions/(\d+)', function($id) use ($transactionsApiController) {
|
||||
$transactionsApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/transactions', function() use ($transactionsApiController) {
|
||||
$transactionsApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/transactions/(\d+)', function($id) use ($transactionsApiController) {
|
||||
$transactionsApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/transactions/(\d+)', function($id) use ($transactionsApiController) {
|
||||
$transactionsApiController->delete($id);
|
||||
});
|
||||
|
||||
// Credit Cards API
|
||||
$router->get('/api/v1/credit-cards', function() use ($creditCardsApiController) {
|
||||
$creditCardsApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/credit-cards/(\d+)', function($id) use ($creditCardsApiController) {
|
||||
$creditCardsApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/credit-cards', function() use ($creditCardsApiController) {
|
||||
$creditCardsApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/credit-cards/(\d+)', function($id) use ($creditCardsApiController) {
|
||||
$creditCardsApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/credit-cards/(\d+)', function($id) use ($creditCardsApiController) {
|
||||
$creditCardsApiController->delete($id);
|
||||
});
|
||||
|
||||
// Subscriptions API
|
||||
$router->get('/api/v1/subscriptions', function() use ($subscriptionsApiController) {
|
||||
$subscriptionsApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/subscriptions/(\d+)', function($id) use ($subscriptionsApiController) {
|
||||
$subscriptionsApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/subscriptions', function() use ($subscriptionsApiController) {
|
||||
$subscriptionsApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/subscriptions/(\d+)', function($id) use ($subscriptionsApiController) {
|
||||
$subscriptionsApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/subscriptions/(\d+)', function($id) use ($subscriptionsApiController) {
|
||||
$subscriptionsApiController->delete($id);
|
||||
});
|
||||
|
||||
// Expenses API
|
||||
$router->get('/api/v1/expenses', function() use ($expensesApiController) {
|
||||
$expensesApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/expenses/(\d+)', function($id) use ($expensesApiController) {
|
||||
$expensesApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/expenses', function() use ($expensesApiController) {
|
||||
$expensesApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/expenses/(\d+)', function($id) use ($expensesApiController) {
|
||||
$expensesApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/expenses/(\d+)', function($id) use ($expensesApiController) {
|
||||
$expensesApiController->delete($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/expenses/(\d+)/approve', function($id) use ($expensesApiController) {
|
||||
$expensesApiController->approve($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/expenses/(\d+)/reject', function($id) use ($expensesApiController) {
|
||||
$expensesApiController->reject($id);
|
||||
});
|
||||
|
||||
// Categories API
|
||||
$router->get('/api/v1/categories', function() use ($categoriesApiController) {
|
||||
$categoriesApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/categories/(\d+)', function($id) use ($categoriesApiController) {
|
||||
$categoriesApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/categories', function() use ($categoriesApiController) {
|
||||
$categoriesApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/categories/(\d+)', function($id) use ($categoriesApiController) {
|
||||
$categoriesApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/categories/(\d+)', function($id) use ($categoriesApiController) {
|
||||
$categoriesApiController->delete($id);
|
||||
});
|
||||
|
||||
$router->get('/api/v1/categories/popular', function() use ($categoriesApiController) {
|
||||
$categoriesApiController->popular();
|
||||
});
|
||||
|
||||
// Tags API
|
||||
$router->get('/api/v1/tags', function() use ($tagsApiController) {
|
||||
$tagsApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/tags/(\d+)', function($id) use ($tagsApiController) {
|
||||
$tagsApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/tags', function() use ($tagsApiController) {
|
||||
$tagsApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/tags/(\d+)', function($id) use ($tagsApiController) {
|
||||
$tagsApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/tags/(\d+)', function($id) use ($tagsApiController) {
|
||||
$tagsApiController->delete($id);
|
||||
});
|
||||
|
||||
$router->get('/api/v1/tags/popular', function() use ($tagsApiController) {
|
||||
$tagsApiController->popular();
|
||||
});
|
||||
|
||||
// Bank Accounts API
|
||||
$router->get('/api/v1/bank-accounts', function() use ($bankAccountsApiController) {
|
||||
$bankAccountsApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/bank-accounts/(\d+)', function($id) use ($bankAccountsApiController) {
|
||||
$bankAccountsApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/bank-accounts', function() use ($bankAccountsApiController) {
|
||||
$bankAccountsApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/bank-accounts/(\d+)', function($id) use ($bankAccountsApiController) {
|
||||
$bankAccountsApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/bank-accounts/(\d+)', function($id) use ($bankAccountsApiController) {
|
||||
$bankAccountsApiController->delete($id);
|
||||
});
|
||||
|
||||
$router->get('/api/v1/bank-accounts/by-currency/(\w+)', function($currency) use ($bankAccountsApiController) {
|
||||
$bankAccountsApiController->byCurrency($currency);
|
||||
});
|
||||
|
||||
// Crypto Wallets API
|
||||
$router->get('/api/v1/crypto-wallets', function() use ($cryptoWalletsApiController) {
|
||||
$cryptoWalletsApiController->index();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/crypto-wallets/(\d+)', function($id) use ($cryptoWalletsApiController) {
|
||||
$cryptoWalletsApiController->show($id);
|
||||
});
|
||||
|
||||
$router->post('/api/v1/crypto-wallets', function() use ($cryptoWalletsApiController) {
|
||||
$cryptoWalletsApiController->store();
|
||||
});
|
||||
|
||||
$router->put('/api/v1/crypto-wallets/(\d+)', function($id) use ($cryptoWalletsApiController) {
|
||||
$cryptoWalletsApiController->update($id);
|
||||
});
|
||||
|
||||
$router->delete('/api/v1/crypto-wallets/(\d+)', function($id) use ($cryptoWalletsApiController) {
|
||||
$cryptoWalletsApiController->delete($id);
|
||||
});
|
||||
|
||||
$router->get('/api/v1/crypto-wallets/by-currency/(\w+)', function($currency) use ($cryptoWalletsApiController) {
|
||||
$cryptoWalletsApiController->byCurrency($currency);
|
||||
});
|
||||
|
||||
$router->get('/api/v1/crypto-wallets/by-network/(\w+)', function($network) use ($cryptoWalletsApiController) {
|
||||
$cryptoWalletsApiController->byNetwork($network);
|
||||
});
|
||||
|
||||
// Reports API
|
||||
$router->get('/api/v1/reports/dashboard', function() use ($reportsApiController) {
|
||||
$reportsApiController->dashboard();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/reports/expenses', function() use ($reportsApiController) {
|
||||
$reportsApiController->expenses();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/reports/subscriptions', function() use ($reportsApiController) {
|
||||
$reportsApiController->subscriptions();
|
||||
});
|
||||
|
||||
$router->get('/api/v1/reports/export', function() use ($reportsApiController) {
|
||||
$reportsApiController->export();
|
||||
});
|
||||
|
||||
// API Documentation (only in debug mode)
|
||||
$router->get('/api/docs', function() {
|
||||
// Check if debug mode is enabled
|
||||
$isDebug = Config::get('debug', false) || ($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||
|
||||
if (!$isDebug) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Not Found', 'message' => 'API documentation is not available in production mode']);
|
||||
exit();
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate Swagger documentation
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
// Set up OpenAPI generator with enhanced configuration
|
||||
$openapi = \OpenApi\Generator::scan([
|
||||
__DIR__ . '/../Controllers/Api/',
|
||||
], [
|
||||
'exclude' => [],
|
||||
'pattern' => '*.php',
|
||||
'bootstrap' => null,
|
||||
'validate' => false // Disable validation for now to prevent errors
|
||||
]);
|
||||
|
||||
// Enhance the OpenAPI spec with additional information
|
||||
$openapi->info->title = 'Accounting Panel API';
|
||||
$openapi->info->version = '1.0.0';
|
||||
$openapi->info->description = 'API for Accounting Panel - Manage users, subscriptions, credit cards, transactions, expenses, and more.';
|
||||
|
||||
// Add server configuration
|
||||
$baseUrl = ($_ENV['APP_URL'] ?? 'http://localhost');
|
||||
$openapi->servers = [
|
||||
new \OpenApi\Annotations\Server([
|
||||
'url' => $baseUrl,
|
||||
'description' => 'API Server'
|
||||
])
|
||||
];
|
||||
|
||||
header('Content-Type: application/json');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key');
|
||||
|
||||
echo $openapi->toJson(JSON_PRETTY_PRINT);
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Swagger generation failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'error' => 'Internal Server Error',
|
||||
'message' => 'Failed to generate API documentation: ' . $e->getMessage(),
|
||||
'debug' => $isDebug ? $e->getTraceAsString() : null
|
||||
], JSON_PRETTY_PRINT);
|
||||
}
|
||||
});
|
||||
|
||||
// Swagger UI (only in debug mode)
|
||||
$router->get('/api/docs/ui', function() {
|
||||
// Check if debug mode is enabled
|
||||
$isDebug = Config::get('debug', false) || ($_ENV['APP_ENV'] ?? 'production') === 'development';
|
||||
|
||||
if (!$isDebug) {
|
||||
http_response_code(404);
|
||||
echo '<h1>404 Not Found</h1><p>API documentation is not available in production mode.</p>';
|
||||
exit();
|
||||
}
|
||||
|
||||
$swaggerUiHtml = '
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Accounting Panel API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@4.15.5/swagger-ui.css" />
|
||||
<style>
|
||||
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
||||
*, *:before, *:after { box-sizing: inherit; }
|
||||
body { margin:0; background: #fafafa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4.15.5/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@4.15.5/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/api/docs",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>';
|
||||
|
||||
echo $swaggerUiHtml;
|
||||
});
|
||||
|
||||
|
||||
486
app/Routes/web.php
Normal file
486
app/Routes/web.php
Normal file
@@ -0,0 +1,486 @@
|
||||
<?php
|
||||
|
||||
if (!defined('APP_RAN')) {
|
||||
die('Direct access not allowed');
|
||||
}
|
||||
|
||||
// Load controllers
|
||||
require_once __DIR__ . '/../Controllers/AuthController.php';
|
||||
require_once __DIR__ . '/../Controllers/DashboardController.php';
|
||||
require_once __DIR__ . '/../Controllers/CreditCardController.php';
|
||||
require_once __DIR__ . '/../Controllers/SubscriptionController.php';
|
||||
require_once __DIR__ . '/../Controllers/ReportController.php';
|
||||
require_once __DIR__ . '/../Controllers/UserController.php';
|
||||
require_once __DIR__ . '/../Controllers/ProfileController.php';
|
||||
require_once __DIR__ . '/../Controllers/CategoryController.php';
|
||||
require_once __DIR__ . '/../Controllers/TagController.php';
|
||||
require_once __DIR__ . '/../Controllers/BankAccountController.php';
|
||||
require_once __DIR__ . '/../Controllers/CryptoWalletController.php';
|
||||
require_once __DIR__ . '/../Controllers/ExpenseController.php';
|
||||
|
||||
// Instantiate controllers
|
||||
$authController = new AuthController($database);
|
||||
$dashboardController = new DashboardController($database);
|
||||
$creditCardController = new CreditCardController($database);
|
||||
$subscriptionController = new SubscriptionController($database);
|
||||
$reportController = new ReportController($database);
|
||||
$userController = new UserController($database);
|
||||
$profileController = new ProfileController($database);
|
||||
$categoryController = new CategoryController($database);
|
||||
$tagController = new TagController($database);
|
||||
$bankAccountController = new BankAccountController($database);
|
||||
$cryptoWalletController = new CryptoWalletController($database);
|
||||
$expenseController = new ExpenseController($database);
|
||||
|
||||
// Global middleware for all routes
|
||||
$router->before('GET|POST', '/.*', function() {
|
||||
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
AppLogger::info('Request received', [
|
||||
'method' => $method,
|
||||
'uri' => $uri,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
// Handle middleware for this route
|
||||
$result = Middleware::handleRoute($uri, $method);
|
||||
|
||||
AppLogger::info('Web Middleware result', [
|
||||
'uri' => $uri,
|
||||
'result' => $result ? 'passed' : 'failed',
|
||||
'session_id' => session_id(), // Now safe to call after middleware
|
||||
'session_data_exists' => !empty($_SESSION),
|
||||
'session_user_exists' => isset($_SESSION['user'])
|
||||
]);
|
||||
|
||||
if (!$result) {
|
||||
// Middleware handled the response (redirect, error, etc.)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
// Public routes (no authentication required)
|
||||
$router->get('/login', function() use ($authController) {
|
||||
$authController->showLoginForm();
|
||||
});
|
||||
|
||||
$router->post('/login', function() use ($authController) {
|
||||
$authController->login();
|
||||
});
|
||||
|
||||
// 2FA Routes
|
||||
$router->get('/2fa/verify', function() use ($authController) {
|
||||
$authController->show2FAForm();
|
||||
});
|
||||
|
||||
$router->post('/2fa/verify', function() use ($authController) {
|
||||
$authController->verify2FA();
|
||||
});
|
||||
|
||||
// Protected routes (authentication required)
|
||||
$router->get('/logout', function() use ($authController) {
|
||||
$authController->logout();
|
||||
});
|
||||
|
||||
$router->get('/', function() use ($dashboardController) {
|
||||
$dashboardController->index();
|
||||
});
|
||||
|
||||
// AJAX Routes
|
||||
$router->get('/ajax/stats', function() use ($dashboardController) {
|
||||
$dashboardController->ajaxStats();
|
||||
});
|
||||
|
||||
// Credit Card Routes
|
||||
$router->get('/credit-cards', function() use ($creditCardController) {
|
||||
$creditCardController->index();
|
||||
});
|
||||
|
||||
$router->get('/credit-cards/create', function() use ($creditCardController) {
|
||||
$creditCardController->create();
|
||||
});
|
||||
|
||||
$router->post('/credit-cards', function() use ($creditCardController) {
|
||||
$creditCardController->store();
|
||||
});
|
||||
|
||||
$router->get('/credit-cards/(\d+)/edit', function($id) use ($creditCardController) {
|
||||
$creditCardController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/credit-cards/(\d+)', function($id) use ($creditCardController) {
|
||||
$creditCardController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/credit-cards/(\d+)/delete', function($id) use ($creditCardController) {
|
||||
$creditCardController->delete($id);
|
||||
});
|
||||
|
||||
// Subscription Routes
|
||||
$router->get('/subscriptions', function() use ($subscriptionController) {
|
||||
$subscriptionController->index();
|
||||
});
|
||||
|
||||
$router->get('/subscriptions/create', function() use ($subscriptionController) {
|
||||
$subscriptionController->create();
|
||||
});
|
||||
|
||||
$router->post('/subscriptions', function() use ($subscriptionController) {
|
||||
$subscriptionController->store();
|
||||
});
|
||||
|
||||
$router->get('/subscriptions/(\d+)/edit', function($id) use ($subscriptionController) {
|
||||
$subscriptionController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/subscriptions/(\d+)', function($id) use ($subscriptionController) {
|
||||
$subscriptionController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/subscriptions/(\d+)/delete', function($id) use ($subscriptionController) {
|
||||
$subscriptionController->delete($id);
|
||||
});
|
||||
|
||||
// Report Routes
|
||||
$router->get('/reports', function() use ($reportController) {
|
||||
$reportController->index();
|
||||
});
|
||||
|
||||
$router->get('/reports/export', function() use ($reportController) {
|
||||
$reportController->export();
|
||||
});
|
||||
|
||||
// User Management Routes
|
||||
$router->get('/users', function() use ($userController) {
|
||||
$userController->index();
|
||||
});
|
||||
|
||||
$router->get('/users/create', function() use ($userController) {
|
||||
$userController->create();
|
||||
});
|
||||
|
||||
$router->post('/users', function() use ($userController) {
|
||||
$userController->store();
|
||||
});
|
||||
|
||||
$router->get('/users/(\d+)/edit', function($id) use ($userController) {
|
||||
$userController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/users/(\d+)', function($id) use ($userController) {
|
||||
$userController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/users/(\d+)/delete', function($id) use ($userController) {
|
||||
$userController->delete($id);
|
||||
});
|
||||
|
||||
// Category Routes
|
||||
$router->get('/categories', function() use ($categoryController) {
|
||||
$categoryController->index();
|
||||
});
|
||||
|
||||
$router->get('/categories/create', function() use ($categoryController) {
|
||||
$categoryController->create();
|
||||
});
|
||||
|
||||
$router->post('/categories', function() use ($categoryController) {
|
||||
$categoryController->store();
|
||||
});
|
||||
|
||||
$router->get('/categories/(\d+)/edit', function($id) use ($categoryController) {
|
||||
$categoryController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/categories/(\d+)', function($id) use ($categoryController) {
|
||||
$categoryController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/categories/(\d+)/delete', function($id) use ($categoryController) {
|
||||
$categoryController->delete($id);
|
||||
});
|
||||
|
||||
$router->post('/categories/create-defaults', function() use ($categoryController) {
|
||||
$categoryController->createDefaults();
|
||||
});
|
||||
|
||||
$router->get('/categories/ajax/list', function() use ($categoryController) {
|
||||
$categoryController->ajaxList();
|
||||
});
|
||||
|
||||
$router->get('/categories/search', function() use ($categoryController) {
|
||||
$categoryController->search();
|
||||
});
|
||||
|
||||
$router->post('/categories/quick-create', function() use ($categoryController) {
|
||||
$categoryController->quickCreate();
|
||||
});
|
||||
|
||||
$router->get('/categories/popular', function() use ($categoryController) {
|
||||
$categoryController->popular();
|
||||
});
|
||||
|
||||
// Tag Routes
|
||||
$router->get('/tags', function() use ($tagController) {
|
||||
$tagController->index();
|
||||
});
|
||||
|
||||
$router->get('/tags/create', function() use ($tagController) {
|
||||
$tagController->create();
|
||||
});
|
||||
|
||||
$router->post('/tags', function() use ($tagController) {
|
||||
$tagController->store();
|
||||
});
|
||||
|
||||
$router->get('/tags/(\d+)/edit', function($id) use ($tagController) {
|
||||
$tagController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/tags/(\d+)', function($id) use ($tagController) {
|
||||
$tagController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/tags/(\d+)/delete', function($id) use ($tagController) {
|
||||
$tagController->delete($id);
|
||||
});
|
||||
|
||||
$router->post('/tags/create-defaults', function() use ($tagController) {
|
||||
$tagController->createDefaults();
|
||||
});
|
||||
|
||||
$router->get('/tags/ajax/list', function() use ($tagController) {
|
||||
$tagController->ajaxList();
|
||||
});
|
||||
|
||||
$router->get('/tags/search', function() use ($tagController) {
|
||||
$tagController->search();
|
||||
});
|
||||
|
||||
$router->post('/tags/quick-create', function() use ($tagController) {
|
||||
$tagController->quickCreate();
|
||||
});
|
||||
|
||||
$router->get('/tags/popular', function() use ($tagController) {
|
||||
$tagController->popular();
|
||||
});
|
||||
|
||||
// Bank Account Routes
|
||||
$router->get('/bank-accounts', function() use ($bankAccountController) {
|
||||
$bankAccountController->index();
|
||||
});
|
||||
|
||||
$router->get('/bank-accounts/create', function() use ($bankAccountController) {
|
||||
$bankAccountController->create();
|
||||
});
|
||||
|
||||
$router->post('/bank-accounts', function() use ($bankAccountController) {
|
||||
$bankAccountController->store();
|
||||
});
|
||||
|
||||
$router->get('/bank-accounts/(\d+)/edit', function($id) use ($bankAccountController) {
|
||||
$bankAccountController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/bank-accounts/(\d+)', function($id) use ($bankAccountController) {
|
||||
$bankAccountController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/bank-accounts/(\d+)/delete', function($id) use ($bankAccountController) {
|
||||
$bankAccountController->delete($id);
|
||||
});
|
||||
|
||||
$router->get('/bank-accounts/ajax/list', function() use ($bankAccountController) {
|
||||
$bankAccountController->ajaxList();
|
||||
});
|
||||
|
||||
$router->get('/bank-accounts/(\d+)/details', function($id) use ($bankAccountController) {
|
||||
$bankAccountController->details($id);
|
||||
});
|
||||
|
||||
$router->get('/bank-accounts/search', function() use ($bankAccountController) {
|
||||
$bankAccountController->search();
|
||||
});
|
||||
|
||||
$router->get('/bank-accounts/by-currency/(\w+)', function($currency) use ($bankAccountController) {
|
||||
$bankAccountController->byCurrency($currency);
|
||||
});
|
||||
|
||||
$router->post('/bank-accounts/validate', function() use ($bankAccountController) {
|
||||
$bankAccountController->validateAccount();
|
||||
});
|
||||
|
||||
// Crypto Wallet Routes
|
||||
$router->get('/crypto-wallets', function() use ($cryptoWalletController) {
|
||||
$cryptoWalletController->index();
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/create', function() use ($cryptoWalletController) {
|
||||
$cryptoWalletController->create();
|
||||
});
|
||||
|
||||
$router->post('/crypto-wallets', function() use ($cryptoWalletController) {
|
||||
$cryptoWalletController->store();
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/(\d+)/edit', function($id) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/crypto-wallets/(\d+)', function($id) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/crypto-wallets/(\d+)/delete', function($id) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->delete($id);
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/ajax/list', function() use ($cryptoWalletController) {
|
||||
$cryptoWalletController->ajaxList();
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/(\d+)/details', function($id) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->details($id);
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/search', function() use ($cryptoWalletController) {
|
||||
$cryptoWalletController->search();
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/by-currency/(\w+)', function($currency) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->byCurrency($currency);
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/by-network/(\w+)', function($network) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->byNetwork($network);
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/currencies-for-network/(\w+)', function($network) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->getCurrenciesForNetwork($network);
|
||||
});
|
||||
|
||||
$router->get('/crypto-wallets/networks-for-currency/(\w+)', function($currency) use ($cryptoWalletController) {
|
||||
$cryptoWalletController->getNetworksForCurrency($currency);
|
||||
});
|
||||
|
||||
$router->post('/crypto-wallets/validate-address', function() use ($cryptoWalletController) {
|
||||
$cryptoWalletController->validateAddress();
|
||||
});
|
||||
|
||||
// Expense Routes
|
||||
$router->get('/expenses', function() use ($expenseController) {
|
||||
$expenseController->index();
|
||||
});
|
||||
|
||||
$router->get('/expenses/create', function() use ($expenseController) {
|
||||
$expenseController->create();
|
||||
});
|
||||
|
||||
$router->post('/expenses', function() use ($expenseController) {
|
||||
$expenseController->store();
|
||||
});
|
||||
|
||||
$router->get('/expenses/(\d+)', function($id) use ($expenseController) {
|
||||
$expenseController->show($id);
|
||||
});
|
||||
|
||||
$router->get('/expenses/(\d+)/edit', function($id) use ($expenseController) {
|
||||
$expenseController->edit($id);
|
||||
});
|
||||
|
||||
$router->post('/expenses/(\d+)', function($id) use ($expenseController) {
|
||||
$expenseController->update($id);
|
||||
});
|
||||
|
||||
$router->post('/expenses/(\d+)/delete', function($id) use ($expenseController) {
|
||||
$expenseController->delete($id);
|
||||
});
|
||||
|
||||
$router->get('/expenses/import', function() use ($expenseController) {
|
||||
$expenseController->import();
|
||||
});
|
||||
|
||||
$router->post('/expenses/import', function() use ($expenseController) {
|
||||
$expenseController->processImport();
|
||||
});
|
||||
|
||||
$router->get('/expenses/export', function() use ($expenseController) {
|
||||
$expenseController->export();
|
||||
});
|
||||
|
||||
$router->post('/expenses/(\d+)/approve', function($id) use ($expenseController) {
|
||||
$expenseController->approve($id);
|
||||
});
|
||||
|
||||
$router->post('/expenses/(\d+)/reject', function($id) use ($expenseController) {
|
||||
$expenseController->reject($id);
|
||||
});
|
||||
|
||||
$router->get('/expenses/analytics', function() use ($expenseController) {
|
||||
$expenseController->analytics();
|
||||
});
|
||||
|
||||
$router->get('/expenses/(\d+)/download-attachment', function($id) use ($expenseController) {
|
||||
$expenseController->downloadAttachment($id);
|
||||
});
|
||||
|
||||
$router->get('/expenses/download-template', function() use ($expenseController) {
|
||||
$expenseController->downloadTemplate();
|
||||
});
|
||||
|
||||
// Profile Routes
|
||||
$router->get('/profile/edit', function() use ($profileController) {
|
||||
$profileController->edit();
|
||||
});
|
||||
|
||||
$router->post('/profile/update', function() use ($profileController) {
|
||||
$profileController->update();
|
||||
});
|
||||
|
||||
// 2FA Profile Routes
|
||||
$router->get('/profile/2fa/setup', function() use ($profileController) {
|
||||
$profileController->setup2FA();
|
||||
});
|
||||
|
||||
$router->post('/profile/2fa/enable', function() use ($profileController) {
|
||||
$profileController->enable2FA();
|
||||
});
|
||||
|
||||
$router->post('/profile/2fa/disable', function() use ($profileController) {
|
||||
$profileController->disable2FA();
|
||||
});
|
||||
|
||||
$router->post('/profile/2fa/regenerate-backup-codes', function() use ($profileController) {
|
||||
$profileController->regenerateBackupCodes();
|
||||
});
|
||||
|
||||
// API Key Management Routes
|
||||
$router->get('/profile/api-keys', function() use ($profileController) {
|
||||
$profileController->apiKeys();
|
||||
});
|
||||
|
||||
$router->post('/profile/api-keys/create', function() use ($profileController) {
|
||||
$profileController->createApiKey();
|
||||
});
|
||||
|
||||
$router->post('/profile/api-keys/(\d+)/delete', function($keyId) use ($profileController) {
|
||||
$profileController->deleteApiKey($keyId);
|
||||
});
|
||||
|
||||
// Include API routes
|
||||
require_once __DIR__ . '/api.php';
|
||||
|
||||
// 404 Handler
|
||||
$router->set404(function() use ($errorController) {
|
||||
AppLogger::warning('404 Not Found', [
|
||||
'uri' => $_SERVER['REQUEST_URI'],
|
||||
'method' => $_SERVER['REQUEST_METHOD'],
|
||||
'referer' => $_SERVER['HTTP_REFERER'] ?? 'none'
|
||||
]);
|
||||
|
||||
$errorController->notFound();
|
||||
});
|
||||
282
app/Services/ApiMiddleware.php
Normal file
282
app/Services/ApiMiddleware.php
Normal file
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../Models/ApiKey.php';
|
||||
require_once __DIR__ . '/Logger.php';
|
||||
require_once __DIR__ . '/Config.php';
|
||||
|
||||
class ApiMiddleware {
|
||||
|
||||
private static $apiKeyModel = null;
|
||||
|
||||
/**
|
||||
* Initialize API middleware
|
||||
*/
|
||||
public static function init($database) {
|
||||
self::$apiKeyModel = new ApiKey($database);
|
||||
}
|
||||
|
||||
/**
|
||||
* API Authentication middleware
|
||||
*/
|
||||
public static function authenticate($request) {
|
||||
AppLogger::debug('API Authentication started', [
|
||||
'uri' => $request['uri'] ?? 'unknown',
|
||||
'method' => $request['method'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
// Get API key from Authorization header or query parameter
|
||||
$apiKey = self::extractApiKey($request);
|
||||
|
||||
if (!$apiKey) {
|
||||
AppLogger::warning('API authentication failed - no API key provided');
|
||||
return self::unauthorizedResponse('API key is required');
|
||||
}
|
||||
|
||||
AppLogger::debug('API Key extracted, validating...', [
|
||||
'key_prefix' => substr($apiKey, 0, 8),
|
||||
'key_length' => strlen($apiKey)
|
||||
]);
|
||||
|
||||
// Validate API key
|
||||
$keyData = self::$apiKeyModel->validateApiKey($apiKey);
|
||||
|
||||
if (!$keyData) {
|
||||
AppLogger::warning('API key validation failed', [
|
||||
'key_prefix' => substr($apiKey, 0, 8)
|
||||
]);
|
||||
// Record failed attempt if we can identify the key
|
||||
self::recordFailedAttempt($apiKey);
|
||||
return self::unauthorizedResponse('Invalid API key');
|
||||
}
|
||||
|
||||
AppLogger::debug('API key validated successfully', [
|
||||
'key_id' => $keyData['id'],
|
||||
'user_id' => $keyData['user_id'],
|
||||
'key_name' => $keyData['name']
|
||||
]);
|
||||
|
||||
// Check rate limit
|
||||
if (!self::$apiKeyModel->checkRateLimit($keyData['id'])) {
|
||||
AppLogger::warning('API rate limit exceeded', [
|
||||
'key_id' => $keyData['id']
|
||||
]);
|
||||
return self::rateLimitResponse();
|
||||
}
|
||||
|
||||
// Record successful usage
|
||||
self::$apiKeyModel->recordUsage($keyData['id']);
|
||||
|
||||
// Store API key data in request for later use
|
||||
$request['api_key'] = $keyData;
|
||||
|
||||
AppLogger::debug('API authentication completed successfully');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check API permission middleware
|
||||
*/
|
||||
public static function checkPermission($permission) {
|
||||
return function($request) use ($permission) {
|
||||
if (!isset($request['api_key'])) {
|
||||
return self::unauthorizedResponse('API key not found in request');
|
||||
}
|
||||
|
||||
if (!self::$apiKeyModel->hasPermission($request['api_key'], $permission)) {
|
||||
return self::forbiddenResponse("Permission '{$permission}' is required");
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API CORS middleware
|
||||
*/
|
||||
public static function cors($request) {
|
||||
// Set CORS headers for API
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
|
||||
// Handle preflight requests
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
http_response_code(200);
|
||||
exit();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Security headers middleware
|
||||
*/
|
||||
public static function securityHeaders($request) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('X-XSS-Protection: 1; mode=block');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
header('Cache-Control: no-cache, no-store, must-revalidate');
|
||||
header('Pragma: no-cache');
|
||||
header('Expires: 0');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract API key from request
|
||||
*/
|
||||
private static function extractApiKey($request) {
|
||||
// Log headers for debugging
|
||||
AppLogger::debug('API Key extraction - Headers received', [
|
||||
'headers' => $request['headers'],
|
||||
'header_keys' => array_keys($request['headers']),
|
||||
'uri' => $request['uri'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
// Check Authorization header (Bearer token)
|
||||
if (isset($request['headers']['Authorization'])) {
|
||||
$authHeader = $request['headers']['Authorization'];
|
||||
if (preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
|
||||
AppLogger::debug('API Key found in Authorization header (Bearer)');
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-API-Key header (case-insensitive)
|
||||
foreach ($request['headers'] as $name => $value) {
|
||||
if (strtolower($name) === 'x-api-key') {
|
||||
AppLogger::debug('API Key found in X-API-Key header', [
|
||||
'header_name' => $name,
|
||||
'key_prefix' => substr($value, 0, 8)
|
||||
]);
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check query parameter (less secure, but sometimes necessary)
|
||||
if (isset($request['query']['api_key'])) {
|
||||
AppLogger::debug('API Key found in query parameter');
|
||||
return $request['query']['api_key'];
|
||||
}
|
||||
|
||||
AppLogger::warning('No API key found in request', [
|
||||
'uri' => $request['uri'] ?? 'unknown',
|
||||
'headers' => array_keys($request['headers'])
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record failed API key attempt
|
||||
*/
|
||||
private static function recordFailedAttempt($rawKey) {
|
||||
if (empty($rawKey) || !str_starts_with($rawKey, 'ak_')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hashedKey = hash('sha256', $rawKey);
|
||||
$keyPrefix = substr($rawKey, 0, 8);
|
||||
|
||||
// Find the key even if it's invalid to record the attempt
|
||||
$apiKey = self::$apiKeyModel->db->get('api_keys', 'id', [
|
||||
'api_key' => $hashedKey,
|
||||
'api_key_prefix' => $keyPrefix
|
||||
]);
|
||||
|
||||
if ($apiKey) {
|
||||
self::$apiKeyModel->recordFailedAttempt($apiKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return unauthorized response
|
||||
*/
|
||||
private static function unauthorizedResponse($message = 'Unauthorized') {
|
||||
http_response_code(401);
|
||||
echo json_encode([
|
||||
'error' => 'Unauthorized',
|
||||
'message' => $message,
|
||||
'timestamp' => date('c')
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return forbidden response
|
||||
*/
|
||||
private static function forbiddenResponse($message = 'Forbidden') {
|
||||
http_response_code(403);
|
||||
echo json_encode([
|
||||
'error' => 'Forbidden',
|
||||
'message' => $message,
|
||||
'timestamp' => date('c')
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return rate limit response
|
||||
*/
|
||||
private static function rateLimitResponse() {
|
||||
http_response_code(429);
|
||||
echo json_encode([
|
||||
'error' => 'Rate Limit Exceeded',
|
||||
'message' => 'Too many requests. Please try again later.',
|
||||
'timestamp' => date('c')
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API middleware stack
|
||||
*/
|
||||
public static function handle($middlewares, $request) {
|
||||
foreach ($middlewares as $middleware) {
|
||||
if (is_callable($middleware)) {
|
||||
$result = $middleware($request);
|
||||
} elseif (method_exists(self::class, $middleware)) {
|
||||
$result = self::$middleware($request);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($result !== true) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current API request information
|
||||
*/
|
||||
public static function getCurrentRequest() {
|
||||
return [
|
||||
'uri' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
|
||||
'method' => $_SERVER['REQUEST_METHOD'],
|
||||
'query' => $_GET,
|
||||
'body' => self::getRequestBody(),
|
||||
'headers' => getallheaders() ?: []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request body (handles JSON)
|
||||
*/
|
||||
private static function getRequestBody() {
|
||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||
|
||||
if (strpos($contentType, 'application/json') !== false) {
|
||||
$rawBody = file_get_contents('php://input');
|
||||
return json_decode($rawBody, true) ?: [];
|
||||
}
|
||||
|
||||
return $_POST;
|
||||
}
|
||||
}
|
||||
146
app/Services/Config.php
Normal file
146
app/Services/Config.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
class Config
|
||||
{
|
||||
private static $config = null;
|
||||
private static $loaded = false;
|
||||
|
||||
/**
|
||||
* Load configuration from files
|
||||
*/
|
||||
public static function load()
|
||||
{
|
||||
if (self::$loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load environment variables
|
||||
self::loadEnvironment();
|
||||
|
||||
// Validate required environment variables
|
||||
self::validateEnvironment();
|
||||
|
||||
// Load configuration files
|
||||
self::$config = require __DIR__ . '/../../config/app.php';
|
||||
|
||||
self::$loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value using dot notation
|
||||
*/
|
||||
public static function get($key, $default = null)
|
||||
{
|
||||
if (!self::$loaded) {
|
||||
self::load();
|
||||
}
|
||||
|
||||
$keys = explode('.', $key);
|
||||
$value = self::$config;
|
||||
|
||||
foreach ($keys as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set configuration value
|
||||
*/
|
||||
public static function set($key, $value)
|
||||
{
|
||||
if (!self::$loaded) {
|
||||
self::load();
|
||||
}
|
||||
|
||||
$keys = explode('.', $key);
|
||||
$config = &self::$config;
|
||||
|
||||
foreach ($keys as $segment) {
|
||||
if (!isset($config[$segment]) || !is_array($config[$segment])) {
|
||||
$config[$segment] = [];
|
||||
}
|
||||
$config = &$config[$segment];
|
||||
}
|
||||
|
||||
$config = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configuration
|
||||
*/
|
||||
public static function all()
|
||||
{
|
||||
if (!self::$loaded) {
|
||||
self::load();
|
||||
}
|
||||
|
||||
return self::$config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables
|
||||
*/
|
||||
private static function loadEnvironment()
|
||||
{
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
try {
|
||||
$dotenv = \Dotenv\Dotenv::createImmutable(__DIR__ . '/../..');
|
||||
$dotenv->load();
|
||||
} catch (\Dotenv\Exception\InvalidPathException $e) {
|
||||
self::handleConfigError(
|
||||
'Environment Configuration Error',
|
||||
'The .env file is missing. Please copy .env.example to .env and configure your environment variables.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
*/
|
||||
private static function validateEnvironment()
|
||||
{
|
||||
$required = [
|
||||
'DB_HOST',
|
||||
'DB_NAME',
|
||||
'DB_USER',
|
||||
'DB_PASS',
|
||||
'APP_DOMAIN'
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
foreach ($required as $key) {
|
||||
if (!isset($_ENV[$key]) || empty($_ENV[$key])) {
|
||||
$missing[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missing)) {
|
||||
self::handleConfigError(
|
||||
'Environment Validation Error',
|
||||
'The following required environment variables are missing or empty: ' . implode(', ', $missing)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle configuration errors
|
||||
*/
|
||||
private static function handleConfigError($title, $message)
|
||||
{
|
||||
http_response_code(500);
|
||||
|
||||
if (php_sapi_name() === 'cli') {
|
||||
echo "ERROR: {$title}\n{$message}\n";
|
||||
} else {
|
||||
echo "<h1>{$title}</h1><p>{$message}</p>";
|
||||
}
|
||||
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
427
app/Services/CronScheduler.php
Normal file
427
app/Services/CronScheduler.php
Normal file
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/ScheduleService.php';
|
||||
require_once __DIR__ . '/ErrorHandler.php';
|
||||
require_once __DIR__ . '/Logger.php';
|
||||
require_once __DIR__ . '/Config.php';
|
||||
|
||||
class CronScheduler {
|
||||
|
||||
private $db;
|
||||
private $scheduleService;
|
||||
private $lockFile;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->db = $db;
|
||||
$this->scheduleService = new ScheduleService($db);
|
||||
$this->lockFile = __DIR__ . '/../../sessions/cron.lock';
|
||||
}
|
||||
|
||||
/**
|
||||
* Main cron runner - called every minute
|
||||
*/
|
||||
public function run() {
|
||||
// Prevent multiple instances running at the same time
|
||||
if (!$this->acquireLock()) {
|
||||
AppLogger::info("Cron job already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$currentTime = new DateTime();
|
||||
$minute = (int) $currentTime->format('i');
|
||||
$hour = (int) $currentTime->format('H');
|
||||
$dayOfWeek = (int) $currentTime->format('w'); // 0 = Sunday
|
||||
|
||||
AppLogger::info("Cron scheduler started", [
|
||||
'time' => $currentTime->format('Y-m-d H:i:s'),
|
||||
'minute' => $minute,
|
||||
'hour' => $hour,
|
||||
'day_of_week' => $dayOfWeek
|
||||
]);
|
||||
|
||||
$tasksRun = [];
|
||||
|
||||
// Process due payments every day at 2:00 AM
|
||||
if ($hour === 2 && $minute === 0) {
|
||||
$tasksRun[] = $this->runDuePayments();
|
||||
}
|
||||
|
||||
// Handle expired subscriptions every day at 3:00 AM
|
||||
if ($hour === 3 && $minute === 0) {
|
||||
$tasksRun[] = $this->runExpiredSubscriptions();
|
||||
}
|
||||
|
||||
// Generate schedule statistics every day at 8:00 AM
|
||||
if ($hour === 8 && $minute === 0) {
|
||||
$tasksRun[] = $this->runScheduleStats();
|
||||
}
|
||||
|
||||
// Health check every hour at minute 0
|
||||
if ($minute === 0) {
|
||||
$tasksRun[] = $this->runHealthCheck();
|
||||
}
|
||||
|
||||
// Cleanup logs every Sunday at 4:00 AM
|
||||
if ($dayOfWeek === 0 && $hour === 4 && $minute === 0) {
|
||||
$tasksRun[] = $this->runLogCleanup();
|
||||
}
|
||||
|
||||
// Cleanup sessions every 6 hours at minute 30
|
||||
if ($minute === 30 && $hour % 6 === 0) {
|
||||
$tasksRun[] = $this->runSessionCleanup();
|
||||
}
|
||||
|
||||
if (!empty($tasksRun)) {
|
||||
AppLogger::info("Cron tasks completed", [
|
||||
'tasks_run' => $tasksRun
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Cron scheduler error", [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
} finally {
|
||||
$this->releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run due payments processing
|
||||
*/
|
||||
private function runDuePayments() {
|
||||
try {
|
||||
AppLogger::info("Starting due payments task");
|
||||
$result = $this->scheduleService->processDuePayments();
|
||||
|
||||
return [
|
||||
'task' => 'due_payments',
|
||||
'success' => true,
|
||||
'result' => $result
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Due payments task failed", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'due_payments',
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run expired subscriptions handling
|
||||
*/
|
||||
private function runExpiredSubscriptions() {
|
||||
try {
|
||||
AppLogger::info("Starting expired subscriptions task");
|
||||
$result = $this->scheduleService->handleExpiredSubscriptions();
|
||||
|
||||
return [
|
||||
'task' => 'expired_subscriptions',
|
||||
'success' => true,
|
||||
'result' => $result
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Expired subscriptions task failed", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'expired_subscriptions',
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run schedule statistics generation
|
||||
*/
|
||||
private function runScheduleStats() {
|
||||
try {
|
||||
AppLogger::info("Starting schedule stats task");
|
||||
$result = $this->scheduleService->getScheduleStats();
|
||||
|
||||
// Log the statistics
|
||||
AppLogger::info("Schedule statistics", $result);
|
||||
|
||||
return [
|
||||
'task' => 'schedule_stats',
|
||||
'success' => true,
|
||||
'result' => $result
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Schedule stats task failed", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'schedule_stats',
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run health check
|
||||
*/
|
||||
private function runHealthCheck() {
|
||||
try {
|
||||
$checks = [];
|
||||
|
||||
// Check database connection
|
||||
try {
|
||||
$this->db->query("SELECT 1");
|
||||
$checks['database'] = true;
|
||||
} catch (Exception $e) {
|
||||
$checks['database'] = false;
|
||||
AppLogger::error("Database health check failed", ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// Check log directory writability
|
||||
$logDir = __DIR__ . '/../../logs';
|
||||
$checks['logs_writable'] = is_writable($logDir);
|
||||
|
||||
// Check sessions directory writability
|
||||
$sessionDir = __DIR__ . '/../../sessions';
|
||||
$checks['sessions_writable'] = is_writable($sessionDir);
|
||||
|
||||
// Count active subscriptions
|
||||
$activeSubscriptions = $this->db->count('subscriptions', ['status' => 'active']);
|
||||
$checks['active_subscriptions'] = $activeSubscriptions;
|
||||
|
||||
AppLogger::info("Health check completed", $checks);
|
||||
|
||||
return [
|
||||
'task' => 'health_check',
|
||||
'success' => true,
|
||||
'result' => $checks
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Health check failed", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'health_check',
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run log cleanup (remove old log files)
|
||||
*/
|
||||
private function runLogCleanup() {
|
||||
try {
|
||||
$logDir = __DIR__ . '/../../logs';
|
||||
$daysToKeep = 30; // Keep logs for 30 days
|
||||
$cutoffDate = time() - ($daysToKeep * 24 * 60 * 60);
|
||||
|
||||
$cleanedFiles = 0;
|
||||
$totalSize = 0;
|
||||
|
||||
if (is_dir($logDir)) {
|
||||
$files = glob($logDir . '/*.log');
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file) && filemtime($file) < $cutoffDate) {
|
||||
$size = filesize($file);
|
||||
if (unlink($file)) {
|
||||
$cleanedFiles++;
|
||||
$totalSize += $size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger::info("Log cleanup completed", [
|
||||
'files_cleaned' => $cleanedFiles,
|
||||
'bytes_freed' => $totalSize,
|
||||
'days_kept' => $daysToKeep
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'log_cleanup',
|
||||
'success' => true,
|
||||
'result' => [
|
||||
'files_cleaned' => $cleanedFiles,
|
||||
'bytes_freed' => $totalSize
|
||||
]
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Log cleanup failed", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'log_cleanup',
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run session cleanup (remove old session files)
|
||||
*/
|
||||
private function runSessionCleanup() {
|
||||
try {
|
||||
$sessionDir = __DIR__ . '/../../sessions';
|
||||
$maxAge = 24 * 60 * 60; // 24 hours
|
||||
$cutoffTime = time() - $maxAge;
|
||||
|
||||
$cleanedFiles = 0;
|
||||
$totalSize = 0;
|
||||
|
||||
if (is_dir($sessionDir)) {
|
||||
$files = glob($sessionDir . '/sess_*');
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file) && filemtime($file) < $cutoffTime) {
|
||||
$size = filesize($file);
|
||||
if (unlink($file)) {
|
||||
$cleanedFiles++;
|
||||
$totalSize += $size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also clean up rate limit files
|
||||
$rateLimitFiles = glob($sessionDir . '/rate_limit_*');
|
||||
foreach ($rateLimitFiles as $file) {
|
||||
if (is_file($file) && filemtime($file) < $cutoffTime) {
|
||||
$size = filesize($file);
|
||||
if (unlink($file)) {
|
||||
$cleanedFiles++;
|
||||
$totalSize += $size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger::info("Session cleanup completed", [
|
||||
'files_cleaned' => $cleanedFiles,
|
||||
'bytes_freed' => $totalSize,
|
||||
'max_age_hours' => $maxAge / 3600
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'session_cleanup',
|
||||
'success' => true,
|
||||
'result' => [
|
||||
'files_cleaned' => $cleanedFiles,
|
||||
'bytes_freed' => $totalSize
|
||||
]
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Session cleanup failed", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'task' => 'session_cleanup',
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire lock to prevent multiple cron instances
|
||||
*/
|
||||
private function acquireLock() {
|
||||
$currentTime = time();
|
||||
|
||||
if (file_exists($this->lockFile)) {
|
||||
$lockTime = filemtime($this->lockFile);
|
||||
|
||||
// If lock is older than 5 minutes, consider it stale and remove it
|
||||
if ($currentTime - $lockTime > 300) {
|
||||
unlink($this->lockFile);
|
||||
AppLogger::warning("Removed stale cron lock file");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return file_put_contents($this->lockFile, $currentTime) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release lock
|
||||
*/
|
||||
private function releaseLock() {
|
||||
if (file_exists($this->lockFile)) {
|
||||
unlink($this->lockFile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next scheduled tasks (for debugging/monitoring)
|
||||
*/
|
||||
public function getUpcomingTasks() {
|
||||
$currentTime = new DateTime();
|
||||
$tasks = [];
|
||||
|
||||
// Calculate next due payments run (daily at 2:00 AM)
|
||||
$nextDuePayments = clone $currentTime;
|
||||
$nextDuePayments->setTime(2, 0, 0);
|
||||
if ($currentTime >= $nextDuePayments) {
|
||||
$nextDuePayments->add(new DateInterval('P1D'));
|
||||
}
|
||||
$tasks[] = [
|
||||
'name' => 'Process Due Payments',
|
||||
'next_run' => $nextDuePayments->format('Y-m-d H:i:s'),
|
||||
'frequency' => 'Daily at 2:00 AM'
|
||||
];
|
||||
|
||||
// Calculate next expired subscriptions run (daily at 3:00 AM)
|
||||
$nextExpired = clone $currentTime;
|
||||
$nextExpired->setTime(3, 0, 0);
|
||||
if ($currentTime >= $nextExpired) {
|
||||
$nextExpired->add(new DateInterval('P1D'));
|
||||
}
|
||||
$tasks[] = [
|
||||
'name' => 'Handle Expired Subscriptions',
|
||||
'next_run' => $nextExpired->format('Y-m-d H:i:s'),
|
||||
'frequency' => 'Daily at 3:00 AM'
|
||||
];
|
||||
|
||||
// Calculate next schedule stats run (daily at 8:00 AM)
|
||||
$nextStats = clone $currentTime;
|
||||
$nextStats->setTime(8, 0, 0);
|
||||
if ($currentTime >= $nextStats) {
|
||||
$nextStats->add(new DateInterval('P1D'));
|
||||
}
|
||||
$tasks[] = [
|
||||
'name' => 'Generate Schedule Statistics',
|
||||
'next_run' => $nextStats->format('Y-m-d H:i:s'),
|
||||
'frequency' => 'Daily at 8:00 AM'
|
||||
];
|
||||
|
||||
// Calculate next health check (hourly)
|
||||
$nextHealth = clone $currentTime;
|
||||
$nextHealth->setTime($currentTime->format('H'), 0, 0);
|
||||
$nextHealth->add(new DateInterval('PT1H'));
|
||||
$tasks[] = [
|
||||
'name' => 'Health Check',
|
||||
'next_run' => $nextHealth->format('Y-m-d H:i:s'),
|
||||
'frequency' => 'Hourly'
|
||||
];
|
||||
|
||||
return $tasks;
|
||||
}
|
||||
}
|
||||
142
app/Services/ErrorHandler.php
Normal file
142
app/Services/ErrorHandler.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
class ErrorHandler
|
||||
{
|
||||
private static $errorController = null;
|
||||
|
||||
/**
|
||||
* Initialize the error handler with error controller
|
||||
*/
|
||||
public static function init($errorController)
|
||||
{
|
||||
self::$errorController = $errorController;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap database operations to catch and handle errors gracefully
|
||||
*/
|
||||
public static function wrapDatabaseOperation($callback, $fallbackValue = null)
|
||||
{
|
||||
try {
|
||||
return $callback();
|
||||
} catch (PDOException $e) {
|
||||
// Log the database error
|
||||
AppLogger::error('Database operation failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
// Check if this is a missing table error
|
||||
if (strpos($e->getMessage(), "doesn't exist") !== false) {
|
||||
self::handleMissingTable($e);
|
||||
} else {
|
||||
self::handleDatabaseError($e);
|
||||
}
|
||||
|
||||
return $fallbackValue;
|
||||
} catch (Exception $e) {
|
||||
// Log general errors
|
||||
AppLogger::error('Operation failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
if (self::$errorController) {
|
||||
self::$errorController->serverError($e->getMessage(), $e);
|
||||
}
|
||||
|
||||
return $fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle missing table errors specifically
|
||||
*/
|
||||
private static function handleMissingTable($exception)
|
||||
{
|
||||
$message = $exception->getMessage();
|
||||
|
||||
// Extract table name from error message
|
||||
preg_match("/Table '.*\.(\w+)' doesn't exist/", $message, $matches);
|
||||
$tableName = $matches[1] ?? 'unknown';
|
||||
|
||||
AppLogger::critical('Missing database table', [
|
||||
'table' => $tableName,
|
||||
'message' => $message
|
||||
]);
|
||||
|
||||
if (self::$errorController) {
|
||||
$isDebug = Config::get('debug', false);
|
||||
$env = Config::get('env', 'production');
|
||||
|
||||
if ($isDebug || $env === 'development') {
|
||||
$debugMessage = "Database table '{$tableName}' doesn't exist. Please run migrations: php control migrate run";
|
||||
self::$errorController->databaseError($debugMessage);
|
||||
} else {
|
||||
self::$errorController->databaseError('Database configuration error. Please contact support.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle general database errors
|
||||
*/
|
||||
private static function handleDatabaseError($exception)
|
||||
{
|
||||
if (self::$errorController) {
|
||||
self::$errorController->databaseError($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is AJAX
|
||||
*/
|
||||
public static function isAjaxRequest()
|
||||
{
|
||||
return !empty($_SERVER['HTTP_X_REQUESTED_WITH']) &&
|
||||
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle AJAX errors
|
||||
*/
|
||||
public static function handleAjaxError($message, $code = 500)
|
||||
{
|
||||
if (self::$errorController) {
|
||||
self::$errorController->ajaxError($message, $code);
|
||||
} else {
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => true,
|
||||
'message' => 'An error occurred'
|
||||
]);
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap any operation with error handling
|
||||
*/
|
||||
public static function wrap($callback, $fallbackValue = null)
|
||||
{
|
||||
try {
|
||||
return $callback();
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error('Wrapped operation failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
if (self::isAjaxRequest()) {
|
||||
self::handleAjaxError($e->getMessage());
|
||||
} else if (self::$errorController) {
|
||||
self::$errorController->handleException($e);
|
||||
}
|
||||
|
||||
return $fallbackValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
app/Services/FlashMessage.php
Normal file
86
app/Services/FlashMessage.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
class FlashMessage {
|
||||
|
||||
/**
|
||||
* Set a flash message
|
||||
*/
|
||||
public static function set($type, $message) {
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$_SESSION['flash_messages'][] = [
|
||||
'type' => $type,
|
||||
'message' => $message
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a success message
|
||||
*/
|
||||
public static function success($message) {
|
||||
self::set('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an error message
|
||||
*/
|
||||
public static function error($message) {
|
||||
self::set('error', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a warning message
|
||||
*/
|
||||
public static function warning($message) {
|
||||
self::set('warning', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an info message
|
||||
*/
|
||||
public static function info($message) {
|
||||
self::set('info', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all flash messages and clear them
|
||||
*/
|
||||
public static function getAll() {
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$messages = $_SESSION['flash_messages'] ?? [];
|
||||
unset($_SESSION['flash_messages']);
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any flash messages
|
||||
*/
|
||||
public static function has() {
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
return !empty($_SESSION['flash_messages']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all flash messages
|
||||
*/
|
||||
public static function clear() {
|
||||
// Ensure session is started
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
unset($_SESSION['flash_messages']);
|
||||
}
|
||||
}
|
||||
216
app/Services/Logger.php
Normal file
216
app/Services/Logger.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Logger;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\RotatingFileHandler;
|
||||
use Monolog\Formatter\LineFormatter;
|
||||
|
||||
class AppLogger
|
||||
{
|
||||
private static $logger = null;
|
||||
private static $initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the logger
|
||||
*/
|
||||
public static function init()
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Config::get('logging');
|
||||
$channel = $config['channels'][$config['default']];
|
||||
|
||||
// Ensure log directory exists
|
||||
$logDir = dirname($channel['path']);
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
// Create formatter
|
||||
$formatter = new LineFormatter(
|
||||
"[%datetime%] %channel%.%level_name%: %message% %context% %extra%\n",
|
||||
"Y-m-d H:i:s",
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
// Create handler based on configuration
|
||||
if (isset($channel['max_files']) && $channel['max_files'] > 1) {
|
||||
$handler = new RotatingFileHandler(
|
||||
$channel['path'],
|
||||
$channel['max_files'],
|
||||
self::getLogLevel($channel['level'])
|
||||
);
|
||||
} else {
|
||||
$handler = new StreamHandler(
|
||||
$channel['path'],
|
||||
self::getLogLevel($channel['level'])
|
||||
);
|
||||
}
|
||||
|
||||
$handler->setFormatter($formatter);
|
||||
|
||||
// Create logger
|
||||
self::$logger = new Logger('AccountingPanel');
|
||||
self::$logger->pushHandler($handler);
|
||||
|
||||
self::$initialized = true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Fallback to error_log if logger initialization fails
|
||||
error_log("Logger initialization failed: " . $e->getMessage());
|
||||
self::createFallbackLogger();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the logger instance
|
||||
*/
|
||||
public static function getLogger()
|
||||
{
|
||||
if (!self::$initialized) {
|
||||
self::init();
|
||||
}
|
||||
|
||||
return self::$logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message
|
||||
*/
|
||||
public static function info($message, array $context = [])
|
||||
{
|
||||
if (self::shouldLog('info')) {
|
||||
self::getLogger()->info($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message
|
||||
*/
|
||||
public static function error($message, array $context = [])
|
||||
{
|
||||
if (self::shouldLog('error')) {
|
||||
self::getLogger()->error($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message
|
||||
*/
|
||||
public static function warning($message, array $context = [])
|
||||
{
|
||||
if (self::shouldLog('warning')) {
|
||||
self::getLogger()->warning($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message
|
||||
*/
|
||||
public static function debug($message, array $context = [])
|
||||
{
|
||||
if (self::shouldLog('debug')) {
|
||||
self::getLogger()->debug($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a critical message
|
||||
*/
|
||||
public static function critical($message, array $context = [])
|
||||
{
|
||||
if (self::shouldLog('critical')) {
|
||||
self::getLogger()->critical($message, $context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should log based on environment and debug settings
|
||||
*/
|
||||
private static function shouldLog($level)
|
||||
{
|
||||
// Check if logging is completely disabled
|
||||
if (filter_var($_ENV['LOG_DISABLED'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Always log critical errors (unless completely disabled above)
|
||||
if ($level === 'critical' || $level === 'error') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if debug is enabled
|
||||
$debug = Config::get('debug', false);
|
||||
$env = Config::get('env', 'production');
|
||||
|
||||
// In production with debug off, only log warnings and above
|
||||
if ($env === 'production' && !$debug) {
|
||||
return in_array($level, ['warning', 'error', 'critical']);
|
||||
}
|
||||
|
||||
// In development or with debug on, log everything
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string log level to Monolog constant
|
||||
*/
|
||||
private static function getLogLevel($level)
|
||||
{
|
||||
$levels = [
|
||||
'debug' => Logger::DEBUG,
|
||||
'info' => Logger::INFO,
|
||||
'notice' => Logger::NOTICE,
|
||||
'warning' => Logger::WARNING,
|
||||
'error' => Logger::ERROR,
|
||||
'critical' => Logger::CRITICAL,
|
||||
'alert' => Logger::ALERT,
|
||||
'emergency' => Logger::EMERGENCY,
|
||||
];
|
||||
|
||||
return $levels[strtolower($level)] ?? Logger::DEBUG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fallback logger when initialization fails
|
||||
*/
|
||||
private static function createFallbackLogger()
|
||||
{
|
||||
try {
|
||||
$logFile = __DIR__ . '/../../logs/fallback.log';
|
||||
$logDir = dirname($logFile);
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
$handler = new StreamHandler($logFile, Logger::ERROR);
|
||||
$formatter = new LineFormatter(
|
||||
"[%datetime%] FALLBACK.%level_name%: %message%\n",
|
||||
"Y-m-d H:i:s"
|
||||
);
|
||||
$handler->setFormatter($formatter);
|
||||
|
||||
self::$logger = new Logger('FallbackLogger');
|
||||
self::$logger->pushHandler($handler);
|
||||
|
||||
self::$initialized = true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Ultimate fallback - just use error_log
|
||||
self::$logger = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logger is properly initialized
|
||||
*/
|
||||
public static function isInitialized()
|
||||
{
|
||||
return self::$initialized && self::$logger !== null;
|
||||
}
|
||||
}
|
||||
318
app/Services/Middleware.php
Normal file
318
app/Services/Middleware.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
class Middleware
|
||||
{
|
||||
private static $middlewareStack = [];
|
||||
private static $currentRequest = null;
|
||||
|
||||
/**
|
||||
* Register middleware
|
||||
*/
|
||||
public static function register($name, $callback)
|
||||
{
|
||||
self::$middlewareStack[$name] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run middleware stack
|
||||
*/
|
||||
public static function run($middlewareNames, $request = null)
|
||||
{
|
||||
self::$currentRequest = $request ?? self::getCurrentRequest();
|
||||
|
||||
foreach ($middlewareNames as $name) {
|
||||
if (isset(self::$middlewareStack[$name])) {
|
||||
$result = call_user_func(self::$middlewareStack[$name], self::$currentRequest);
|
||||
|
||||
// If middleware returns false, stop execution
|
||||
if ($result === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current request information
|
||||
*/
|
||||
private static function getCurrentRequest()
|
||||
{
|
||||
return [
|
||||
'uri' => parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
|
||||
'method' => $_SERVER['REQUEST_METHOD'],
|
||||
'query' => $_GET,
|
||||
'body' => $_POST,
|
||||
'headers' => getallheaders() ?: [],
|
||||
'session' => $_SESSION ?? []
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default middleware
|
||||
*/
|
||||
public static function init()
|
||||
{
|
||||
// Session Middleware - MUST be first to ensure session is available
|
||||
self::register('session', function($request) {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$sessionConfig = Config::get('session');
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => $sessionConfig['lifetime'],
|
||||
'path' => $sessionConfig['path'],
|
||||
'domain' => $sessionConfig['domain'],
|
||||
'secure' => $sessionConfig['secure'],
|
||||
'httponly' => $sessionConfig['httponly'],
|
||||
'samesite' => $sessionConfig['samesite']
|
||||
]);
|
||||
|
||||
session_start();
|
||||
|
||||
AppLogger::debug('Session started', [
|
||||
'session_id' => session_id(),
|
||||
'uri' => $request['uri'],
|
||||
'method' => $request['method'],
|
||||
'session_config' => $sessionConfig
|
||||
]);
|
||||
|
||||
// Generate CSRF token if not exists (should be done early)
|
||||
if (!isset($_SESSION['_token'])) {
|
||||
$_SESSION['_token'] = bin2hex(random_bytes(32));
|
||||
AppLogger::debug('Generated new CSRF token', [
|
||||
'session_id' => session_id(),
|
||||
'token_preview' => substr($_SESSION['_token'], 0, 8) . '...'
|
||||
]);
|
||||
} else {
|
||||
AppLogger::debug('Using existing CSRF token', [
|
||||
'session_id' => session_id(),
|
||||
'token_preview' => substr($_SESSION['_token'], 0, 8) . '...'
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
AppLogger::debug('Session already started', [
|
||||
'session_id' => session_id(),
|
||||
'uri' => $request['uri'],
|
||||
'method' => $request['method']
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// CORS Middleware
|
||||
self::register('cors', function($request) {
|
||||
if (Config::get('env') === 'development') {
|
||||
$allowedOrigins = ['http://localhost:3000', 'http://localhost:8000', 'http://127.0.0.1:8000' , $_ENV['APP_URL']];
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
|
||||
if (in_array($origin, $allowedOrigins)) {
|
||||
header("Access-Control-Allow-Origin: {$origin}");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Security Headers Middleware
|
||||
self::register('security_headers', function($request) {
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: SAMEORIGIN');
|
||||
header('X-XSS-Protection: 1; mode=block');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
|
||||
if (Config::get('session.secure')) {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Authentication Middleware
|
||||
self::register('authenticate', function($request) {
|
||||
$sessionKey = Config::get('auth.session_key');
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!isset($_SESSION[$sessionKey])) {
|
||||
AppLogger::warning('Authentication required', [
|
||||
'uri' => $request['uri'],
|
||||
'session_id' => session_id(),
|
||||
'session_data' => $_SESSION
|
||||
]);
|
||||
|
||||
self::redirect('/login');
|
||||
return false;
|
||||
}
|
||||
|
||||
AppLogger::debug('Authentication passed', [
|
||||
'user' => $_SESSION[$sessionKey]['email'] ?? 'unknown',
|
||||
'uri' => $request['uri']
|
||||
]);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Guest Middleware (redirect if authenticated)
|
||||
self::register('redirect_if_authenticated', function($request) {
|
||||
$sessionKey = Config::get('auth.session_key');
|
||||
|
||||
if (isset($_SESSION[$sessionKey])) {
|
||||
AppLogger::debug('User already authenticated, redirecting to dashboard', [
|
||||
'user' => $_SESSION[$sessionKey]['email'] ?? 'unknown',
|
||||
'uri' => $request['uri']
|
||||
]);
|
||||
|
||||
self::redirect('/');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// CSRF Protection Middleware
|
||||
self::register('csrf', function($request) {
|
||||
// Only do basic logging here - let the controller handle validation
|
||||
if ($request['method'] === 'POST') {
|
||||
$token = $request['body']['_token'] ?? '';
|
||||
$sessionToken = $_SESSION['_token'] ?? '';
|
||||
|
||||
AppLogger::debug('CSRF token check', [
|
||||
'uri' => $request['uri'],
|
||||
'session_id' => session_id(),
|
||||
'session_token_exists' => !empty($sessionToken),
|
||||
'post_token_exists' => !empty($token),
|
||||
'session_token_length' => strlen($sessionToken),
|
||||
'post_token_length' => strlen($token)
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route is public
|
||||
*/
|
||||
public static function isPublicRoute($uri)
|
||||
{
|
||||
$publicRoutes = Config::get('routes.public', []);
|
||||
|
||||
foreach ($publicRoutes as $route) {
|
||||
if (strpos($uri, $route) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if route is guest only
|
||||
*/
|
||||
public static function isGuestOnlyRoute($uri)
|
||||
{
|
||||
$guestRoutes = Config::get('routes.guest_only', []);
|
||||
|
||||
foreach ($guestRoutes as $route) {
|
||||
if (strpos($uri, $route) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe redirect to prevent loops
|
||||
*/
|
||||
public static function redirect($url, $statusCode = 302)
|
||||
{
|
||||
// Prevent redirect loops
|
||||
$currentUri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
$targetUri = parse_url($url, PHP_URL_PATH);
|
||||
|
||||
if ($currentUri === $targetUri) {
|
||||
AppLogger::error('Redirect loop detected', [
|
||||
'current_uri' => $currentUri,
|
||||
'target_uri' => $targetUri,
|
||||
'session_id' => session_id()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log the redirect
|
||||
AppLogger::info('Redirecting', [
|
||||
'from' => $currentUri,
|
||||
'to' => $targetUri,
|
||||
'status_code' => $statusCode
|
||||
]);
|
||||
|
||||
header("Location: {$url}", true, $statusCode);
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle middleware for specific route
|
||||
*/
|
||||
public static function handleRoute($uri, $method = 'GET')
|
||||
{
|
||||
$request = [
|
||||
'uri' => $uri,
|
||||
'method' => $method,
|
||||
'query' => $_GET,
|
||||
'body' => $_POST,
|
||||
'headers' => getallheaders() ?: [],
|
||||
'session' => $_SESSION ?? []
|
||||
];
|
||||
|
||||
// ALWAYS start session first - this is critical
|
||||
if (!self::run(['session'], $request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run global middleware
|
||||
$globalMiddleware = Config::get('middleware.global', []);
|
||||
if (!self::run($globalMiddleware, $request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check route-specific middleware
|
||||
if (self::isGuestOnlyRoute($uri)) {
|
||||
// Guest-only routes (like login) should redirect if authenticated
|
||||
$guestMiddleware = Config::get('middleware.guest', []);
|
||||
if (!self::run($guestMiddleware, $request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's also a POST request, run CSRF middleware
|
||||
if ($method === 'POST') {
|
||||
return self::run(['csrf'], $request);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (self::isPublicRoute($uri)) {
|
||||
// Public routes don't need authentication but may need CSRF for POST
|
||||
AppLogger::debug('Public route accessed', ['uri' => $uri]);
|
||||
if ($method === 'POST') {
|
||||
return self::run(['csrf'], $request);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Protected routes need authentication and CSRF for POST
|
||||
$authMiddleware = Config::get('middleware.auth', []);
|
||||
if (!self::run($authMiddleware, $request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add CSRF protection for POST requests
|
||||
if ($method === 'POST') {
|
||||
return self::run(['csrf'], $request);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
295
app/Services/ScheduleService.php
Normal file
295
app/Services/ScheduleService.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/TransactionService.php';
|
||||
require_once __DIR__ . '/../Models/Subscription.php';
|
||||
require_once __DIR__ . '/ErrorHandler.php';
|
||||
require_once __DIR__ . '/Logger.php';
|
||||
require_once __DIR__ . '/Config.php';
|
||||
|
||||
class ScheduleService {
|
||||
|
||||
private $db;
|
||||
private $transactionService;
|
||||
private $subscriptionModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->db = $db;
|
||||
$this->transactionService = new TransactionService($db);
|
||||
$this->subscriptionModel = new Subscription($db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all due payments (called by cron job)
|
||||
*/
|
||||
public function processDuePayments() {
|
||||
$today = date('Y-m-d');
|
||||
$processedCount = 0;
|
||||
$failedCount = 0;
|
||||
$results = [];
|
||||
|
||||
try {
|
||||
// Get all active subscriptions that are due for payment
|
||||
$dueSubscriptions = $this->getDueSubscriptions($today);
|
||||
|
||||
AppLogger::info("Processing due payments", [
|
||||
'date' => $today,
|
||||
'due_subscriptions_count' => count($dueSubscriptions)
|
||||
]);
|
||||
|
||||
foreach ($dueSubscriptions as $subscription) {
|
||||
$result = $this->processSubscriptionPayment($subscription);
|
||||
$results[] = $result;
|
||||
|
||||
if ($result['success']) {
|
||||
$processedCount++;
|
||||
} else {
|
||||
$failedCount++;
|
||||
}
|
||||
|
||||
// Small delay to prevent overwhelming the system
|
||||
usleep(100000); // 0.1 second
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'total_due' => count($dueSubscriptions),
|
||||
'processed' => $processedCount,
|
||||
'failed' => $failedCount,
|
||||
'results' => $results
|
||||
];
|
||||
|
||||
AppLogger::info("Due payments processing completed", $summary);
|
||||
|
||||
return $summary;
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Error processing due payments", [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return [
|
||||
'total_due' => 0,
|
||||
'processed' => $processedCount,
|
||||
'failed' => $failedCount,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriptions that are due for payment
|
||||
*/
|
||||
private function getDueSubscriptions($date) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($date) {
|
||||
return $this->db->select('subscriptions', '*', [
|
||||
'status' => 'active',
|
||||
'billing_cycle[!]' => 'one-time',
|
||||
'next_payment_date[<=]' => $date,
|
||||
'ORDER' => ['next_payment_date' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process payment for a single subscription
|
||||
*/
|
||||
private function processSubscriptionPayment($subscription) {
|
||||
try {
|
||||
$result = $this->transactionService->processTransaction($subscription['id']);
|
||||
|
||||
// Log the result
|
||||
AppLogger::info("Subscription payment processed", [
|
||||
'subscription_id' => $subscription['id'],
|
||||
'subscription_name' => $subscription['name'],
|
||||
'amount' => $subscription['amount'],
|
||||
'success' => $result['success']
|
||||
]);
|
||||
|
||||
return array_merge($result, [
|
||||
'subscription_id' => $subscription['id'],
|
||||
'subscription_name' => $subscription['name'],
|
||||
'amount' => $subscription['amount']
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Error processing subscription payment", [
|
||||
'subscription_id' => $subscription['id'],
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'subscription_id' => $subscription['id'],
|
||||
'subscription_name' => $subscription['name'],
|
||||
'amount' => $subscription['amount'],
|
||||
'message' => 'Processing error: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process upcoming payments (within next 7 days)
|
||||
*/
|
||||
public function processUpcomingPayments($days = 7) {
|
||||
$endDate = date('Y-m-d', strtotime("+{$days} days"));
|
||||
$today = date('Y-m-d');
|
||||
|
||||
$upcomingSubscriptions = ErrorHandler::wrapDatabaseOperation(function() use ($today, $endDate) {
|
||||
return $this->db->select('subscriptions', '*', [
|
||||
'status' => 'active',
|
||||
'billing_cycle[!]' => 'one-time',
|
||||
'next_payment_date[>=]' => $today,
|
||||
'next_payment_date[<=]' => $endDate,
|
||||
'ORDER' => ['next_payment_date' => 'ASC']
|
||||
]);
|
||||
}, []);
|
||||
|
||||
return [
|
||||
'upcoming_count' => count($upcomingSubscriptions),
|
||||
'subscriptions' => $upcomingSubscriptions,
|
||||
'date_range' => [
|
||||
'from' => $today,
|
||||
'to' => $endDate
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle expired subscriptions
|
||||
*/
|
||||
public function handleExpiredSubscriptions() {
|
||||
$today = date('Y-m-d');
|
||||
$expiredCount = 0;
|
||||
|
||||
try {
|
||||
// Find subscriptions that are overdue by more than 30 days
|
||||
$overdueDate = date('Y-m-d', strtotime('-30 days'));
|
||||
|
||||
$overdueSubscriptions = ErrorHandler::wrapDatabaseOperation(function() use ($overdueDate) {
|
||||
return $this->db->select('subscriptions', '*', [
|
||||
'status' => 'active',
|
||||
'billing_cycle[!]' => 'one-time',
|
||||
'next_payment_date[<]' => $overdueDate
|
||||
]);
|
||||
}, []);
|
||||
|
||||
foreach ($overdueSubscriptions as $subscription) {
|
||||
// Mark as expired
|
||||
$this->subscriptionModel->update($subscription['id'], [
|
||||
'status' => 'expired'
|
||||
]);
|
||||
|
||||
$expiredCount++;
|
||||
|
||||
AppLogger::info("Subscription marked as expired", [
|
||||
'subscription_id' => $subscription['id'],
|
||||
'subscription_name' => $subscription['name'],
|
||||
'last_payment_date' => $subscription['next_payment_date']
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'expired_count' => $expiredCount,
|
||||
'subscriptions' => $overdueSubscriptions
|
||||
];
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Error handling expired subscriptions", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'expired_count' => $expiredCount,
|
||||
'error' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate payment schedule for a subscription
|
||||
*/
|
||||
public function generatePaymentSchedule($subscriptionId, $months = 12) {
|
||||
$subscription = $this->subscriptionModel->find($subscriptionId);
|
||||
|
||||
if (!$subscription || $subscription['billing_cycle'] === 'one-time') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$schedule = [];
|
||||
$currentDate = new DateTime($subscription['next_payment_date'] ?: 'now');
|
||||
|
||||
for ($i = 0; $i < $months; $i++) {
|
||||
$schedule[] = [
|
||||
'date' => $currentDate->format('Y-m-d'),
|
||||
'amount' => $subscription['amount'],
|
||||
'currency' => $subscription['currency'],
|
||||
'billing_cycle' => $subscription['billing_cycle']
|
||||
];
|
||||
|
||||
// Calculate next payment date
|
||||
switch ($subscription['billing_cycle']) {
|
||||
case 'weekly':
|
||||
$currentDate->add(new DateInterval('P1W'));
|
||||
break;
|
||||
case 'monthly':
|
||||
$currentDate->add(new DateInterval('P1M'));
|
||||
break;
|
||||
case 'quarterly':
|
||||
$currentDate->add(new DateInterval('P3M'));
|
||||
break;
|
||||
case 'yearly':
|
||||
$currentDate->add(new DateInterval('P1Y'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment statistics for scheduling
|
||||
*/
|
||||
public function getScheduleStats() {
|
||||
$today = date('Y-m-d');
|
||||
$nextWeek = date('Y-m-d', strtotime('+7 days'));
|
||||
$nextMonth = date('Y-m-d', strtotime('+30 days'));
|
||||
|
||||
return [
|
||||
'due_today' => $this->countDueSubscriptions($today, $today),
|
||||
'due_this_week' => $this->countDueSubscriptions($today, $nextWeek),
|
||||
'due_this_month' => $this->countDueSubscriptions($today, $nextMonth),
|
||||
'overdue' => $this->countOverdueSubscriptions($today),
|
||||
'active_recurring' => $this->countActiveRecurringSubscriptions()
|
||||
];
|
||||
}
|
||||
|
||||
private function countDueSubscriptions($fromDate, $toDate) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($fromDate, $toDate) {
|
||||
return $this->db->count('subscriptions', [
|
||||
'status' => 'active',
|
||||
'billing_cycle[!]' => 'one-time',
|
||||
'next_payment_date[>=]' => $fromDate,
|
||||
'next_payment_date[<=]' => $toDate
|
||||
]);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private function countOverdueSubscriptions($date) {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() use ($date) {
|
||||
return $this->db->count('subscriptions', [
|
||||
'status' => 'active',
|
||||
'billing_cycle[!]' => 'one-time',
|
||||
'next_payment_date[<]' => $date
|
||||
]);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private function countActiveRecurringSubscriptions() {
|
||||
return ErrorHandler::wrapDatabaseOperation(function() {
|
||||
return $this->db->count('subscriptions', [
|
||||
'status' => 'active',
|
||||
'billing_cycle[!]' => 'one-time'
|
||||
]);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
236
app/Services/TransactionService.php
Normal file
236
app/Services/TransactionService.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../Models/Transaction.php';
|
||||
require_once __DIR__ . '/../Models/Subscription.php';
|
||||
require_once __DIR__ . '/../Models/Expense.php';
|
||||
require_once __DIR__ . '/ErrorHandler.php';
|
||||
require_once __DIR__ . '/Logger.php';
|
||||
require_once __DIR__ . '/Config.php';
|
||||
|
||||
class TransactionService {
|
||||
|
||||
private $db;
|
||||
private $transactionModel;
|
||||
private $subscriptionModel;
|
||||
private $expenseModel;
|
||||
|
||||
public function __construct($db) {
|
||||
$this->db = $db;
|
||||
$this->transactionModel = new Transaction($db);
|
||||
$this->subscriptionModel = new Subscription($db);
|
||||
$this->expenseModel = new Expense($db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a transaction for a subscription
|
||||
*/
|
||||
public function processSubscriptionTransaction($subscriptionId, $amount = null, $transactionDate = null) {
|
||||
try {
|
||||
// Get subscription details
|
||||
$subscription = $this->subscriptionModel->find($subscriptionId);
|
||||
if (!$subscription) {
|
||||
throw new Exception("Subscription not found");
|
||||
}
|
||||
|
||||
// Use subscription amount if not provided
|
||||
$amount = $amount ?: $subscription['amount'];
|
||||
$transactionDate = $transactionDate ?: date('Y-m-d H:i:s');
|
||||
|
||||
// Simulate payment processing (in real app, this would integrate with payment gateway)
|
||||
$isSuccessful = $this->simulatePaymentProcessing($subscription, $amount);
|
||||
|
||||
// Create transaction record using new method
|
||||
$transactionId = $this->transactionModel->createSubscriptionTransaction(
|
||||
$subscription,
|
||||
$amount,
|
||||
$isSuccessful ? 'successful' : 'failed'
|
||||
);
|
||||
|
||||
if ($transactionId && $isSuccessful) {
|
||||
// Update subscription's next payment date if successful
|
||||
$this->updateNextPaymentDate($subscriptionId, $subscription['billing_cycle']);
|
||||
|
||||
AppLogger::info("Subscription transaction processed successfully", [
|
||||
'transaction_id' => $transactionId,
|
||||
'subscription_id' => $subscriptionId,
|
||||
'amount' => $amount,
|
||||
'status' => 'successful'
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'transaction_id' => $transactionId,
|
||||
'message' => 'Subscription transaction processed successfully'
|
||||
];
|
||||
} else {
|
||||
AppLogger::warning("Subscription transaction failed", [
|
||||
'subscription_id' => $subscriptionId,
|
||||
'amount' => $amount,
|
||||
'status' => 'failed'
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'transaction_id' => $transactionId,
|
||||
'message' => 'Subscription transaction failed'
|
||||
];
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Subscription transaction processing error", [
|
||||
'subscription_id' => $subscriptionId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Subscription transaction processing error: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a transaction for an expense
|
||||
*/
|
||||
public function processExpenseTransaction($expenseId, $status = 'successful') {
|
||||
try {
|
||||
// Get expense details
|
||||
$expense = $this->expenseModel->find($expenseId);
|
||||
if (!$expense) {
|
||||
throw new Exception("Expense not found");
|
||||
}
|
||||
|
||||
// Create transaction record using new method
|
||||
$transactionId = $this->transactionModel->createExpenseTransaction($expense, $status);
|
||||
|
||||
if ($transactionId) {
|
||||
AppLogger::info("Expense transaction created successfully", [
|
||||
'transaction_id' => $transactionId,
|
||||
'expense_id' => $expenseId,
|
||||
'amount' => $expense['amount'],
|
||||
'status' => $status
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'transaction_id' => $transactionId,
|
||||
'message' => 'Expense transaction created successfully'
|
||||
];
|
||||
} else {
|
||||
AppLogger::warning("Expense transaction creation failed", [
|
||||
'expense_id' => $expenseId,
|
||||
'amount' => $expense['amount'],
|
||||
'status' => $status
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Expense transaction creation failed'
|
||||
];
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
AppLogger::error("Expense transaction processing error", [
|
||||
'expense_id' => $expenseId,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Expense transaction processing error: ' . $e->getMessage()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility - now uses subscription transaction
|
||||
*/
|
||||
public function processTransaction($subscriptionId, $amount = null, $transactionDate = null) {
|
||||
return $this->processSubscriptionTransaction($subscriptionId, $amount, $transactionDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process one-time payment (instant transaction)
|
||||
*/
|
||||
public function processOneTimePayment($subscriptionId) {
|
||||
$subscription = $this->subscriptionModel->find($subscriptionId);
|
||||
|
||||
if (!$subscription || $subscription['billing_cycle'] !== 'one-time') {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Invalid one-time subscription'
|
||||
];
|
||||
}
|
||||
|
||||
// Process immediate transaction for one-time payments
|
||||
$result = $this->processSubscriptionTransaction($subscriptionId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Mark subscription as completed for one-time payments
|
||||
$this->subscriptionModel->update($subscriptionId, [
|
||||
'status' => 'expired',
|
||||
'next_payment_date' => null
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unified transaction statistics for reporting
|
||||
*/
|
||||
public function getTransactionStats($from_date = null, $to_date = null) {
|
||||
return $this->transactionModel->getTransactionStats($from_date, $to_date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly transaction data for analytics
|
||||
*/
|
||||
public function getMonthlyTransactionData($year = null) {
|
||||
return $this->transactionModel->getMonthlyTransactionData($year);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate payment processing (replace with real payment gateway integration)
|
||||
*/
|
||||
private function simulatePaymentProcessing($subscription, $amount) {
|
||||
// Simulate 95% success rate for demo purposes
|
||||
// In real application, this would integrate with Stripe, PayPal, etc.
|
||||
return (rand(1, 100) <= 95);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subscription's next payment date based on billing cycle
|
||||
*/
|
||||
private function updateNextPaymentDate($subscriptionId, $billingCycle) {
|
||||
$nextDate = $this->calculateNextPaymentDate($billingCycle);
|
||||
|
||||
if ($nextDate) {
|
||||
$this->subscriptionModel->update($subscriptionId, [
|
||||
'next_payment_date' => $nextDate
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next payment date based on billing cycle
|
||||
*/
|
||||
private function calculateNextPaymentDate($billingCycle) {
|
||||
$today = new DateTime();
|
||||
|
||||
switch ($billingCycle) {
|
||||
case 'monthly':
|
||||
return $today->add(new DateInterval('P1M'))->format('Y-m-d');
|
||||
case 'yearly':
|
||||
return $today->add(new DateInterval('P1Y'))->format('Y-m-d');
|
||||
case 'weekly':
|
||||
return $today->add(new DateInterval('P1W'))->format('Y-m-d');
|
||||
case 'quarterly':
|
||||
return $today->add(new DateInterval('P3M'))->format('Y-m-d');
|
||||
case 'one-time':
|
||||
return null; // No next payment for one-time subscriptions
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
app/Services/TwoFactorService.php
Normal file
110
app/Services/TwoFactorService.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../../vendor/autoload.php';
|
||||
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Endroid\QrCode\Builder\Builder;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
|
||||
class TwoFactorService {
|
||||
|
||||
private $google2fa;
|
||||
|
||||
public function __construct() {
|
||||
$this->google2fa = new Google2FA();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new secret key for 2FA
|
||||
*/
|
||||
public function generateSecretKey() {
|
||||
return $this->google2fa->generateSecretKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code URL for Google Authenticator
|
||||
*/
|
||||
public function getQRCodeUrl($user, $secret) {
|
||||
$companyName = 'Accounting Panel';
|
||||
$companyEmail = $user['email'];
|
||||
|
||||
return $this->google2fa->getQRCodeUrl(
|
||||
$companyName,
|
||||
$companyEmail,
|
||||
$secret
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code image as base64 data URL
|
||||
*/
|
||||
public function getQRCodeImage($user, $secret) {
|
||||
$qrCodeUrl = $this->getQRCodeUrl($user, $secret);
|
||||
|
||||
try {
|
||||
$result = Builder::create()
|
||||
->writer(new PngWriter())
|
||||
->data($qrCodeUrl)
|
||||
->size(200)
|
||||
->margin(10)
|
||||
->build();
|
||||
|
||||
return 'data:image/png;base64,' . base64_encode($result->getString());
|
||||
} catch (Exception $e) {
|
||||
// Fallback: return a simple text-based representation
|
||||
AppLogger::error('QR Code generation failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_email' => $user['email']
|
||||
]);
|
||||
|
||||
// Return a placeholder image or the URL itself
|
||||
return 'data:text/plain;base64,' . base64_encode($qrCodeUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a 2FA code
|
||||
*/
|
||||
public function verifyCode($secret, $code) {
|
||||
return $this->google2fa->verifyKey($secret, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate backup codes
|
||||
*/
|
||||
public function generateBackupCodes($count = 8) {
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$codes[] = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify backup code
|
||||
*/
|
||||
public function verifyBackupCode($backupCodes, $code) {
|
||||
if (empty($backupCodes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$codes = json_decode($backupCodes, true);
|
||||
if (!is_array($codes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$code = strtoupper(trim($code));
|
||||
$index = array_search($code, $codes);
|
||||
|
||||
if ($index !== false) {
|
||||
// Remove used backup code
|
||||
unset($codes[$index]);
|
||||
return [
|
||||
'valid' => true,
|
||||
'remaining_codes' => json_encode(array_values($codes))
|
||||
];
|
||||
}
|
||||
|
||||
return ['valid' => false, 'remaining_codes' => $backupCodes];
|
||||
}
|
||||
}
|
||||
77
app/Views/auth/2fa-verify.php
Normal file
77
app/Views/auth/2fa-verify.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../layouts/header.php'; ?>
|
||||
|
||||
<div class="login-wrapper">
|
||||
<div class="login-box">
|
||||
<h2><i class="fas fa-shield-alt me-2"></i>Two-Factor Authentication</h2>
|
||||
<p class="text-center text-muted mb-4">Enter the 6-digit code from your authenticator app or use a backup code.</p>
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?php echo htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="/2fa/verify" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="code">Authentication Code</label>
|
||||
<input type="text" id="code" name="code" class="form-control text-center"
|
||||
placeholder="000000" maxlength="8" autocomplete="one-time-code"
|
||||
style="font-size: 1.5rem; letter-spacing: 0.5rem;" required autofocus>
|
||||
<small class="form-text text-muted mt-2">
|
||||
Enter the 6-digit code from your authenticator app, or an 8-character backup code.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>Verify & Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="/login" class="text-muted">
|
||||
<i class="fas fa-arrow-left me-1"></i>Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('code');
|
||||
|
||||
// Auto-format input (remove spaces, limit length)
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/\s/g, '').toUpperCase();
|
||||
|
||||
// Limit to 8 characters for backup codes or 6 for regular codes
|
||||
if (value.length > 8) {
|
||||
value = value.substring(0, 8);
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
});
|
||||
|
||||
// Auto-submit when 6 digits are entered (for regular 2FA codes)
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
if (e.target.value.length === 6 && /^\d{6}$/.test(e.target.value)) {
|
||||
// Small delay to allow user to see the complete code
|
||||
setTimeout(() => {
|
||||
e.target.form.submit();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../layouts/footer.php'; ?>
|
||||
20
app/Views/auth/login.php
Normal file
20
app/Views/auth/login.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../layouts/header.php'; ?>
|
||||
|
||||
<div class="login-box">
|
||||
<h2>Accounting Panel</h2>
|
||||
<form action="/login" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" autocomplete="email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php require_once __DIR__ . '/../layouts/footer.php'; ?>
|
||||
203
app/Views/dashboard/bank-accounts/create.php
Normal file
203
app/Views/dashboard/bank-accounts/create.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add Bank Account</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/bank-accounts">Bank Accounts</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Bank Account Details</h4>
|
||||
<p class="card-title-desc">Fill out the form below to add a new bank account.</p>
|
||||
|
||||
<form action="/bank-accounts" method="POST" id="bank-account-form" class="bank-account-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Account Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required maxlength="100" placeholder="My Business Account">
|
||||
<div class="form-text">A friendly name to identify this account.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="bank_name" class="form-label">Bank Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="bank_name" name="bank_name" class="form-control" required maxlength="100" placeholder="Chase Bank">
|
||||
<div class="form-text">The name of your bank or financial institution.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="account_type" class="form-label">Account Type</label>
|
||||
<select id="account_type" name="account_type" class="form-control">
|
||||
<?php foreach ($accountTypes as $key => $type): ?>
|
||||
<option value="<?php echo htmlspecialchars($key); ?>"><?php echo htmlspecialchars($type); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="currency" class="form-label">Currency</label>
|
||||
<input type="text" id="currency" name="currency" class="form-control" maxlength="3" placeholder="USD" value="USD" pattern="[A-Z]{3}">
|
||||
<div class="form-text">Enter 3-letter currency code (e.g., USD, EUR, GBP).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="account_number" class="form-label">Account Number <span class="text-danger">*</span></label>
|
||||
<input type="text" id="account_number" name="account_number" class="form-control" required pattern="[0-9]{8,17}" placeholder="12345678901234567">
|
||||
<div class="form-text">8-17 digit account number (only last 4 digits will be stored).</div>
|
||||
<div id="account-validation" class="mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="routing_number" class="form-label">Routing/Sort Code</label>
|
||||
<input type="text" id="routing_number" name="routing_number" class="form-control" pattern="[0-9]{6,15}" placeholder="123456789">
|
||||
<div class="form-text">Routing number, sort code, or bank code (6-15 digits, optional).</div>
|
||||
<div id="routing-validation" class="mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- International Banking Fields -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="country_code" class="form-label">Country</label>
|
||||
<input type="text" id="country_code" name="country_code" class="form-control" maxlength="50" placeholder="United States, Germany, etc.">
|
||||
<div class="form-text">Country where the bank account is located.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="swift_bic" class="form-label">SWIFT/BIC Code</label>
|
||||
<input type="text" id="swift_bic" name="swift_bic" class="form-control" maxlength="11" placeholder="DEUTDEFFXXX">
|
||||
<div class="form-text">SWIFT/BIC code for international transfers (optional).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="iban" class="form-label">IBAN</label>
|
||||
<input type="text" id="iban" name="iban" class="form-control" maxlength="34" placeholder="DE89 3704 0044 0532 0130 00">
|
||||
<div class="form-text">International Bank Account Number (optional, mainly for European banks).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notes</label>
|
||||
<textarea id="notes" name="notes" class="form-control" rows="3" maxlength="1000" placeholder="Additional notes about this account"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 bank-account-actions">
|
||||
<button type="submit" class="btn btn-primary">Add Bank Account</button>
|
||||
<a href="/bank-accounts" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const accountNumberInput = document.getElementById('account_number');
|
||||
const routingNumberInput = document.getElementById('routing_number');
|
||||
const accountValidation = document.getElementById('account-validation');
|
||||
const routingValidation = document.getElementById('routing-validation');
|
||||
|
||||
function validateInputs() {
|
||||
const accountNumber = accountNumberInput.value.trim();
|
||||
const routingNumber = routingNumberInput.value.trim();
|
||||
|
||||
if (accountNumber || routingNumber) {
|
||||
fetch('/bank-accounts/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': '<?php echo htmlspecialchars($csrf_token ?? ''); ?>'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
account_number: accountNumber,
|
||||
routing_number: routingNumber,
|
||||
_token: '<?php echo htmlspecialchars($csrf_token ?? ''); ?>'
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Account number validation
|
||||
if (accountNumber) {
|
||||
if (data.account_valid) {
|
||||
accountValidation.innerHTML = '<small class="text-success">✓ Valid account number format</small>';
|
||||
accountNumberInput.classList.remove('is-invalid');
|
||||
accountNumberInput.classList.add('is-valid');
|
||||
} else {
|
||||
accountValidation.innerHTML = '<small class="text-danger">✗ Invalid account number format</small>';
|
||||
accountNumberInput.classList.remove('is-valid');
|
||||
accountNumberInput.classList.add('is-invalid');
|
||||
}
|
||||
} else {
|
||||
accountValidation.innerHTML = '';
|
||||
accountNumberInput.classList.remove('is-valid', 'is-invalid');
|
||||
}
|
||||
|
||||
// Routing number validation
|
||||
if (routingNumber) {
|
||||
if (data.routing_valid) {
|
||||
routingValidation.innerHTML = '<small class="text-success">✓ Valid routing number format</small>';
|
||||
routingNumberInput.classList.remove('is-invalid');
|
||||
routingNumberInput.classList.add('is-valid');
|
||||
} else {
|
||||
routingValidation.innerHTML = '<small class="text-danger">✗ Invalid routing number format</small>';
|
||||
routingNumberInput.classList.remove('is-valid');
|
||||
routingNumberInput.classList.add('is-invalid');
|
||||
}
|
||||
} else {
|
||||
routingValidation.innerHTML = '';
|
||||
routingNumberInput.classList.remove('is-valid', 'is-invalid');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Validation error:', error);
|
||||
});
|
||||
} else {
|
||||
accountValidation.innerHTML = '';
|
||||
routingValidation.innerHTML = '';
|
||||
accountNumberInput.classList.remove('is-valid', 'is-invalid');
|
||||
routingNumberInput.classList.remove('is-valid', 'is-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time validation
|
||||
let validationTimeout;
|
||||
accountNumberInput.addEventListener('input', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
validationTimeout = setTimeout(validateInputs, 500);
|
||||
});
|
||||
|
||||
routingNumberInput.addEventListener('input', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
validationTimeout = setTimeout(validateInputs, 500);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
146
app/Views/dashboard/bank-accounts/edit.php
Normal file
146
app/Views/dashboard/bank-accounts/edit.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Bank Account</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/bank-accounts">Bank Accounts</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Edit Bank Account Details</h4>
|
||||
<form action="/bank-accounts/<?php echo $account['id']; ?>" method="POST" class="bank-account-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Account Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required value="<?php echo htmlspecialchars($account['name']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="bank_name" class="form-label">Bank Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="bank_name" name="bank_name" class="form-control" required value="<?php echo htmlspecialchars($account['bank_name']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="account_type" class="form-label">Account Type</label>
|
||||
<select id="account_type" name="account_type" class="form-control">
|
||||
<?php foreach ($accountTypes as $key => $type): ?>
|
||||
<option value="<?php echo htmlspecialchars($key); ?>" <?php echo $account['account_type'] === $key ? 'selected' : ''; ?>><?php echo htmlspecialchars($type); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="currency" class="form-label">Currency</label>
|
||||
<input type="text" id="currency" name="currency" class="form-control" maxlength="3" pattern="[A-Z]{3}" value="<?php echo htmlspecialchars($account['currency']); ?>">
|
||||
<div class="form-text">Enter 3-letter currency code (e.g., USD, EUR, GBP).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Account Number</label>
|
||||
<input type="text" class="form-control" disabled value="**** **** <?php echo htmlspecialchars($account['account_number_last4']); ?>">
|
||||
<div class="form-text">Account number cannot be changed for security reasons.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="routing_number" class="form-label">Routing/Sort Code</label>
|
||||
<input type="text" id="routing_number" name="routing_number" class="form-control" pattern="[0-9]{6,15}" value="<?php echo htmlspecialchars($account['routing_number'] ?? ''); ?>">
|
||||
<div class="form-text">Routing number, sort code, or bank code (6-15 digits, optional).</div>
|
||||
</div>
|
||||
|
||||
<!-- International Banking Fields -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="country_code" class="form-label">Country</label>
|
||||
<input type="text" id="country_code" name="country_code" class="form-control" maxlength="50" value="<?php echo htmlspecialchars($account['country_code'] ?? ''); ?>" placeholder="United States, Germany, etc.">
|
||||
<div class="form-text">Country where the bank account is located.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="swift_bic" class="form-label">SWIFT/BIC Code</label>
|
||||
<input type="text" id="swift_bic" name="swift_bic" class="form-control" maxlength="11" value="<?php echo htmlspecialchars($account['swift_bic'] ?? ''); ?>" placeholder="DEUTDEFFXXX">
|
||||
<div class="form-text">SWIFT/BIC code for international transfers (optional).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="iban" class="form-label">IBAN</label>
|
||||
<input type="text" id="iban" name="iban" class="form-control" maxlength="34" value="<?php echo htmlspecialchars($account['iban'] ?? ''); ?>" placeholder="DE89 3704 0044 0532 0130 00">
|
||||
<div class="form-text">International Bank Account Number (optional, mainly for European banks).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notes</label>
|
||||
<textarea id="notes" name="notes" class="form-control" rows="3"><?php echo htmlspecialchars($account['notes'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 bank-account-actions">
|
||||
<button type="submit" class="btn btn-primary">Update Account</button>
|
||||
<a href="/bank-accounts" class="btn btn-secondary">Cancel</a>
|
||||
<button type="button" class="btn btn-danger float-end" onclick="confirmDelete()">Delete Account</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-form" action="/bank-accounts/<?php echo $account['id']; ?>/delete" method="POST" style="display: none;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
showDarkAlert({
|
||||
title: 'Delete Bank Account',
|
||||
text: 'Are you sure you want to delete this bank account? This action cannot be undone and will remove it from all associated expenses.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
document.getElementById('delete-form').submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
117
app/Views/dashboard/bank-accounts/index.php
Normal file
117
app/Views/dashboard/bank-accounts/index.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Bank Accounts</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Bank Accounts</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Bank Accounts</h4>
|
||||
<a href="/bank-accounts/create" class="btn btn-primary">Add New Account</a>
|
||||
</div>
|
||||
|
||||
<table id="bank-accounts-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Bank</th>
|
||||
<th>Type</th>
|
||||
<th>Account Number</th>
|
||||
<th>Currency</th>
|
||||
<th>Owner</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($bankAccounts as $account): ?>
|
||||
<tr>
|
||||
<td><?php echo $account['id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($account['name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($account['bank_name']); ?></td>
|
||||
<td>
|
||||
<span class="badge bg-info">
|
||||
<?php echo ucfirst(str_replace('_', ' ', $account['account_type'])); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted">**** **** <?php echo htmlspecialchars($account['account_number_last4']); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary"><?php echo htmlspecialchars($account['currency']); ?></span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($account['user_name'] ?? 'Unknown'); ?></td>
|
||||
<td><?php echo date('M j, Y', strtotime($account['created_at'])); ?></td>
|
||||
<td>
|
||||
<a href="/bank-accounts/<?php echo $account['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<form action="/bank-accounts/<?php echo $account['id']; ?>/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger delete-btn">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteButtons = document.querySelectorAll('.delete-btn');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const form = this.closest('form');
|
||||
showDarkAlert({
|
||||
title: 'Delete Bank Account',
|
||||
text: 'Are you sure you want to delete this bank account? This action cannot be undone and will remove it from all associated expenses.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
156
app/Views/dashboard/categories/create.php
Normal file
156
app/Views/dashboard/categories/create.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add Category</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/categories">Categories</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Category Details</h4>
|
||||
<p class="card-title-desc">Fill out the form below to add a new expense category.</p>
|
||||
|
||||
<form action="/categories" method="POST" class="category-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Category Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required maxlength="100" placeholder="Enter category name">
|
||||
<div class="form-text">This will be the display name for the category.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3" maxlength="500" placeholder="Optional description for this category"></textarea>
|
||||
<div class="form-text">Optional description to help identify this category.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6 col-12 mb-3">
|
||||
<label for="color" class="form-label">Color</label>
|
||||
<input type="color" id="color" name="color" class="form-control form-control-color" value="#10B981" title="Choose category color">
|
||||
<div class="form-text">Select a color to visually identify this category.</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-12 mb-3">
|
||||
<label for="icon" class="form-label">Icon</label>
|
||||
<input type="text" id="icon" name="icon" class="form-control" placeholder="fa fa-shopping-cart" maxlength="50">
|
||||
<div class="form-text">Optional Font Awesome icon class (e.g., "fa fa-shopping-cart").</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Default Categories</label>
|
||||
<div class="row">
|
||||
<?php foreach ($defaultCategories as $default): ?>
|
||||
<div class="col-lg-4 col-md-6 col-12 mb-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input default-category" type="radio" name="default_category" value="<?php echo htmlspecialchars($default['name']); ?>" data-color="<?php echo htmlspecialchars($default['color']); ?>" data-icon="<?php echo htmlspecialchars($default['icon']); ?>" data-description="<?php echo htmlspecialchars($default['description']); ?>">
|
||||
<label class="form-check-label">
|
||||
<span class="badge" style="background-color: <?php echo htmlspecialchars($default['color']); ?>; color: white;">
|
||||
<?php if (!empty($default['icon'])): ?>
|
||||
<i class="<?php echo htmlspecialchars($default['icon']); ?>"></i>
|
||||
<?php endif; ?>
|
||||
<?php echo htmlspecialchars($default['name']); ?>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="form-text">Select a default category to auto-fill the form, or create a custom category.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="category-preview">
|
||||
<label class="form-label">Preview</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="category-preview-badge" class="badge" style="background-color: #10B981; color: white;">
|
||||
<i id="category-preview-icon" class=""></i>
|
||||
<span id="category-preview-name">Category Name</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<button type="submit" class="btn btn-primary flex-sm-fill">
|
||||
<i class="fas fa-save me-2"></i>Add Category
|
||||
</button>
|
||||
<a href="/categories" class="btn btn-secondary flex-sm-fill">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Elements
|
||||
const nameInput = document.getElementById('name');
|
||||
const colorInput = document.getElementById('color');
|
||||
const iconInput = document.getElementById('icon');
|
||||
const previewBadge = document.getElementById('category-preview-badge');
|
||||
const previewIcon = document.getElementById('category-preview-icon');
|
||||
const previewName = document.getElementById('category-preview-name');
|
||||
const defaultCategoryRadios = document.querySelectorAll('.default-category');
|
||||
|
||||
// Update preview
|
||||
function updatePreview() {
|
||||
const name = nameInput.value || 'Category Name';
|
||||
const color = colorInput.value || '#10B981';
|
||||
const icon = iconInput.value || '';
|
||||
|
||||
previewBadge.style.backgroundColor = color;
|
||||
previewName.textContent = name;
|
||||
previewIcon.className = icon;
|
||||
|
||||
if (icon) {
|
||||
previewIcon.style.display = 'inline';
|
||||
} else {
|
||||
previewIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Default category selection
|
||||
defaultCategoryRadios.forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
nameInput.value = this.value;
|
||||
colorInput.value = this.dataset.color;
|
||||
iconInput.value = this.dataset.icon;
|
||||
document.getElementById('description').value = this.dataset.description;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Live preview updates
|
||||
nameInput.addEventListener('input', updatePreview);
|
||||
colorInput.addEventListener('input', updatePreview);
|
||||
iconInput.addEventListener('input', updatePreview);
|
||||
|
||||
// Initial preview
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
159
app/Views/dashboard/categories/edit.php
Normal file
159
app/Views/dashboard/categories/edit.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Category</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/categories">Categories</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Edit Category Details</h4>
|
||||
<p class="card-title-desc">Update the category information below.</p>
|
||||
|
||||
<form action="/categories/<?php echo $category['id']; ?>" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Category Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required maxlength="100" value="<?php echo htmlspecialchars($category['name']); ?>" placeholder="Enter category name">
|
||||
<div class="form-text">This will be the display name for the category.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3" maxlength="500" placeholder="Optional description for this category"><?php echo htmlspecialchars($category['description'] ?? ''); ?></textarea>
|
||||
<div class="form-text">Optional description to help identify this category.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="color" class="form-label">Color</label>
|
||||
<input type="color" id="color" name="color" class="form-control form-control-color" value="<?php echo htmlspecialchars($category['color']); ?>" title="Choose category color">
|
||||
<div class="form-text">Select a color to visually identify this category.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="icon" class="form-label">Icon</label>
|
||||
<input type="text" id="icon" name="icon" class="form-control" placeholder="fa fa-shopping-cart" maxlength="50" value="<?php echo htmlspecialchars($category['icon'] ?? ''); ?>">
|
||||
<div class="form-text">Optional Font Awesome icon class (e.g., "fa fa-shopping-cart").</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="category-preview">
|
||||
<label class="form-label">Preview</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="category-preview-badge" class="badge" style="background-color: <?php echo htmlspecialchars($category['color']); ?>; color: white;">
|
||||
<i id="category-preview-icon" class="<?php echo htmlspecialchars($category['icon'] ?? ''); ?>"></i>
|
||||
<span id="category-preview-name"><?php echo htmlspecialchars($category['name']); ?></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">Update Category</button>
|
||||
<a href="/categories" class="btn btn-secondary">Cancel</a>
|
||||
<?php if (($category['expense_count'] ?? 0) == 0): ?>
|
||||
<button type="button" class="btn btn-danger float-end" onclick="confirmDelete()">Delete Category</button>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-outline-danger float-end" disabled title="Cannot delete category with expenses">Delete Category</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<!-- Delete Form (hidden) -->
|
||||
<form id="delete-form" action="/categories/<?php echo $category['id']; ?>/delete" method="POST" style="display: none;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Elements
|
||||
const nameInput = document.getElementById('name');
|
||||
const colorInput = document.getElementById('color');
|
||||
const iconInput = document.getElementById('icon');
|
||||
const previewBadge = document.getElementById('category-preview-badge');
|
||||
const previewIcon = document.getElementById('category-preview-icon');
|
||||
const previewName = document.getElementById('category-preview-name');
|
||||
|
||||
// Update preview
|
||||
function updatePreview() {
|
||||
const name = nameInput.value || 'Category Name';
|
||||
const color = colorInput.value || '#10B981';
|
||||
const icon = iconInput.value || '';
|
||||
|
||||
previewBadge.style.backgroundColor = color;
|
||||
previewName.textContent = name;
|
||||
previewIcon.className = icon;
|
||||
|
||||
if (icon) {
|
||||
previewIcon.style.display = 'inline';
|
||||
} else {
|
||||
previewIcon.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Live preview updates
|
||||
nameInput.addEventListener('input', updatePreview);
|
||||
colorInput.addEventListener('input', updatePreview);
|
||||
iconInput.addEventListener('input', updatePreview);
|
||||
|
||||
// Initial preview update
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
showDarkAlert({
|
||||
title: 'Delete Category',
|
||||
text: 'Are you sure you want to delete this category? This action cannot be undone and will remove the category from all associated expenses.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
document.getElementById('delete-form').submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
131
app/Views/dashboard/categories/index.php
Normal file
131
app/Views/dashboard/categories/index.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Categories</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Categories</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Categories</h4>
|
||||
<a href="/categories/create" class="btn btn-primary">Add New Category</a>
|
||||
</div>
|
||||
|
||||
<table id="categories-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Icon</th>
|
||||
<th>Expenses</th>
|
||||
<th>Owner</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<tr>
|
||||
<td><?php echo $category['id']; ?></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: <?php echo htmlspecialchars($category['color']); ?>; color: white;">
|
||||
<?php if (!empty($category['icon'])): ?>
|
||||
<i class="<?php echo htmlspecialchars($category['icon']); ?>"></i>
|
||||
<?php endif; ?>
|
||||
<?php echo htmlspecialchars($category['name']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($category['description'] ?? ''); ?></td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="color-preview me-2" style="background-color: <?php echo htmlspecialchars($category['color']); ?>; width: 20px; height: 20px; display: inline-block; border-radius: 50%; border: 1px solid #ccc;"></span>
|
||||
<span><?php echo htmlspecialchars($category['color']); ?></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($category['icon'])): ?>
|
||||
<i class="<?php echo htmlspecialchars($category['icon']); ?>"></i>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">No icon</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">
|
||||
<?php echo number_format($category['expense_count'] ?? 0); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($category['creator_name'] ?? 'Unknown'); ?></td>
|
||||
<td><?php echo date('M j, Y', strtotime($category['created_at'])); ?></td>
|
||||
<td>
|
||||
<a href="/categories/<?php echo $category['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<form action="/categories/<?php echo $category['id']; ?>/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger delete-btn">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteButtons = document.querySelectorAll('.delete-btn');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const form = this.closest('form');
|
||||
showDarkAlert({
|
||||
title: 'Delete Category',
|
||||
text: 'Are you sure you want to delete this category? This action cannot be undone and will remove the category from all associated expenses.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
60
app/Views/dashboard/credit_cards/create.php
Normal file
60
app/Views/dashboard/credit_cards/create.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add Credit Card</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/credit-cards">Credit Cards</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Card Details</h4>
|
||||
<p class="card-title-desc">Fill out the form below to add a new credit card.</p>
|
||||
<form action="/credit-cards" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Card Name / Nickname</label>
|
||||
<input type="text" id="name" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="card_number" class="form-label">Card Number</label>
|
||||
<input type="text" id="card_number" name="card_number" class="form-control" required pattern="\d{16}" title="16 digit card number">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="expiry_month" class="form-label">Expiry Month</label>
|
||||
<input type="text" id="expiry_month" name="expiry_month" class="form-control" required pattern="\d{2}" title="2 digit month (e.g. 01)" placeholder="MM">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="expiry_year" class="form-label">Expiry Year</label>
|
||||
<input type="text" id="expiry_year" name="expiry_year" class="form-control" required pattern="\d{4}" title="4 digit year (e.g. 2025)" placeholder="YYYY">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Add Card</button>
|
||||
<a href="/credit-cards" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
59
app/Views/dashboard/credit_cards/edit.php
Normal file
59
app/Views/dashboard/credit_cards/edit.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Credit Card</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/credit-cards">Credit Cards</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Update Card Details</h4>
|
||||
<form action="/credit-cards/<?php echo $credit_card['id']; ?>" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Card Name / Nickname</label>
|
||||
<input type="text" id="name" name="name" class="form-control" value="<?php echo htmlspecialchars($credit_card['name']); ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="card_number" class="form-label">Card Number (only last 4 will be stored)</label>
|
||||
<input type="text" id="card_number" name="card_number" class="form-control" required pattern="\d{16}" title="16 digit card number" placeholder="**** **** **** <?php echo $credit_card['card_number_last4']; ?>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="expiry_month" class="form-label">Expiry Month</label>
|
||||
<input type="text" id="expiry_month" name="expiry_month" class="form-control" value="<?php echo htmlspecialchars($credit_card['expiry_month']); ?>" required pattern="\d{2}" title="2 digit month (e.g. 01)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="expiry_year" class="form-label">Expiry Year</label>
|
||||
<input type="text" id="expiry_year" name="expiry_year" class="form-control" value="<?php echo htmlspecialchars($credit_card['expiry_year']); ?>" required pattern="\d{4}" title="4 digit year (e.g. 2025)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Update Card</button>
|
||||
<a href="/credit-cards" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
63
app/Views/dashboard/credit_cards/index.php
Normal file
63
app/Views/dashboard/credit_cards/index.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Credit Cards</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Credit Cards</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Credit Cards</h4>
|
||||
<a href="/credit-cards/create" class="btn btn-primary">Add New Card</a>
|
||||
</div>
|
||||
|
||||
<table id="credit-cards-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>User</th>
|
||||
<th>Last 4 Digits</th>
|
||||
<th>Expiry</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($credit_cards as $card): ?>
|
||||
<tr>
|
||||
<td><?php echo $card['id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($card['name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($card['user_name'] ?? 'Unknown'); ?></td>
|
||||
<td>**** **** **** <?php echo $card['card_number_last4']; ?></td>
|
||||
<td><?php echo htmlspecialchars($card['expiry_month'] . '/' . $card['expiry_year']); ?></td>
|
||||
<td>
|
||||
<a href="/credit-cards/<?php echo $card['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<form action="/credit-cards/<?php echo $card['id']; ?>/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger delete-btn">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
139
app/Views/dashboard/crypto-wallets/create.php
Normal file
139
app/Views/dashboard/crypto-wallets/create.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add Crypto Wallet</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/crypto-wallets">Crypto Wallets</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Crypto Wallet Details</h4>
|
||||
<p class="card-title-desc">Fill out the form below to add a new crypto wallet.</p>
|
||||
|
||||
<form action="/crypto-wallets" method="POST" class="crypto-wallet-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Wallet Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required maxlength="100" placeholder="My USDT Wallet">
|
||||
<div class="form-text">A friendly name to identify this wallet.</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="network" class="form-label">Network <span class="text-danger">*</span></label>
|
||||
<select id="network" name="network" class="form-control" required>
|
||||
<option value="">Select Network</option>
|
||||
<?php foreach ($networks as $key => $name): ?>
|
||||
<option value="<?php echo htmlspecialchars($key); ?>"><?php echo htmlspecialchars($name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="currency" class="form-label">Currency <span class="text-danger">*</span></label>
|
||||
<input type="text" id="currency" name="currency" class="form-control" required maxlength="10" placeholder="USDT, BTC, ETH, TRX, etc.">
|
||||
<div class="form-text">Enter the currency symbol (e.g., USDT, BTC, ETH, TRX).</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="address" class="form-label">Wallet Address <span class="text-danger">*</span></label>
|
||||
<input type="text" id="address" name="address" class="form-control" required placeholder="Enter wallet address">
|
||||
<div id="address-validation" class="mt-1"></div>
|
||||
<div class="form-text">Enter the complete wallet address for this network.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notes</label>
|
||||
<textarea id="notes" name="notes" class="form-control" rows="3" maxlength="1000" placeholder="Additional notes about this wallet"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 crypto-wallet-actions">
|
||||
<button type="submit" class="btn btn-primary">Add Crypto Wallet</button>
|
||||
<a href="/crypto-wallets" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const networkSelect = document.getElementById('network');
|
||||
const addressInput = document.getElementById('address');
|
||||
const addressValidation = document.getElementById('address-validation');
|
||||
|
||||
// Reset address validation when network changes
|
||||
networkSelect.addEventListener('change', function() {
|
||||
validateAddress();
|
||||
});
|
||||
|
||||
// Validate address
|
||||
function validateAddress() {
|
||||
const address = addressInput.value.trim();
|
||||
const network = networkSelect.value;
|
||||
|
||||
if (address && network) {
|
||||
fetch('/crypto-wallets/validate-address', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': '<?php echo htmlspecialchars($csrf_token ?? ''); ?>'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
address: address,
|
||||
network: network,
|
||||
_token: '<?php echo htmlspecialchars($csrf_token ?? ''); ?>'
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
if (data.valid) {
|
||||
addressValidation.innerHTML = '<small class="text-success">✓ Valid address format</small>';
|
||||
addressInput.classList.remove('is-invalid');
|
||||
addressInput.classList.add('is-valid');
|
||||
} else {
|
||||
addressValidation.innerHTML = '<small class="text-danger">✗ Invalid address format for this network</small>';
|
||||
addressInput.classList.remove('is-valid');
|
||||
addressInput.classList.add('is-invalid');
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Address validation error:', error);
|
||||
});
|
||||
} else {
|
||||
addressValidation.innerHTML = '';
|
||||
addressInput.classList.remove('is-valid', 'is-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time address validation
|
||||
let validationTimeout;
|
||||
addressInput.addEventListener('input', function() {
|
||||
clearTimeout(validationTimeout);
|
||||
validationTimeout = setTimeout(validateAddress, 500);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
84
app/Views/dashboard/crypto-wallets/edit.php
Normal file
84
app/Views/dashboard/crypto-wallets/edit.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Crypto Wallet</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/crypto-wallets">Crypto Wallets</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Edit Crypto Wallet Details</h4>
|
||||
<form action="/crypto-wallets/<?php echo $wallet['id']; ?>" method="POST" class="crypto-wallet-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Wallet Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required value="<?php echo htmlspecialchars($wallet['name']); ?>">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Network</label>
|
||||
<input type="text" class="form-control" disabled value="<?php echo htmlspecialchars($wallet['network']); ?>">
|
||||
<div class="form-text">Network cannot be changed for security reasons.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Currency</label>
|
||||
<input type="text" class="form-control" disabled value="<?php echo htmlspecialchars($wallet['currency']); ?>">
|
||||
<div class="form-text">Currency cannot be changed for security reasons.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Wallet Address</label>
|
||||
<input type="text" class="form-control" disabled value="<?php echo htmlspecialchars($wallet['address']); ?>">
|
||||
<div class="form-text">Address cannot be changed for security reasons.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notes</label>
|
||||
<textarea id="notes" name="notes" class="form-control" rows="3"><?php echo htmlspecialchars($wallet['notes'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 crypto-wallet-actions">
|
||||
<button type="submit" class="btn btn-primary">Update Wallet</button>
|
||||
<a href="/crypto-wallets" class="btn btn-secondary">Cancel</a>
|
||||
<button type="button" class="btn btn-danger float-end" onclick="confirmDelete()">Delete Wallet</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-form" action="/crypto-wallets/<?php echo $wallet['id']; ?>/delete" method="POST" style="display: none;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function confirmDelete() {
|
||||
if (confirm('Are you sure you want to delete this crypto wallet? This action cannot be undone.')) {
|
||||
document.getElementById('delete-form').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
113
app/Views/dashboard/crypto-wallets/index.php
Normal file
113
app/Views/dashboard/crypto-wallets/index.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Crypto Wallets</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Crypto Wallets</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Crypto Wallets</h4>
|
||||
<a href="/crypto-wallets/create" class="btn btn-primary">Add New Wallet</a>
|
||||
</div>
|
||||
|
||||
<table id="crypto-wallets-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Network</th>
|
||||
<th>Currency</th>
|
||||
<th>Address</th>
|
||||
<th>Owner</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($wallets as $wallet): ?>
|
||||
<tr>
|
||||
<td><?php echo $wallet['id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($wallet['name']); ?></td>
|
||||
<td>
|
||||
<span class="badge bg-info"><?php echo htmlspecialchars($wallet['network']); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary"><?php echo htmlspecialchars($wallet['currency']); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-muted"><?php echo htmlspecialchars($wallet['address_short'] ?? substr($wallet['address'] ?? '', 0, 8) . '...' . substr($wallet['address'] ?? '', -8)); ?></span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($wallet['user_name'] ?? 'Unknown'); ?></td>
|
||||
<td><?php echo date('M j, Y', strtotime($wallet['created_at'])); ?></td>
|
||||
<td>
|
||||
<a href="/crypto-wallets/<?php echo $wallet['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<form action="/crypto-wallets/<?php echo $wallet['id']; ?>/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger delete-btn">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteButtons = document.querySelectorAll('.delete-btn');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const form = this.closest('form');
|
||||
showDarkAlert({
|
||||
title: 'Delete Crypto Wallet',
|
||||
text: 'Are you sure you want to delete this crypto wallet? This action cannot be undone and will remove it from all associated expenses.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
413
app/Views/dashboard/expenses/create.php
Normal file
413
app/Views/dashboard/expenses/create.php
Normal file
@@ -0,0 +1,413 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add New Expense</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/expenses">Expenses</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Expense Details</h4>
|
||||
<p class="card-title-desc">Fill out the form below to add a new expense.</p>
|
||||
|
||||
<form action="/expenses" method="POST" enctype="multipart/form-data" id="expense-form" class="expense-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="row">
|
||||
<div class="col-lg-8 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title <span class="text-danger">*</span></label>
|
||||
<input type="text" id="title" name="title" class="form-control" required maxlength="255" placeholder="Expense title">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="expense_date" class="form-label">Expense Date <span class="text-danger">*</span></label>
|
||||
<input type="date" id="expense_date" name="expense_date" class="form-control" required>
|
||||
<small class="form-text text-muted">When did this expense occur?</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3" maxlength="1000" placeholder="Optional expense description"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Amount and Tax -->
|
||||
<div class="row">
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="amount" class="form-label">Amount <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" id="amount" name="amount" class="form-control" required min="0" step="0.01" placeholder="0.00">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="currency" class="form-label">Currency</label>
|
||||
<select id="currency" name="currency" class="form-control" readonly>
|
||||
<option value="">Select payment method first</option>
|
||||
</select>
|
||||
<small class="form-text text-muted">Currency is set by payment source</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="tax_rate" class="form-label">Tax Rate (%)</label>
|
||||
<input type="number" id="tax_rate" name="tax_rate" class="form-control" min="0" max="100" step="0.01" placeholder="0.00">
|
||||
<small class="form-text text-muted">Optional tax percentage</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax and Total Display -->
|
||||
<div class="row" id="tax-display" style="display: none;">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tax Amount</label>
|
||||
<div class="form-control-plaintext" id="tax-amount">$0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Total Amount</label>
|
||||
<div class="form-control-plaintext font-weight-bold" id="total-amount">$0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category and Tags -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="category_id" class="form-label">Category</label>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<select id="category_id" name="category_id" class="form-control flex-grow-1">
|
||||
<option value="">Select Category</option>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<option value="<?php echo $category['id']; ?>" data-color="<?php echo htmlspecialchars($category['color']); ?>">
|
||||
<?php echo htmlspecialchars($category['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="openQuickCreate('category')">
|
||||
<i class="fas fa-plus"></i><span class="d-none d-sm-inline ms-1">New</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="tag_ids" class="form-label">Tags</label>
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<select id="tag_ids" name="tag_ids[]" class="form-control flex-grow-1" multiple>
|
||||
<?php foreach ($tags as $tag): ?>
|
||||
<option value="<?php echo $tag['id']; ?>" data-color="<?php echo htmlspecialchars($tag['color']); ?>">
|
||||
<?php echo htmlspecialchars($tag['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="openQuickCreate('tag')">
|
||||
<i class="fas fa-plus"></i><span class="d-none d-sm-inline ms-1">New</span>
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple tags</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Method -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="payment_method" class="form-label">Payment Method <span class="text-danger">*</span></label>
|
||||
<select id="payment_method" name="payment_method" class="form-control" required>
|
||||
<option value="">Select Payment Method</option>
|
||||
<option value="credit_card">Credit Card</option>
|
||||
<option value="bank_account">Bank Account</option>
|
||||
<option value="crypto_wallet">Crypto Wallet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="payment_id" class="form-label">Payment Source <span class="text-danger">*</span></label>
|
||||
<select id="payment_id" name="payment_id" class="form-control" required>
|
||||
<option value="">Select payment method first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Due Date and Status -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="due_date" class="form-label">Due Date</label>
|
||||
<input type="date" id="due_date" name="due_date" class="form-control">
|
||||
<small class="form-text text-muted">Optional due date for payment</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="mb-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select id="status" name="status" class="form-control">
|
||||
<option value="pending" selected>Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Attachment -->
|
||||
<div class="mb-3">
|
||||
<label for="attachment" class="form-label">Attachment</label>
|
||||
<input type="file" id="attachment" name="attachment" class="form-control" accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx">
|
||||
<small class="form-text text-muted">Upload receipt or supporting document (max 10MB)</small>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notes</label>
|
||||
<textarea id="notes" name="notes" class="form-control" rows="3" maxlength="1000" placeholder="Additional notes or comments"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<button type="submit" class="btn btn-primary flex-sm-fill">
|
||||
<i class="fas fa-save me-2"></i>Create Expense
|
||||
</button>
|
||||
<a href="/expenses" class="btn btn-secondary flex-sm-fill">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Create Modals -->
|
||||
<div class="modal fade" id="quickCreateModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="quickCreateModalLabel">Quick Create</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="quickCreateForm">
|
||||
<input type="hidden" id="quickCreateType" name="type">
|
||||
<div class="mb-3">
|
||||
<label for="quickCreateName" class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="quickCreateName" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="quickCreateDescription" class="form-label">Description</label>
|
||||
<textarea id="quickCreateDescription" name="description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="quickCreateColor" class="form-label">Color</label>
|
||||
<input type="color" id="quickCreateColor" name="color" class="form-control" value="#007bff">
|
||||
</div>
|
||||
<div class="mb-3" id="quickCreateIconGroup" style="display: none;">
|
||||
<label for="quickCreateIcon" class="form-label">Icon</label>
|
||||
<select id="quickCreateIcon" name="icon" class="form-control">
|
||||
<option value="fas fa-tag">Tag</option>
|
||||
<option value="fas fa-folder">Folder</option>
|
||||
<option value="fas fa-briefcase">Briefcase</option>
|
||||
<option value="fas fa-star">Star</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveQuickCreate()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Payment method data
|
||||
const paymentMethods = {
|
||||
credit_card: <?php echo json_encode($creditCards); ?>,
|
||||
bank_account: <?php echo json_encode($bankAccounts); ?>,
|
||||
crypto_wallet: <?php echo json_encode($cryptoWallets); ?>
|
||||
};
|
||||
|
||||
// Update payment sources when payment method changes
|
||||
document.getElementById('payment_method').addEventListener('change', function() {
|
||||
const method = this.value;
|
||||
const paymentIdSelect = document.getElementById('payment_id');
|
||||
const currencySelect = document.getElementById('currency');
|
||||
|
||||
paymentIdSelect.innerHTML = '<option value="">Select ' + method.replace('_', ' ') + '</option>';
|
||||
currencySelect.innerHTML = '<option value="">Select payment source first</option>';
|
||||
|
||||
if (method && paymentMethods[method]) {
|
||||
paymentMethods[method].forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.setAttribute('data-currency', item.currency || 'USD');
|
||||
|
||||
if (method === 'credit_card') {
|
||||
option.textContent = `${item.name || 'Card'} (**** ${item.card_number_last4 || item.last4})`;
|
||||
} else if (method === 'bank_account') {
|
||||
option.textContent = `${item.name} (**** ${item.account_number_last4}) - ${item.currency}`;
|
||||
} else if (method === 'crypto_wallet') {
|
||||
option.textContent = `${item.name} (${item.currency})`;
|
||||
}
|
||||
|
||||
paymentIdSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update currency when payment source changes
|
||||
document.getElementById('payment_id').addEventListener('change', function() {
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const currencySelect = document.getElementById('currency');
|
||||
|
||||
if (selectedOption && selectedOption.getAttribute('data-currency')) {
|
||||
const currency = selectedOption.getAttribute('data-currency');
|
||||
currencySelect.innerHTML = `<option value="${currency}" selected>${currency}</option>`;
|
||||
} else {
|
||||
currencySelect.innerHTML = '<option value="">Select payment source first</option>';
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate tax and total
|
||||
function calculateAmounts() {
|
||||
const amount = parseFloat(document.getElementById('amount').value) || 0;
|
||||
const taxRate = parseFloat(document.getElementById('tax_rate').value) || 0;
|
||||
|
||||
const taxAmount = (taxRate > 0) ? (amount * taxRate / 100) : 0;
|
||||
const totalAmount = amount + taxAmount;
|
||||
|
||||
if (taxRate > 0 || amount > 0) {
|
||||
document.getElementById('tax-display').style.display = 'flex';
|
||||
document.getElementById('tax-amount').textContent = '$' + taxAmount.toFixed(2);
|
||||
document.getElementById('total-amount').textContent = '$' + totalAmount.toFixed(2);
|
||||
} else {
|
||||
document.getElementById('tax-display').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('amount').addEventListener('input', calculateAmounts);
|
||||
document.getElementById('tax_rate').addEventListener('input', calculateAmounts);
|
||||
});
|
||||
|
||||
// Quick create functions
|
||||
function openQuickCreate(type) {
|
||||
document.getElementById('quickCreateType').value = type;
|
||||
document.getElementById('quickCreateModalLabel').textContent = 'Quick Create ' + type.charAt(0).toUpperCase() + type.slice(1);
|
||||
|
||||
if (type === 'category') {
|
||||
document.getElementById('quickCreateIconGroup').style.display = 'block';
|
||||
} else {
|
||||
document.getElementById('quickCreateIconGroup').style.display = 'none';
|
||||
}
|
||||
|
||||
new bootstrap.Modal(document.getElementById('quickCreateModal')).show();
|
||||
}
|
||||
|
||||
function saveQuickCreate() {
|
||||
const form = document.getElementById('quickCreateForm');
|
||||
const formData = new FormData(form);
|
||||
formData.append('_token', '<?php echo htmlspecialchars($csrf_token ?? ''); ?>');
|
||||
|
||||
const type = formData.get('type');
|
||||
const url = type === 'category' ? '/categories/quick-create' : '/tags/quick-create';
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Add new option to select
|
||||
const select = document.getElementById(type + '_id' + (type === 'tag' ? 's' : ''));
|
||||
const option = document.createElement('option');
|
||||
|
||||
// The response contains either 'category' or 'tag' object
|
||||
const item = data.category || data.tag;
|
||||
option.value = item.id;
|
||||
option.textContent = item.name;
|
||||
option.selected = true;
|
||||
|
||||
// Add color data attribute for styling
|
||||
if (item.color) {
|
||||
option.setAttribute('data-color', item.color);
|
||||
}
|
||||
|
||||
select.appendChild(option);
|
||||
|
||||
// Close modal and reset form
|
||||
bootstrap.Modal.getInstance(document.getElementById('quickCreateModal')).hide();
|
||||
form.reset();
|
||||
|
||||
// Show success message
|
||||
showDarkAlert({
|
||||
title: 'Success!',
|
||||
text: type.charAt(0).toUpperCase() + type.slice(1) + ' created successfully!',
|
||||
icon: 'success',
|
||||
timer: 2000,
|
||||
timerProgressBar: true
|
||||
});
|
||||
} else {
|
||||
showDarkAlert({
|
||||
title: 'Error!',
|
||||
text: 'Error creating ' + type + ': ' + (data.message || 'Unknown error'),
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showDarkAlert({
|
||||
title: 'Error!',
|
||||
text: 'Error creating ' + type,
|
||||
icon: 'error'
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
286
app/Views/dashboard/expenses/edit.php
Normal file
286
app/Views/dashboard/expenses/edit.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Expense</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/expenses">Expenses</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Edit Expense Details</h4>
|
||||
<p class="card-title-desc">Update the expense information below.</p>
|
||||
|
||||
<form action="/expenses/<?php echo $expense['id']; ?>" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<!-- Basic Information -->
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title <span class="text-danger">*</span></label>
|
||||
<input type="text" id="title" name="title" class="form-control" required value="<?php echo htmlspecialchars($expense['title']); ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="expense_date" class="form-label">Expense Date <span class="text-danger">*</span></label>
|
||||
<input type="date" id="expense_date" name="expense_date" class="form-control" required value="<?php echo htmlspecialchars($expense['expense_date']); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3"><?php echo htmlspecialchars($expense['description'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Amount and Tax -->
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="amount" class="form-label">Amount <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" id="amount" name="amount" class="form-control" required min="0" step="0.01" value="<?php echo htmlspecialchars($expense['amount']); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="currency" class="form-label">Currency</label>
|
||||
<select id="currency" name="currency" class="form-control">
|
||||
<?php foreach ($currencies as $code => $name): ?>
|
||||
<option value="<?php echo htmlspecialchars($code); ?>" <?php echo $expense['currency'] === $code ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($code . ' - ' . $name); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="tax_rate" class="form-label">Tax Rate (%)</label>
|
||||
<input type="number" id="tax_rate" name="tax_rate" class="form-control" min="0" max="100" step="0.01" value="<?php echo htmlspecialchars($expense['tax_rate'] ?? '0'); ?>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tax and Total Display -->
|
||||
<div class="row" id="tax-display" style="<?php echo ($expense['tax_rate'] ?? 0) > 0 ? 'display: flex;' : 'display: none;'; ?>">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Tax Amount</label>
|
||||
<div class="form-control-plaintext" id="tax-amount">$<?php echo number_format($expense['tax_amount'] ?? 0, 2); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Total Amount</label>
|
||||
<div class="form-control-plaintext font-weight-bold" id="total-amount">$<?php echo number_format(($expense['amount'] + ($expense['tax_amount'] ?? 0)), 2); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category and Tags -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="category_id" class="form-label">Category</label>
|
||||
<select id="category_id" name="category_id" class="form-control">
|
||||
<option value="">Select Category</option>
|
||||
<?php foreach ($categories as $category): ?>
|
||||
<option value="<?php echo $category['id']; ?>" <?php echo ($expense['category_id'] ?? '') == $category['id'] ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($category['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="tag_ids" class="form-label">Tags</label>
|
||||
<select id="tag_ids" name="tag_ids[]" class="form-control" multiple>
|
||||
<?php foreach ($tags as $tag): ?>
|
||||
<option value="<?php echo $tag['id']; ?>" <?php echo in_array($tag['id'], $selectedTagIds ?? []) ? 'selected' : ''; ?>>
|
||||
<?php echo htmlspecialchars($tag['name']); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple tags</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Method (Read-only in edit) -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Payment Method</label>
|
||||
<input type="text" class="form-control" disabled value="<?php echo ucfirst(str_replace('_', ' ', $expense['payment_method_type'] ?? 'N/A')); ?>">
|
||||
<div class="form-text">Payment method cannot be changed after creation.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Payment Source</label>
|
||||
<?php
|
||||
// Display payment source name based on type
|
||||
$paymentSourceName = 'N/A';
|
||||
switch($expense['payment_method_type']) {
|
||||
case 'credit_card':
|
||||
$paymentSourceName = $expense['credit_card_name'] ?? 'Credit Card';
|
||||
break;
|
||||
case 'bank_account':
|
||||
$paymentSourceName = $expense['bank_account_name'] ?? 'Bank Account';
|
||||
break;
|
||||
case 'crypto_wallet':
|
||||
$paymentSourceName = $expense['crypto_wallet_name'] ?? 'Crypto Wallet';
|
||||
break;
|
||||
}
|
||||
?>
|
||||
<input type="text" class="form-control" disabled value="<?php echo htmlspecialchars($paymentSourceName); ?>">
|
||||
<div class="form-text">Payment source cannot be changed after creation.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select id="status" name="status" class="form-control">
|
||||
<option value="pending" <?php echo $expense['status'] === 'pending' ? 'selected' : ''; ?>>Pending</option>
|
||||
<option value="approved" <?php echo $expense['status'] === 'approved' ? 'selected' : ''; ?>>Approved</option>
|
||||
<option value="rejected" <?php echo $expense['status'] === 'rejected' ? 'selected' : ''; ?>>Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Attachments -->
|
||||
<?php if (!empty($expense['attachments'])):
|
||||
$attachments = json_decode($expense['attachments'], true);
|
||||
if (is_array($attachments) && !empty($attachments)): ?>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Current Attachments</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<?php foreach ($attachments as $attachment): ?>
|
||||
<div class="d-flex align-items-center border rounded p-2">
|
||||
<i class="fas fa-paperclip text-muted me-2"></i>
|
||||
<a href="/uploads/expenses/<?php echo $expense['user_id']; ?>/<?php echo basename($attachment); ?>"
|
||||
class="text-decoration-none me-2"
|
||||
download="<?php echo basename($attachment); ?>">
|
||||
<?php echo htmlspecialchars(basename($attachment)); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; endif; ?>
|
||||
|
||||
<!-- New File Attachment -->
|
||||
<div class="mb-3">
|
||||
<label for="attachment" class="form-label">
|
||||
<?php echo (!empty($expense['attachments']) && $expense['attachments'] !== 'null') ? 'Replace Attachment' : 'Add Attachment'; ?>
|
||||
</label>
|
||||
<input type="file" id="attachment" name="attachment" class="form-control" accept=".pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx">
|
||||
<small class="form-text text-muted">
|
||||
<?php echo (!empty($expense['attachments']) && $expense['attachments'] !== 'null') ? 'Leave empty to keep current attachments' : 'Upload receipt or supporting document (max 10MB)'; ?>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notes</label>
|
||||
<textarea id="notes" name="notes" class="form-control" rows="3"><?php echo htmlspecialchars($expense['notes'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">Update Expense</button>
|
||||
<a href="/expenses/<?php echo $expense['id']; ?>" class="btn btn-secondary">Cancel</a>
|
||||
<button type="button" class="btn btn-danger float-end" onclick="confirmDelete()">Delete Expense</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete form -->
|
||||
<form id="delete-form" action="/expenses/<?php echo $expense['id']; ?>/delete" method="POST" style="display: none;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Calculate tax and total
|
||||
function calculateAmounts() {
|
||||
const amount = parseFloat(document.getElementById('amount').value) || 0;
|
||||
const taxRate = parseFloat(document.getElementById('tax_rate').value) || 0;
|
||||
|
||||
const taxAmount = (taxRate > 0) ? (amount * taxRate / 100) : 0;
|
||||
const totalAmount = amount + taxAmount;
|
||||
|
||||
if (taxRate > 0 || amount > 0) {
|
||||
document.getElementById('tax-display').style.display = 'flex';
|
||||
document.getElementById('tax-amount').textContent = '$' + taxAmount.toFixed(2);
|
||||
document.getElementById('total-amount').textContent = '$' + totalAmount.toFixed(2);
|
||||
} else {
|
||||
document.getElementById('tax-display').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('amount').addEventListener('input', calculateAmounts);
|
||||
document.getElementById('tax_rate').addEventListener('input', calculateAmounts);
|
||||
|
||||
// Initial calculation
|
||||
calculateAmounts();
|
||||
});
|
||||
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
showDarkAlert({
|
||||
title: 'Delete Expense',
|
||||
text: 'Are you sure you want to delete this expense? This action cannot be undone.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
document.getElementById('delete-form').submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
389
app/Views/dashboard/expenses/import.php
Normal file
389
app/Views/dashboard/expenses/import.php
Normal file
@@ -0,0 +1,389 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Import Expenses</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/expenses">Expenses</a></li>
|
||||
<li class="breadcrumb-item active">Import</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Upload Excel File</h4>
|
||||
<p class="card-title-desc">Import multiple expenses from an Excel spreadsheet.</p>
|
||||
|
||||
<form action="/expenses/import" method="POST" enctype="multipart/form-data" id="import-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<!-- File Upload -->
|
||||
<div class="mb-4">
|
||||
<label for="excel_file" class="form-label">Excel/CSV File <span class="text-danger">*</span></label>
|
||||
<input type="file" id="excel_file" name="excel_file" class="form-control" accept=".xls,.xlsx,.csv" required>
|
||||
<div class="form-text">Supported formats: .xls, .xlsx, .csv (limited by PHP upload settings)</div>
|
||||
</div>
|
||||
|
||||
<!-- Field Mapping -->
|
||||
<h6 class="mb-3">Field Mapping</h6>
|
||||
<p class="text-muted mb-3">Map your Excel columns to expense fields. Required fields are marked with *</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_title" class="form-label">Title Column <span class="text-danger">*</span></label>
|
||||
<input type="text" id="map_title" name="map_title" class="form-control" placeholder="e.g., A or Title" value="A" required>
|
||||
<small class="form-text text-muted">Column containing expense titles</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_amount" class="form-label">Amount Column <span class="text-danger">*</span></label>
|
||||
<input type="text" id="map_amount" name="map_amount" class="form-control" placeholder="e.g., B or Amount" value="B" required>
|
||||
<small class="form-text text-muted">Column containing expense amounts</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_category" class="form-label">Category Column <span class="text-danger">*</span></label>
|
||||
<input type="text" id="map_category" name="map_category" class="form-control" placeholder="e.g., C or Category" value="C" required>
|
||||
<small class="form-text text-muted">Column containing category names</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_expense_date" class="form-label">Date Column</label>
|
||||
<input type="text" id="map_expense_date" name="map_expense_date" class="form-control" placeholder="e.g., D or Date" value="D">
|
||||
<small class="form-text text-muted">Column containing expense dates (optional)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_description" class="form-label">Description Column</label>
|
||||
<input type="text" id="map_description" name="map_description" class="form-control" placeholder="e.g., E or Description">
|
||||
<small class="form-text text-muted">Column containing descriptions (optional)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_currency" class="form-label">Currency Column</label>
|
||||
<input type="text" id="map_currency" name="map_currency" class="form-control" placeholder="e.g., F or Currency">
|
||||
<small class="form-text text-muted">Column containing currency codes (optional)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_tax_rate" class="form-label">Tax Rate Column</label>
|
||||
<input type="text" id="map_tax_rate" name="map_tax_rate" class="form-control" placeholder="e.g., G or Tax">
|
||||
<small class="form-text text-muted">Column containing tax rates (optional)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_notes" class="form-label">Notes Column</label>
|
||||
<input type="text" id="map_notes" name="map_notes" class="form-control" placeholder="e.g., H or Notes">
|
||||
<small class="form-text text-muted">Column containing notes (optional)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="map_tags" class="form-label">Tags Column</label>
|
||||
<input type="text" id="map_tags" name="map_tags" class="form-control" placeholder="e.g., I or Tags">
|
||||
<small class="form-text text-muted">Column containing comma-separated tags (optional)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default Values -->
|
||||
<h6 class="mb-3 mt-4">Default Values</h6>
|
||||
<p class="text-muted mb-3">These values will be used when not specified in the Excel file.</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="default_payment_method" class="form-label">Default Payment Method <span class="text-danger">*</span></label>
|
||||
<select id="default_payment_method" name="default_payment_method" class="form-control" required>
|
||||
<option value="">Select Payment Method</option>
|
||||
<option value="credit_card">Credit Card</option>
|
||||
<option value="bank_account">Bank Account</option>
|
||||
<option value="crypto_wallet">Crypto Wallet</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="default_payment_id" class="form-label">Default Payment Source <span class="text-danger">*</span></label>
|
||||
<select id="default_payment_id" name="default_payment_id" class="form-control" required>
|
||||
<option value="">Select payment method first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="default_currency" class="form-label">Default Currency</label>
|
||||
<select id="default_currency" name="default_currency" class="form-control">
|
||||
<option value="USD" selected>USD - US Dollar</option>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
<option value="GBP">GBP - British Pound</option>
|
||||
<option value="CAD">CAD - Canadian Dollar</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="default_status" class="form-label">Default Status</label>
|
||||
<select id="default_status" name="default_status" class="form-control">
|
||||
<option value="pending" selected>Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Options -->
|
||||
<h6 class="mb-3 mt-4">Import Options</h6>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="skip_header" name="skip_header" value="1" checked>
|
||||
<label class="form-check-label" for="skip_header">
|
||||
Skip first row (header row)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="create_categories" name="create_categories" value="1" checked>
|
||||
<label class="form-check-label" for="create_categories">
|
||||
Automatically create missing categories
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="skip_invalid" name="skip_invalid" value="1" checked>
|
||||
<label class="form-check-label" for="skip_invalid">
|
||||
Skip invalid rows instead of stopping import
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="skip_duplicates" name="skip_duplicates" value="1">
|
||||
<label class="form-check-label" for="skip_duplicates">
|
||||
Skip duplicate expense titles (prevent importing expenses with same title)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">Import Expenses</button>
|
||||
<a href="/expenses" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Example Template -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Excel Template Example</h6>
|
||||
<p class="text-muted">Your Excel file should have columns like this:</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr class="table-light">
|
||||
<th>A</th>
|
||||
<th>B</th>
|
||||
<th>C</th>
|
||||
<th>D</th>
|
||||
<th>I</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Title</td>
|
||||
<td>Amount</td>
|
||||
<td>Category</td>
|
||||
<td>Date</td>
|
||||
<td>Tags</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Office Supplies</td>
|
||||
<td>150.00</td>
|
||||
<td>Office</td>
|
||||
<td>2024-01-15</td>
|
||||
<td>business,office</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Business Lunch</td>
|
||||
<td>75.50</td>
|
||||
<td>Meals</td>
|
||||
<td>2024-01-16</td>
|
||||
<td>client,food</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<a href="/expenses/download-template" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-download me-1"></i> Download Template
|
||||
</a>
|
||||
|
||||
<!-- Import Tips -->
|
||||
<hr class="my-3">
|
||||
<h6 class="card-title mb-2">Import Tips</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li class="mb-1">
|
||||
<i class="fas fa-check text-success me-1"></i>
|
||||
Use column letters (A, B, C) or names
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="fas fa-check text-success me-1"></i>
|
||||
Dates should be in YYYY-MM-DD format
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="fas fa-check text-success me-1"></i>
|
||||
Amounts should be numeric values
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="fas fa-check text-success me-1"></i>
|
||||
Categories and tags will be created if they don't exist
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="fas fa-check text-success me-1"></i>
|
||||
Empty cells will use default values
|
||||
</li>
|
||||
<li class="mb-1">
|
||||
<i class="fas fa-check text-success me-1"></i>
|
||||
Enable duplicate prevention to skip duplicate titles
|
||||
</li>
|
||||
<li class="mb-0">
|
||||
<i class="fas fa-check text-success me-1"></i>
|
||||
File size limited by PHP settings
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Payment method data
|
||||
const paymentMethods = {
|
||||
credit_card: <?php echo json_encode($creditCards); ?>,
|
||||
bank_account: <?php echo json_encode($bankAccounts); ?>,
|
||||
crypto_wallet: <?php echo json_encode($cryptoWallets); ?>
|
||||
};
|
||||
|
||||
// Update payment sources when payment method changes
|
||||
document.getElementById('default_payment_method').addEventListener('change', function() {
|
||||
const method = this.value;
|
||||
const paymentIdSelect = document.getElementById('default_payment_id');
|
||||
|
||||
paymentIdSelect.innerHTML = '<option value="">Select ' + method.replace('_', ' ') + '</option>';
|
||||
|
||||
if (method && paymentMethods[method]) {
|
||||
paymentMethods[method].forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
|
||||
if (method === 'credit_card') {
|
||||
option.textContent = `${item.name || 'Card'} (**** ${item.card_number_last4 || item.last4})`;
|
||||
} else if (method === 'bank_account') {
|
||||
option.textContent = `${item.name} (**** ${item.account_number_last4})`;
|
||||
} else if (method === 'crypto_wallet') {
|
||||
option.textContent = `${item.name} (${item.currency})`;
|
||||
}
|
||||
|
||||
paymentIdSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Form validation
|
||||
document.getElementById('import-form').addEventListener('submit', function(e) {
|
||||
const fileInput = document.getElementById('excel_file');
|
||||
const paymentMethod = document.getElementById('default_payment_method').value;
|
||||
const paymentId = document.getElementById('default_payment_id').value;
|
||||
|
||||
if (!fileInput.files.length) {
|
||||
e.preventDefault();
|
||||
showDarkAlert({
|
||||
title: 'File Required',
|
||||
text: 'Please select an Excel file to import.',
|
||||
icon: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paymentMethod || !paymentId) {
|
||||
e.preventDefault();
|
||||
showDarkAlert({
|
||||
title: 'Payment Method Required',
|
||||
text: 'Please select a default payment method and source.',
|
||||
icon: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileInput.files[0].size > 10 * 1024 * 1024) {
|
||||
e.preventDefault();
|
||||
showDarkAlert({
|
||||
title: 'File Too Large',
|
||||
text: 'File size must be less than 10MB.',
|
||||
icon: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
279
app/Views/dashboard/expenses/index.php
Normal file
279
app/Views/dashboard/expenses/index.php
Normal file
@@ -0,0 +1,279 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Expenses</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Expenses</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-lg-6 col-md-6 col-sm-6 col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-receipt text-primary fa-2x"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="text-xs font-weight-bold text-uppercase mb-1">Total Expenses</div>
|
||||
<div class="h5 mb-0"><?php echo number_format($stats['total_count'] ?? 0); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-lg-6 col-md-6 col-sm-6 col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-dollar-sign text-success fa-2x"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="text-xs font-weight-bold text-uppercase mb-1">Total Amount</div>
|
||||
<div class="h5 mb-0">$<?php echo number_format($stats['total_amount'] ?? 0, 2); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-lg-6 col-md-6 col-sm-6 col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock text-warning fa-2x"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="text-xs font-weight-bold text-uppercase mb-1">Pending</div>
|
||||
<div class="h5 mb-0"><?php echo number_format($stats['pending_count'] ?? 0); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-3 col-lg-6 col-md-6 col-sm-6 col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-check text-info fa-2x"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<div class="text-xs font-weight-bold text-uppercase mb-1">Approved</div>
|
||||
<div class="h5 mb-0"><?php echo number_format($stats['approved_count'] ?? 0); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Expenses</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="/expenses/import" class="btn btn-success">
|
||||
<i class="fas fa-file-excel me-2"></i>Import Excel
|
||||
</a>
|
||||
<a href="/expenses/export" class="btn btn-info">
|
||||
<i class="fas fa-download me-2"></i>Export
|
||||
</a>
|
||||
<a href="/expenses/create" class="btn btn-primary">
|
||||
<i class="fas fa-plus me-2"></i>Add New Expense
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="expenses-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Title</th>
|
||||
<th>Category</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Payment Method</th>
|
||||
<th>Creator</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($expenses as $expense): ?>
|
||||
<tr>
|
||||
<td><?php echo $expense['id']; ?></td>
|
||||
<td>
|
||||
<a href="/expenses/<?php echo $expense['id']; ?>" class="text-decoration-none">
|
||||
<?php echo htmlspecialchars($expense['title']); ?>
|
||||
</a>
|
||||
<?php if (!empty($expense['attachments'])): ?>
|
||||
<i class="fas fa-paperclip text-muted ms-1" title="Has attachment"></i>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($expense['category_name'])): ?>
|
||||
<span class="badge" style="background-color: <?php echo htmlspecialchars($expense['category_color'] ?? '#6c757d'); ?>; color: white;">
|
||||
<?php echo htmlspecialchars($expense['category_name']); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">No category</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<strong><?php echo htmlspecialchars($expense['currency']); ?> <?php echo number_format(($expense['amount'] + ($expense['tax_amount'] ?? 0)), 2); ?></strong>
|
||||
<?php if (($expense['tax_amount'] ?? 0) > 0): ?>
|
||||
<br><small class="text-muted">Tax: <?php echo number_format($expense['tax_amount'], 2); ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo date('M j, Y', strtotime($expense['expense_date'])); ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$statusColors = [
|
||||
'pending' => 'warning',
|
||||
'approved' => 'success',
|
||||
'rejected' => 'danger'
|
||||
];
|
||||
$statusColor = $statusColors[$expense['status']] ?? 'secondary';
|
||||
?>
|
||||
<span class="badge bg-<?php echo $statusColor; ?>"><?php echo ucfirst($expense['status']); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info"><?php echo ucfirst(str_replace('_', ' ', $expense['payment_method_type'] ?? 'N/A')); ?></span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($expense['creator_name'] ?? 'Unknown'); ?></td>
|
||||
<td>
|
||||
<a href="/expenses/<?php echo $expense['id']; ?>" class="btn btn-sm btn-secondary">View</a>
|
||||
<a href="/expenses/<?php echo $expense['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<?php if ($expense['status'] === 'pending'): ?>
|
||||
<button type="button" class="btn btn-sm btn-success" onclick="approveExpense(<?php echo $expense['id']; ?>)">Approve</button>
|
||||
<button type="button" class="btn btn-sm btn-warning" onclick="rejectExpense(<?php echo $expense['id']; ?>)">Reject</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-danger delete-btn" onclick="deleteExpense(<?php echo $expense['id']; ?>)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
// Approve expense function
|
||||
function approveExpense(expenseId) {
|
||||
showDarkAlert({
|
||||
title: 'Approve Expense?',
|
||||
text: 'This will approve the expense and generate a transaction.',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#198754',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Yes, approve it!',
|
||||
cancelButtonText: 'Cancel'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/expenses/${expenseId}/approve`;
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = '_token';
|
||||
tokenInput.value = '<?php echo htmlspecialchars($csrf_token ?? ''); ?>';
|
||||
|
||||
form.appendChild(tokenInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reject expense function
|
||||
function rejectExpense(expenseId) {
|
||||
showDarkAlert({
|
||||
title: 'Reject Expense?',
|
||||
text: 'This will reject the expense and remove any associated transaction.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Yes, reject it!',
|
||||
cancelButtonText: 'Cancel'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/expenses/${expenseId}/reject`;
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = '_token';
|
||||
tokenInput.value = '<?php echo htmlspecialchars($csrf_token ?? ''); ?>';
|
||||
|
||||
form.appendChild(tokenInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete expense function
|
||||
function deleteExpense(expenseId) {
|
||||
showDarkAlert({
|
||||
title: 'Delete Expense',
|
||||
text: 'Are you sure you want to delete this expense? This action cannot be undone.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/expenses/${expenseId}/delete`;
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = '_token';
|
||||
tokenInput.value = '<?php echo htmlspecialchars($csrf_token ?? ''); ?>';
|
||||
|
||||
form.appendChild(tokenInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
385
app/Views/dashboard/expenses/view.php
Normal file
385
app/Views/dashboard/expenses/view.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Expense Details</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/expenses">Expenses</a></li>
|
||||
<li class="breadcrumb-item active"><?php echo htmlspecialchars($expense['title']); ?></li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||
<div>
|
||||
<h4 class="card-title mb-2"><?php echo htmlspecialchars($expense['title']); ?></h4>
|
||||
<?php
|
||||
$statusColors = [
|
||||
'pending' => 'warning',
|
||||
'approved' => 'success',
|
||||
'rejected' => 'danger'
|
||||
];
|
||||
$statusColor = $statusColors[$expense['status']] ?? 'secondary';
|
||||
?>
|
||||
<span class="badge bg-<?php echo $statusColor; ?> fs-6"><?php echo ucfirst($expense['status']); ?></span>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="/expenses/<?php echo $expense['id']; ?>/edit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<?php if ($expense['status'] === 'pending'): ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-success" onclick="approveExpense(<?php echo $expense['id']; ?>)">
|
||||
<i class="fas fa-check"></i> Approve
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-warning" onclick="rejectExpense(<?php echo $expense['id']; ?>)">
|
||||
<i class="fas fa-times"></i> Reject
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<a href="/expenses" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($expense['description'])): ?>
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted">Description</h6>
|
||||
<p><?php echo nl2br(htmlspecialchars($expense['description'])); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Amount Information -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<h6 class="text-muted">Amount</h6>
|
||||
<h5 class="text-primary"><?php echo htmlspecialchars($expense['currency']); ?> <?php echo number_format($expense['amount'], 2); ?></h5>
|
||||
</div>
|
||||
<?php if ($expense['tax_amount'] > 0): ?>
|
||||
<div class="col-md-4">
|
||||
<h6 class="text-muted">Tax (<?php echo number_format($expense['tax_rate'], 2); ?>%)</h6>
|
||||
<h6><?php echo htmlspecialchars($expense['currency']); ?> <?php echo number_format($expense['tax_amount'], 2); ?></h6>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="col-md-4">
|
||||
<h6 class="text-muted">Total Amount</h6>
|
||||
<h4 class="text-success"><?php echo htmlspecialchars($expense['currency']); ?> <?php echo number_format(($expense['amount'] + ($expense['tax_amount'] ?? 0)), 2); ?></h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category and Tags -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted">Category</h6>
|
||||
<?php if ($category): ?>
|
||||
<span class="badge fs-6" style="background-color: <?php echo htmlspecialchars($category['color']); ?>; color: white;">
|
||||
<?php if (!empty($category['icon'])): ?>
|
||||
<i class="<?php echo htmlspecialchars($category['icon']); ?>"></i>
|
||||
<?php endif; ?>
|
||||
<?php echo htmlspecialchars($category['name']); ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">No category assigned</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-muted">Tags</h6>
|
||||
<?php if (!empty($tags)): ?>
|
||||
<?php foreach ($tags as $tag): ?>
|
||||
<span class="badge fs-6 me-1" style="background-color: <?php echo htmlspecialchars($tag['color']); ?>; color: white;">
|
||||
<?php echo htmlspecialchars($tag['name']); ?>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">No tags assigned</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Information -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<h6 class="text-muted">Payment Method</h6>
|
||||
<span class="badge bg-info fs-6"><?php echo ucfirst(str_replace('_', ' ', $expense['payment_method_type'] ?? 'N/A')); ?></span>
|
||||
|
||||
<?php
|
||||
// Display payment source name based on type
|
||||
$paymentSourceName = 'Unknown';
|
||||
switch($expense['payment_method_type']) {
|
||||
case 'credit_card':
|
||||
$paymentSourceName = $expense['credit_card_name'] ?? 'Credit Card';
|
||||
break;
|
||||
case 'bank_account':
|
||||
$paymentSourceName = $expense['bank_account_name'] ?? 'Bank Account';
|
||||
break;
|
||||
case 'crypto_wallet':
|
||||
$paymentSourceName = $expense['crypto_wallet_name'] ?? 'Crypto Wallet';
|
||||
break;
|
||||
}
|
||||
?>
|
||||
<span class="ms-2 text-muted">(<?php echo htmlspecialchars($paymentSourceName); ?>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<?php if (!empty($expense['attachments'])): ?>
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted">Attachments</h6>
|
||||
<?php
|
||||
$attachments = json_decode($expense['attachments'], true);
|
||||
if (is_array($attachments) && !empty($attachments)): ?>
|
||||
<div class="row">
|
||||
<?php foreach ($attachments as $attachment): ?>
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card border">
|
||||
<?php
|
||||
// Fix file path - attachment already contains the full filename
|
||||
$filePath = '/uploads/expenses/' . $expense['user_id'] . '/' . basename($attachment);
|
||||
$fileExtension = strtolower(pathinfo($attachment, PATHINFO_EXTENSION));
|
||||
$isImage = in_array($fileExtension, ['jpg', 'jpeg', 'png', 'gif', 'webp']);
|
||||
$displayName = basename($attachment);
|
||||
?>
|
||||
|
||||
<?php if ($isImage): ?>
|
||||
<img src="<?php echo htmlspecialchars($filePath); ?>"
|
||||
class="card-img-top"
|
||||
style="height: 150px; object-fit: cover; cursor: pointer;"
|
||||
onclick="showImageModal('<?php echo htmlspecialchars($filePath); ?>', '<?php echo htmlspecialchars($displayName); ?>')"
|
||||
alt="<?php echo htmlspecialchars($displayName); ?>">
|
||||
<?php else: ?>
|
||||
<div class="card-img-top d-flex align-items-center justify-content-center bg-dark" style="height: 150px;">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-file-alt fa-3x text-muted mb-2"></i>
|
||||
<div class="small text-muted"><?php echo strtoupper($fileExtension); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted text-truncate" style="max-width: 120px;" title="<?php echo htmlspecialchars($displayName); ?>">
|
||||
<?php echo htmlspecialchars($displayName); ?>
|
||||
</small>
|
||||
<div>
|
||||
<a href="<?php echo htmlspecialchars($filePath); ?>"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
download="<?php echo htmlspecialchars($displayName); ?>"
|
||||
title="Download">
|
||||
<i class="fas fa-download"></i>
|
||||
</a>
|
||||
<?php if ($isImage): ?>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick="showImageModal('<?php echo htmlspecialchars($filePath); ?>', '<?php echo htmlspecialchars($displayName); ?>')"
|
||||
title="View Full Size">
|
||||
<i class="fas fa-search-plus"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-muted">No attachments uploaded</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Notes -->
|
||||
<?php if (!empty($expense['notes'])): ?>
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted">Notes</h6>
|
||||
<p><?php echo nl2br(htmlspecialchars($expense['notes'])); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Additional Information -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<h6 class="text-muted">Created by</h6>
|
||||
<p class="mb-0"><?php echo htmlspecialchars($expense['creator_name'] ?? 'Unknown'); ?></p>
|
||||
<small class="text-muted"><?php echo htmlspecialchars($expense['creator_email'] ?? ''); ?></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<!-- Dates Card -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Important Dates</h6>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Expense Date</small>
|
||||
<div class="fw-bold"><?php echo date('M j, Y', strtotime($expense['expense_date'])); ?></div>
|
||||
</div>
|
||||
<?php if (!empty($expense['due_date'])): ?>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Due Date</small>
|
||||
<div class="fw-bold"><?php echo date('M j, Y', strtotime($expense['due_date'])); ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">Created</small>
|
||||
<div><?php echo date('M j, Y g:i A', strtotime($expense['created_at'])); ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">Last Updated</small>
|
||||
<div><?php echo date('M j, Y g:i A', strtotime($expense['updated_at'])); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Information -->
|
||||
<?php if ($transaction): ?>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Transaction Details</h6>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Transaction ID</small>
|
||||
<div class="fw-bold"><?php echo htmlspecialchars($transaction['reference_number']); ?></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Status</small>
|
||||
<div>
|
||||
<?php
|
||||
$transactionStatusColors = [
|
||||
'pending' => 'warning',
|
||||
'completed' => 'success',
|
||||
'failed' => 'danger'
|
||||
];
|
||||
$transactionStatusColor = $transactionStatusColors[$transaction['status']] ?? 'secondary';
|
||||
?>
|
||||
<span class="badge bg-<?php echo $transactionStatusColor; ?>"><?php echo ucfirst($transaction['status']); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Transaction Date</small>
|
||||
<div><?php echo date('M j, Y', strtotime($transaction['transaction_date'])); ?></div>
|
||||
</div>
|
||||
<div>
|
||||
<small class="text-muted">Amount</small>
|
||||
<div class="fw-bold"><?php echo htmlspecialchars($transaction['currency']); ?> <?php echo number_format($transaction['amount'], 2); ?></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Modal -->
|
||||
<div class="modal fade" id="imageModal" tabindex="-1" aria-labelledby="imageModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title text-light" id="imageModalLabel">Image Preview</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img id="modalImage" src="" class="img-fluid" alt="Preview">
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<a id="modalDownload" href="" download class="btn btn-primary">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Image modal functionality
|
||||
function showImageModal(imagePath, fileName) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('imageModal'));
|
||||
const modalImage = document.getElementById('modalImage');
|
||||
const modalDownload = document.getElementById('modalDownload');
|
||||
const modalTitle = document.getElementById('imageModalLabel');
|
||||
|
||||
modalImage.src = imagePath;
|
||||
modalDownload.href = imagePath;
|
||||
modalDownload.download = fileName;
|
||||
modalTitle.textContent = fileName;
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Approve expense function
|
||||
function approveExpense(expenseId) {
|
||||
Swal.fire({
|
||||
title: 'Approve Expense?',
|
||||
text: 'This will approve the expense and generate a transaction.',
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#198754',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Yes, approve it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
background: 'var(--bs-dark)',
|
||||
color: 'var(--bs-light)'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Create form and submit
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/expenses/${expenseId}/approve`;
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = '_token';
|
||||
tokenInput.value = '<?php echo htmlspecialchars($csrf_token ?? ''); ?>';
|
||||
|
||||
form.appendChild(tokenInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Reject expense function
|
||||
function rejectExpense(expenseId) {
|
||||
Swal.fire({
|
||||
title: 'Reject Expense?',
|
||||
text: 'This will reject the expense and remove any associated transaction.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#dc3545',
|
||||
cancelButtonColor: '#6c757d',
|
||||
confirmButtonText: 'Yes, reject it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
background: 'var(--bs-dark)',
|
||||
color: 'var(--bs-light)'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Create form and submit
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/expenses/${expenseId}/reject`;
|
||||
|
||||
const tokenInput = document.createElement('input');
|
||||
tokenInput.type = 'hidden';
|
||||
tokenInput.name = '_token';
|
||||
tokenInput.value = '<?php echo htmlspecialchars($csrf_token ?? ''); ?>';
|
||||
|
||||
form.appendChild(tokenInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
1617
app/Views/dashboard/index.php
Normal file
1617
app/Views/dashboard/index.php
Normal file
File diff suppressed because it is too large
Load Diff
258
app/Views/dashboard/profile/2fa-backup-codes.php
Normal file
258
app/Views/dashboard/profile/2fa-backup-codes.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Two-Factor Authentication Backup Codes</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/profile/edit">Profile</a></li>
|
||||
<li class="breadcrumb-item active">Backup Codes</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<?php if (isset($regenerated) && $regenerated): ?>
|
||||
<h4 class="card-title text-success">
|
||||
<i class="fas fa-check-circle me-2"></i>New Backup Codes Generated
|
||||
</h4>
|
||||
<p class="card-title-desc">Your old backup codes have been replaced with these new ones.</p>
|
||||
<?php else: ?>
|
||||
<h4 class="card-title text-success">
|
||||
<i class="fas fa-check-circle me-2"></i>Two-Factor Authentication Enabled
|
||||
</h4>
|
||||
<p class="card-title-desc">Congratulations! Your account is now protected with two-factor authentication.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Important:</strong> Save these backup codes in a secure location. Each code can only be used once and will allow you to access your account if you lose your authenticator device.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-key me-2"></i>Your Backup Codes
|
||||
</h5>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" onclick="copyBackupCodes()">
|
||||
<i class="fas fa-copy me-1"></i>Copy All
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row backup-codes-container" id="backup-codes-container">
|
||||
<?php foreach ($backupCodes as $index => $code): ?>
|
||||
<div class="col-md-6 mb-2">
|
||||
<div class="backup-code-item p-2 bg-dark rounded border border-secondary">
|
||||
<code class="text-light fw-bold"><?php echo htmlspecialchars($code); ?></code>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="button" class="btn btn-outline-light btn-sm" onclick="printBackupCodes()">
|
||||
<i class="fas fa-print me-1"></i>Print Codes
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-light btn-sm" onclick="downloadBackupCodes()">
|
||||
<i class="fas fa-download me-1"></i>Download as Text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="alert alert-info">
|
||||
<h6><i class="fas fa-info-circle me-2"></i>How to use backup codes:</h6>
|
||||
<ul class="mb-0">
|
||||
<li>Each backup code can only be used once</li>
|
||||
<li>Use them when you don't have access to your authenticator app</li>
|
||||
<li>Enter a backup code in place of the 6-digit authenticator code</li>
|
||||
<li>Generate new codes if you're running low</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/profile/edit" class="btn btn-primary">
|
||||
<i class="fas fa-arrow-left me-2"></i>Back to Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<style>
|
||||
.backup-code-item {
|
||||
background-color: #2d3748 !important;
|
||||
border: 1px solid #4a5568 !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.backup-code-item:hover {
|
||||
background-color: #374151 !important;
|
||||
border-color: #6b7280 !important;
|
||||
}
|
||||
|
||||
.backup-code-item code {
|
||||
color: #e2e8f0 !important;
|
||||
background: transparent !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 0.1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #374151 !important;
|
||||
border-bottom: 1px solid #4b5563 !important;
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
background-color: #f8f9fa;
|
||||
color: #212529;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function copyBackupCodes() {
|
||||
const codes = <?php echo json_encode($backupCodes); ?>;
|
||||
const codesText = codes.join('\n');
|
||||
|
||||
navigator.clipboard.writeText(codesText).then(function() {
|
||||
showAlert('Backup codes copied to clipboard!', 'success');
|
||||
}).catch(function() {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = codesText;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showAlert('Backup codes copied to clipboard!', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
function printBackupCodes() {
|
||||
const codes = <?php echo json_encode($backupCodes); ?>;
|
||||
const printWindow = window.open('', '_blank');
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>2FA Backup Codes - Accounting Panel</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; padding: 20px; }
|
||||
h1 { color: #333; }
|
||||
.code {
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
padding: 5px;
|
||||
margin: 5px 0;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
.warning {
|
||||
color: #d63384;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Two-Factor Authentication Backup Codes</h1>
|
||||
<p><strong>Account:</strong> ${<?php echo json_encode($_SESSION['user']['email']); ?>}</p>
|
||||
<p><strong>Generated:</strong> ${new Date().toLocaleString()}</p>
|
||||
|
||||
<div class="warning">
|
||||
⚠️ IMPORTANT: Keep these codes secure and private. Each code can only be used once.
|
||||
</div>
|
||||
|
||||
<h2>Backup Codes:</h2>
|
||||
${codes.map(code => '<div class="code">' + code + '</div>').join('')}
|
||||
|
||||
<div class="warning">
|
||||
Store these codes in a secure location separate from your authenticator device.
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const codes = <?php echo json_encode($backupCodes); ?>;
|
||||
const userEmail = <?php echo json_encode($_SESSION['user']['email']); ?>;
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
|
||||
const content = `Two-Factor Authentication Backup Codes
|
||||
Account: ${userEmail}
|
||||
Generated: ${new Date().toLocaleString()}
|
||||
|
||||
IMPORTANT: Keep these codes secure and private. Each code can only be used once.
|
||||
|
||||
Backup Codes:
|
||||
${codes.map((code, index) => `${index + 1}. ${code}`).join('\n')}
|
||||
|
||||
Instructions:
|
||||
- Use these codes when you don't have access to your authenticator app
|
||||
- Enter a backup code in place of the 6-digit authenticator code
|
||||
- Each code can only be used once
|
||||
- Generate new codes if you're running low
|
||||
|
||||
Store these codes in a secure location separate from your authenticator device.
|
||||
`;
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `2fa-backup-codes-${timestamp}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showAlert('Backup codes downloaded successfully!', 'success');
|
||||
}
|
||||
|
||||
function showAlert(message, type) {
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||
alertDiv.style.cssText = 'top: 20px; right: 20px; z-index: 1050; max-width: 300px;';
|
||||
alertDiv.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(alertDiv);
|
||||
|
||||
setTimeout(() => {
|
||||
if (alertDiv.parentNode) {
|
||||
alertDiv.parentNode.removeChild(alertDiv);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
132
app/Views/dashboard/profile/2fa-setup.php
Normal file
132
app/Views/dashboard/profile/2fa-setup.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Enable Two-Factor Authentication</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/profile/edit">Profile</a></li>
|
||||
<li class="breadcrumb-item active">2FA Setup</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Setup Two-Factor Authentication</h4>
|
||||
<p class="card-title-desc">Follow these steps to secure your account with two-factor authentication.</p>
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?php echo htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row tfa-setup">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-4">
|
||||
<h5><span class="badge bg-primary me-2">1</span>Install Authenticator App</h5>
|
||||
<p>Download and install an authenticator app on your mobile device:</p>
|
||||
<ul>
|
||||
<li><strong>Google Authenticator</strong> (iOS/Android)</li>
|
||||
<li><strong>Authy</strong> (iOS/Android/Desktop)</li>
|
||||
<li><strong>Microsoft Authenticator</strong> (iOS/Android)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h5><span class="badge bg-primary me-2">2</span>Scan QR Code</h5>
|
||||
<p>Open your authenticator app and scan this QR code:</p>
|
||||
<div class="text-center p-3 bg-dark rounded">
|
||||
<img src="<?php echo htmlspecialchars($qrCodeImage); ?>" alt="2FA QR Code" class="img-fluid">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<small class="text-muted">
|
||||
<strong>Can't scan?</strong> Manually enter this secret key in your app:<br>
|
||||
<code class="bg-dark text-light p-2 rounded d-inline-block mt-1"><?php echo htmlspecialchars($secret); ?></code>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-4">
|
||||
<h5><span class="badge bg-primary me-2">3</span>Verify Setup</h5>
|
||||
<p>Enter the 6-digit code from your authenticator app to complete setup:</p>
|
||||
|
||||
<form action="/profile/2fa/enable" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<input type="hidden" name="secret" value="<?php echo htmlspecialchars($secret); ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="code" class="form-label">Verification Code</label>
|
||||
<input type="text" id="code" name="code" class="form-control text-center"
|
||||
placeholder="000000" maxlength="6" pattern="\d{6}"
|
||||
style="font-size: 1.5rem; letter-spacing: 0.5rem;" required autofocus>
|
||||
<small class="form-text text-muted">Enter the 6-digit code from your authenticator app</small>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-shield-alt me-2"></i>Enable Two-Factor Authentication
|
||||
</button>
|
||||
<a href="/profile/edit" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
<strong>Important:</strong> After enabling 2FA, you'll receive backup codes that can be used to access your account if you lose your authenticator device. Make sure to save them in a secure location.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const codeInput = document.getElementById('code');
|
||||
|
||||
// Auto-format input (only allow digits, limit to 6 characters)
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
let value = e.target.value.replace(/\D/g, '');
|
||||
|
||||
if (value.length > 6) {
|
||||
value = value.substring(0, 6);
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
});
|
||||
|
||||
// Auto-submit when 6 digits are entered
|
||||
codeInput.addEventListener('input', function(e) {
|
||||
if (e.target.value.length === 6) {
|
||||
// Small delay to allow user to see the complete code
|
||||
setTimeout(() => {
|
||||
e.target.form.submit();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
192
app/Views/dashboard/profile/api-key-created.php
Normal file
192
app/Views/dashboard/profile/api-key-created.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">API Key Created Successfully</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/profile/edit">Profile</a></li>
|
||||
<li class="breadcrumb-item"><a href="/profile/api-keys">API Keys</a></li>
|
||||
<li class="breadcrumb-item active">Created</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-success" role="alert">
|
||||
<h4 class="alert-heading"><i class="fas fa-check-circle"></i> API Key Created!</h4>
|
||||
<p>Your new API key "<strong><?= htmlspecialchars($apiKey['name']) ?></strong>" has been created successfully.</p>
|
||||
<a href="/profile/api-keys" class="btn btn-outline-success">
|
||||
<i class="fas fa-arrow-left"></i> Back to API Keys
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="card-title mb-0">
|
||||
<i class="fas fa-exclamation-triangle"></i> Important Security Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>⚠️ This is the only time you will see this API key!</strong><br>
|
||||
Make sure to copy and store it securely. You will not be able to view it again.
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="apiKey" class="form-label"><strong>Your API Key:</strong></label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="apiKey"
|
||||
value="<?= htmlspecialchars($apiKey['raw_key']) ?>" readonly>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyApiKey()">
|
||||
<i class="fas fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Key Details:</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>Name:</strong> <?= htmlspecialchars($apiKey['name']) ?></li>
|
||||
<li><strong>Key ID:</strong> <?= $apiKey['id'] ?></li>
|
||||
<li><strong>Prefix:</strong> <code><?= htmlspecialchars($apiKey['prefix']) ?>...</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Security Best Practices:</h6>
|
||||
<ul>
|
||||
<li>Store this key in a secure location</li>
|
||||
<li>Never commit it to version control</li>
|
||||
<li>Use environment variables in production</li>
|
||||
<li>Rotate keys regularly</li>
|
||||
<li>Monitor API usage for anomalies</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">How to Use Your API Key</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Authentication Methods</h6>
|
||||
<p>Include your API key in requests using one of these methods:</p>
|
||||
|
||||
<h6 class="mt-3">1. Authorization Header (Recommended)</h6>
|
||||
<pre class="bg-dark text-light p-2 rounded border"><code>Authorization: Bearer <?= htmlspecialchars($apiKey['raw_key']) ?></code></pre>
|
||||
|
||||
<h6 class="mt-3">2. X-API-Key Header</h6>
|
||||
<pre class="bg-dark text-light p-2 rounded border"><code>X-API-Key: <?= htmlspecialchars($apiKey['raw_key']) ?></code></pre>
|
||||
|
||||
<h6 class="mt-3">3. Query Parameter (Less Secure)</h6>
|
||||
<pre class="bg-dark text-light p-2 rounded border"><code>?api_key=<?= htmlspecialchars($apiKey['raw_key']) ?></code></pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Example Usage</h6>
|
||||
<p>Here's how to make a request to the API:</p>
|
||||
|
||||
<h6 class="mt-3">cURL Example</h6>
|
||||
<pre class="bg-dark text-light p-2 rounded border small"><code>curl -H "Authorization: Bearer <?= htmlspecialchars($apiKey['raw_key']) ?>" \
|
||||
<?= $_ENV['APP_URL'] ?? 'http://localhost' ?>/api/v1/users</code></pre>
|
||||
|
||||
<h6 class="mt-3">JavaScript Example</h6>
|
||||
<pre class="bg-dark text-light p-2 rounded border small"><code>fetch('<?= $_ENV['APP_URL'] ?? 'http://localhost' ?>/api/v1/users', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer <?= htmlspecialchars($apiKey['raw_key']) ?>',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12 text-center">
|
||||
<a href="/profile/api-keys" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-key"></i> Manage API Keys
|
||||
</a>
|
||||
<?php if (Config::get('debug', false)): ?>
|
||||
<a href="/api/docs/ui" target="_blank" class="btn btn-outline-info btn-lg ms-2">
|
||||
<i class="fas fa-book"></i> View API Documentation
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
function copyApiKey() {
|
||||
const apiKeyInput = document.getElementById('apiKey');
|
||||
apiKeyInput.select();
|
||||
apiKeyInput.setSelectionRange(0, 99999); // For mobile devices
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
|
||||
// Show feedback
|
||||
const button = event.target.closest('button');
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
button.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-outline-secondary');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy API key:', err);
|
||||
showDarkAlert({
|
||||
title: 'Copy Failed',
|
||||
text: 'Failed to copy API key. Please copy it manually.',
|
||||
icon: 'warning'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select the API key on page load for easy copying
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const apiKeyInput = document.getElementById('apiKey');
|
||||
apiKeyInput.focus();
|
||||
apiKeyInput.select();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
535
app/Views/dashboard/profile/api-keys.php
Normal file
535
app/Views/dashboard/profile/api-keys.php
Normal file
@@ -0,0 +1,535 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">API Keys</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/profile/edit">Profile</a></li>
|
||||
<li class="breadcrumb-item active">API Keys</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="card-title">Your API Keys</h4>
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createApiKeyModal">
|
||||
<i class="fas fa-plus"></i> Create API Key
|
||||
</button>
|
||||
</div>
|
||||
<?php if (empty($apiKeys)): ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-key fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No API Keys</h5>
|
||||
<p class="text-muted">You haven't created any API keys yet. Create one to start using the API.</p>
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createApiKeyModal">
|
||||
Create Your First API Key
|
||||
</button>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover api-keys-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Key Prefix</th>
|
||||
<th>Permissions</th>
|
||||
<th>Rate Limit</th>
|
||||
<th>Last Used</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($apiKeys as $key): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><?= htmlspecialchars($key['name']) ?></strong>
|
||||
<br>
|
||||
<small class="text-muted">Created <?= date('M j, Y', strtotime($key['created_at'])) ?></small>
|
||||
</td>
|
||||
<td>
|
||||
<code><?= htmlspecialchars($key['api_key_prefix']) ?>...</code>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (empty($key['permissions'])): ?>
|
||||
<span class="badge bg-success">Full Access</span>
|
||||
<?php else: ?>
|
||||
<?php $permissions = json_decode($key['permissions'], true); ?>
|
||||
<?php if (in_array('*', $permissions)): ?>
|
||||
<span class="badge bg-success">Full Access</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-info"><?= count($permissions) ?> permissions</span>
|
||||
<div class="small text-muted mt-1">
|
||||
<?= htmlspecialchars(implode(', ', array_slice($permissions, 0, 3))) ?>
|
||||
<?php if (count($permissions) > 3): ?>
|
||||
<br>and <?= count($permissions) - 3 ?> more...
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= $key['rate_limit_per_minute'] ?>/min
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($key['last_used_at']): ?>
|
||||
<?= date('M j, Y g:i A', strtotime($key['last_used_at'])) ?>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">Never</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($key['expires_at']): ?>
|
||||
<?php $isExpired = strtotime($key['expires_at']) < time(); ?>
|
||||
<span class="<?= $isExpired ? 'text-danger' : 'text-warning' ?>">
|
||||
<?= date('M j, Y', strtotime($key['expires_at'])) ?>
|
||||
<?php if ($isExpired): ?>
|
||||
<br><small>Expired</small>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">Never</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php
|
||||
$isBlocked = $key['blocked_until'] && strtotime($key['blocked_until']) > time();
|
||||
$isExpired = $key['expires_at'] && strtotime($key['expires_at']) < time();
|
||||
?>
|
||||
<?php if (!$key['is_active']): ?>
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
<?php elseif ($isBlocked): ?>
|
||||
<span class="badge bg-danger">Blocked</span>
|
||||
<div class="small text-muted">
|
||||
Until <?= date('M j, g:i A', strtotime($key['blocked_until'])) ?>
|
||||
</div>
|
||||
<?php elseif ($isExpired): ?>
|
||||
<span class="badge bg-warning">Expired</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-success">Active</span>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($key['failed_attempts'] > 0): ?>
|
||||
<div class="small text-warning mt-1">
|
||||
<?= $key['failed_attempts'] ?> failed attempts
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/profile/api-keys/<?= $key['id'] ?>/delete" class="d-inline"
|
||||
onsubmit="return confirm('Are you sure you want to permanently delete this API key? This action cannot be undone and will immediately invalidate all requests using this key.')">
|
||||
<input type="hidden" name="_token" value="<?= $csrf_token ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Usage Information -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">API Usage Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Authentication</h6>
|
||||
<p>Include your API key in requests using one of these methods:</p>
|
||||
<ul>
|
||||
<li><strong>Authorization header:</strong> <code>Authorization: Bearer YOUR_API_KEY</code></li>
|
||||
<li><strong>X-API-Key header:</strong> <code>X-API-Key: YOUR_API_KEY</code></li>
|
||||
<li><strong>Query parameter:</strong> <code>?api_key=YOUR_API_KEY</code> (less secure)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>API Endpoints</h6>
|
||||
<p>Base URL: <code><?= $_ENV['APP_URL'] ?? 'http://localhost' ?>/api/v1</code></p>
|
||||
<div class="small">
|
||||
<strong>Available Endpoints:</strong>
|
||||
<ul class="mb-2">
|
||||
<li><code>GET /users</code> - List users</li>
|
||||
<li><code>GET /categories</code> - List categories</li>
|
||||
<li><code>GET /tags</code> - List tags</li>
|
||||
<li><code>GET /bank-accounts</code> - List bank accounts</li>
|
||||
<li><code>GET /credit-cards</code> - List credit cards</li>
|
||||
<li><code>GET /crypto-wallets</code> - List crypto wallets</li>
|
||||
<li><code>GET /expenses</code> - List expenses</li>
|
||||
<li><code>GET /subscriptions</code> - List subscriptions</li>
|
||||
<li><code>GET /transactions</code> - List transactions</li>
|
||||
<li><code>GET /reports/dashboard</code> - Dashboard analytics</li>
|
||||
<li><code>GET /reports/expenses</code> - Expense analytics</li>
|
||||
<li><code>GET /reports/export</code> - Export data</li>
|
||||
<li><code>GET /api-keys</code> - List your API keys</li>
|
||||
</ul>
|
||||
<p class="text-muted mb-2">Each endpoint supports GET, POST, PUT, DELETE operations based on permissions.</p>
|
||||
<p class="text-muted mb-2"><strong>Special endpoints:</strong></p>
|
||||
<ul class="mb-2">
|
||||
<li><code>POST /expenses/{id}/approve</code> - Approve expense</li>
|
||||
<li><code>GET /expenses/analytics</code> - Expense analytics</li>
|
||||
<li><code>GET /categories/popular</code> - Popular categories</li>
|
||||
<li><code>GET /tags/popular</code> - Popular tags</li>
|
||||
<li><code>GET /bank-accounts/by-currency/{currency}</code> - Filter by currency</li>
|
||||
<li><code>GET /crypto-wallets/by-currency/{currency}</code> - Filter by currency</li>
|
||||
<li><code>GET /crypto-wallets/by-network/{network}</code> - Filter by network</li>
|
||||
</ul>
|
||||
</div>
|
||||
<?php if (Config::get('debug', false)): ?>
|
||||
<a href="/api/docs/ui" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||
<i class="fas fa-book"></i> View Full API Documentation
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<!-- Create API Key Modal -->
|
||||
<div class="modal fade" id="createApiKeyModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content bg-dark">
|
||||
<form method="POST" action="/profile/api-keys/create">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create New API Key</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="_token" value="<?= $csrf_token ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">API Key Name *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" required
|
||||
placeholder="e.g., Mobile App, Integration Server">
|
||||
<div class="form-text">Choose a descriptive name to identify this API key.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="rate_limit_per_minute" class="form-label">Rate Limit (requests per minute)</label>
|
||||
<input type="number" class="form-control" id="rate_limit_per_minute" name="rate_limit_per_minute"
|
||||
value="60" min="1" max="1000">
|
||||
<div class="form-text">Maximum number of API requests per minute (1-1000).</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="expires_at" class="form-label">Expiration Date (Optional)</label>
|
||||
<input type="datetime-local" class="form-control" id="expires_at" name="expires_at">
|
||||
<div class="form-text">Leave empty for no expiration.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Permissions </label>
|
||||
<div class="form-text mb-2">Select specific permissions:</div>
|
||||
<!-- All Permissions Option -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body py-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="perm_all" onchange="toggleAllPermissions(this)">
|
||||
<label class="form-check-label fw-bold text-primary" for="perm_all">
|
||||
<i class="fas fa-globe"></i> All Permissions (Full Access)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<h6 class="text-primary">Users</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="users.read" id="perm_users_read">
|
||||
<label class="form-check-label" for="perm_users_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="users.create" id="perm_users_create">
|
||||
<label class="form-check-label" for="perm_users_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="users.update" id="perm_users_update">
|
||||
<label class="form-check-label" for="perm_users_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="users.delete" id="perm_users_delete">
|
||||
<label class="form-check-label" for="perm_users_delete">Delete</label>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Categories</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="categories.read" id="perm_categories_read">
|
||||
<label class="form-check-label" for="perm_categories_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="categories.create" id="perm_categories_create">
|
||||
<label class="form-check-label" for="perm_categories_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="categories.update" id="perm_categories_update">
|
||||
<label class="form-check-label" for="perm_categories_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="categories.delete" id="perm_categories_delete">
|
||||
<label class="form-check-label" for="perm_categories_delete">Delete</label>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Tags</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="tags.read" id="perm_tags_read">
|
||||
<label class="form-check-label" for="perm_tags_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="tags.create" id="perm_tags_create">
|
||||
<label class="form-check-label" for="perm_tags_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="tags.update" id="perm_tags_update">
|
||||
<label class="form-check-label" for="perm_tags_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="tags.delete" id="perm_tags_delete">
|
||||
<label class="form-check-label" for="perm_tags_delete">Delete</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h6 class="text-primary">Bank Accounts</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="bank_accounts.read" id="perm_bank_accounts_read">
|
||||
<label class="form-check-label" for="perm_bank_accounts_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="bank_accounts.create" id="perm_bank_accounts_create">
|
||||
<label class="form-check-label" for="perm_bank_accounts_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="bank_accounts.update" id="perm_bank_accounts_update">
|
||||
<label class="form-check-label" for="perm_bank_accounts_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="bank_accounts.delete" id="perm_bank_accounts_delete">
|
||||
<label class="form-check-label" for="perm_bank_accounts_delete">Delete</label>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Credit Cards</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="credit_cards.read" id="perm_credit_cards_read">
|
||||
<label class="form-check-label" for="perm_credit_cards_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="credit_cards.create" id="perm_credit_cards_create">
|
||||
<label class="form-check-label" for="perm_credit_cards_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="credit_cards.update" id="perm_credit_cards_update">
|
||||
<label class="form-check-label" for="perm_credit_cards_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="credit_cards.delete" id="perm_credit_cards_delete">
|
||||
<label class="form-check-label" for="perm_credit_cards_delete">Delete</label>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Crypto Wallets</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="crypto_wallets.read" id="perm_crypto_wallets_read">
|
||||
<label class="form-check-label" for="perm_crypto_wallets_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="crypto_wallets.create" id="perm_crypto_wallets_create">
|
||||
<label class="form-check-label" for="perm_crypto_wallets_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="crypto_wallets.update" id="perm_crypto_wallets_update">
|
||||
<label class="form-check-label" for="perm_crypto_wallets_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="crypto_wallets.delete" id="perm_crypto_wallets_delete">
|
||||
<label class="form-check-label" for="perm_crypto_wallets_delete">Delete</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h6 class="text-primary">Expenses</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="expenses.read" id="perm_expenses_read">
|
||||
<label class="form-check-label" for="perm_expenses_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="expenses.create" id="perm_expenses_create">
|
||||
<label class="form-check-label" for="perm_expenses_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="expenses.update" id="perm_expenses_update">
|
||||
<label class="form-check-label" for="perm_expenses_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="expenses.delete" id="perm_expenses_delete">
|
||||
<label class="form-check-label" for="perm_expenses_delete">Delete</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="expenses.approve" id="perm_expenses_approve">
|
||||
<label class="form-check-label" for="perm_expenses_approve">Approve</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="expenses.analytics" id="perm_expenses_analytics">
|
||||
<label class="form-check-label" for="perm_expenses_analytics">Analytics</label>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Subscriptions</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="subscriptions.read" id="perm_subscriptions_read">
|
||||
<label class="form-check-label" for="perm_subscriptions_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="subscriptions.create" id="perm_subscriptions_create">
|
||||
<label class="form-check-label" for="perm_subscriptions_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="subscriptions.update" id="perm_subscriptions_update">
|
||||
<label class="form-check-label" for="perm_subscriptions_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="subscriptions.delete" id="perm_subscriptions_delete">
|
||||
<label class="form-check-label" for="perm_subscriptions_delete">Delete</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h6 class="text-primary">Transactions</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="transactions.read" id="perm_transactions_read">
|
||||
<label class="form-check-label" for="perm_transactions_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="transactions.create" id="perm_transactions_create">
|
||||
<label class="form-check-label" for="perm_transactions_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="transactions.update" id="perm_transactions_update">
|
||||
<label class="form-check-label" for="perm_transactions_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="transactions.delete" id="perm_transactions_delete">
|
||||
<label class="form-check-label" for="perm_transactions_delete">Delete</label>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">Reports</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="reports.read" id="perm_reports_read">
|
||||
<label class="form-check-label" for="perm_reports_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="reports.export" id="perm_reports_export">
|
||||
<label class="form-check-label" for="perm_reports_export">Export</label>
|
||||
</div>
|
||||
|
||||
<h6 class="text-primary mt-3">API Keys</h6>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="api_keys.read" id="perm_api_keys_read">
|
||||
<label class="form-check-label" for="perm_api_keys_read">Read</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="api_keys.create" id="perm_api_keys_create">
|
||||
<label class="form-check-label" for="perm_api_keys_create">Create</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="api_keys.update" id="perm_api_keys_update">
|
||||
<label class="form-check-label" for="perm_api_keys_update">Update</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input permission-checkbox" type="checkbox" name="permissions[]" value="api_keys.delete" id="perm_api_keys_delete">
|
||||
<label class="form-check-label" for="perm_api_keys_delete">Delete</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create API Key</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleAllPermissions(allCheckbox) {
|
||||
const permissionCheckboxes = document.querySelectorAll('.permission-checkbox');
|
||||
|
||||
permissionCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = allCheckbox.checked;
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for individual permission changes to update the "All" checkbox state
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const allCheckbox = document.getElementById('perm_all');
|
||||
const permissionCheckboxes = document.querySelectorAll('.permission-checkbox');
|
||||
|
||||
permissionCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
// Check if all individual checkboxes are checked
|
||||
const allChecked = Array.from(permissionCheckboxes).every(cb => cb.checked);
|
||||
const someChecked = Array.from(permissionCheckboxes).some(cb => cb.checked);
|
||||
|
||||
if (allChecked) {
|
||||
allCheckbox.checked = true;
|
||||
allCheckbox.indeterminate = false;
|
||||
} else if (someChecked) {
|
||||
allCheckbox.checked = false;
|
||||
allCheckbox.indeterminate = true;
|
||||
} else {
|
||||
allCheckbox.checked = false;
|
||||
allCheckbox.indeterminate = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add category group selection functionality
|
||||
const categoryHeaders = document.querySelectorAll('h6.text-primary');
|
||||
categoryHeaders.forEach(header => {
|
||||
header.style.cursor = 'pointer';
|
||||
header.title = 'Click to toggle all permissions in this category';
|
||||
|
||||
header.addEventListener('click', function() {
|
||||
const category = this.textContent.trim().toLowerCase().replace(/\s+/g, '_');
|
||||
const categoryCheckboxes = document.querySelectorAll(`input[name="permissions[]"][value^="${category}."]`);
|
||||
|
||||
// Check if all category checkboxes are checked
|
||||
const allCategoryChecked = Array.from(categoryCheckboxes).every(cb => cb.checked);
|
||||
|
||||
// Toggle all checkboxes in this category
|
||||
categoryCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = !allCategoryChecked;
|
||||
checkbox.dispatchEvent(new Event('change')); // Trigger change event
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
236
app/Views/dashboard/profile/edit.php
Normal file
236
app/Views/dashboard/profile/edit.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Profile</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Profile</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Update Profile Details</h4>
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?php echo htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form action="/profile/update" method="POST" class="profile-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Full Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" autocomplete="name" value="<?php echo htmlspecialchars($user['name']); ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email Address</label>
|
||||
<input type="email" id="email" name="email" class="form-control" autocomplete="email" value="<?php echo htmlspecialchars($user['email']); ?>" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Role</label>
|
||||
<input type="text" disabled id="role" name="role" class="form-control" value="<?php echo ucfirst(htmlspecialchars($user['role'])); ?>" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="member_since" class="form-label">Member Since</label>
|
||||
<input type="text" disabled id="member_since" name="member_since" class="form-control" value="<?php echo date('F j, Y', strtotime($user['created_at'])); ?>" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Current Password (required to save changes)</label>
|
||||
<input type="password" id="current_password" name="current_password" autocomplete="current-password" class="form-control" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">New Password (leave blank to keep current)</label>
|
||||
<input type="password" id="new_password" name="new_password" autocomplete="new-password" class="form-control">
|
||||
<small class="form-text text-muted">Minimum 6 characters required</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Confirm New Password</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" class="form-control">
|
||||
<div id="password-match-feedback" class="form-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 profile-actions">
|
||||
<button type="submit" class="btn btn-primary">Update Profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
<br>
|
||||
<!-- Two-Factor Authentication Section -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Two-Factor Authentication</h4>
|
||||
<p class="card-title-desc">Add an extra layer of security to your account by enabling two-factor authentication.</p>
|
||||
|
||||
<?php if ($user['two_factor_enabled']): ?>
|
||||
<div class="alert alert-success" role="alert">
|
||||
<i class="fas fa-shield-alt me-2"></i>
|
||||
Two-factor authentication is <strong>enabled</strong> for your account.
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<h6>Backup Codes</h6>
|
||||
<p class="text-muted">Generate new backup codes if you've lost access to your authenticator app.</p>
|
||||
<form action="/profile/2fa/regenerate-backup-codes" method="POST" style="display: inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-info btn-sm">Regenerate Backup Codes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<h6>Disable 2FA</h6>
|
||||
<p class="text-muted">Disable two-factor authentication for your account.</p>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#disable2FAModal">
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
Two-factor authentication is <strong>disabled</strong> for your account.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<p>Secure your account by enabling two-factor authentication. You'll need to install an authenticator app like Google Authenticator or Authy on your mobile device.</p>
|
||||
<a href="/profile/2fa/setup" class="btn btn-success">
|
||||
<i class="fas fa-shield-alt me-2"></i>Enable Two-Factor Authentication
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
<br>
|
||||
<!-- Disable 2FA Modal -->
|
||||
<div class="modal fade" id="disable2FAModal" tabindex="-1" aria-labelledby="disable2FAModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content bg-dark">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="disable2FAModalLabel">Disable Two-Factor Authentication</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form action="/profile/2fa/disable" method="POST">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>Warning:</strong> Disabling two-factor authentication will make your account less secure.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="disable_current_password" class="form-label">Current Password</label>
|
||||
<input type="password" id="disable_current_password" name="current_password" class="form-control" required>
|
||||
<small class="form-text text-muted">Enter your current password to confirm this action.</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Disable 2FA</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const newPasswordField = document.getElementById('new_password');
|
||||
const confirmPasswordField = document.getElementById('confirm_password');
|
||||
const feedback = document.getElementById('password-match-feedback');
|
||||
const form = document.querySelector('form');
|
||||
|
||||
function validatePasswords() {
|
||||
const newPassword = newPasswordField.value;
|
||||
const confirmPassword = confirmPasswordField.value;
|
||||
|
||||
if (newPassword === '' && confirmPassword === '') {
|
||||
feedback.textContent = '';
|
||||
feedback.className = 'form-text';
|
||||
return true;
|
||||
}
|
||||
|
||||
if (newPassword.length > 0 && newPassword.length < 6) {
|
||||
feedback.textContent = 'Password must be at least 6 characters long';
|
||||
feedback.className = 'form-text text-danger';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
feedback.textContent = 'Passwords do not match';
|
||||
feedback.className = 'form-text text-danger';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newPassword === confirmPassword && newPassword.length >= 6) {
|
||||
feedback.textContent = 'Passwords match ✓';
|
||||
feedback.className = 'form-text text-success';
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
newPasswordField.addEventListener('input', validatePasswords);
|
||||
confirmPasswordField.addEventListener('input', validatePasswords);
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validatePasswords()) {
|
||||
e.preventDefault();
|
||||
showDarkAlert({
|
||||
title: 'Validation Error',
|
||||
text: 'Please fix the password validation errors before submitting.',
|
||||
icon: 'error',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
505
app/Views/dashboard/reports/index.php
Normal file
505
app/Views/dashboard/reports/index.php
Normal file
@@ -0,0 +1,505 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Unified Transaction Reports</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Reports</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<!-- Date Filter -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Filter Transactions by Date Range</h5>
|
||||
<?php if (!empty($filter_dates['from']) && !empty($filter_dates['to'])): ?>
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information"></i>
|
||||
Showing transactions from <strong><?php echo $filter_dates['from']; ?></strong> to <strong><?php echo $filter_dates['to']; ?></strong>
|
||||
(<?php echo count($transactions); ?> transactions found)
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="alert alert-secondary">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
Showing all transactions (<?php echo count($transactions); ?> total)
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="GET" action="/reports" class="row align-items-end g-3 reports-filter-form" id="dateFilterForm">
|
||||
<!-- Date Inputs -->
|
||||
<div class="col-xl-3 col-lg-4 col-md-6 col-sm-6 col-12">
|
||||
<label for="from_date" class="form-label">
|
||||
<i class="mdi mdi-calendar mr-2"></i>From Date
|
||||
</label>
|
||||
<input type="date"
|
||||
id="from_date"
|
||||
name="from"
|
||||
value="<?php echo htmlspecialchars($filter_dates['from'] ?? ''); ?>"
|
||||
class="form-control"
|
||||
placeholder="Select start date">
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-lg-4 col-md-6 col-sm-6 col-12">
|
||||
<label for="to_date" class="form-label">
|
||||
<i class="mdi mdi-calendar mr-2"></i>To Date
|
||||
</label>
|
||||
<input type="date"
|
||||
id="to_date"
|
||||
name="to"
|
||||
value="<?php echo htmlspecialchars($filter_dates['to'] ?? ''); ?>"
|
||||
class="form-control"
|
||||
placeholder="Select end date">
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="col-xl-6 col-lg-4 col-md-12 col-12">
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<button type="submit" class="btn btn-primary flex-fill">
|
||||
<i class="mdi mdi-filter mr-2"></i>Apply Filter
|
||||
</button>
|
||||
<a href="/reports" class="btn btn-secondary flex-fill">
|
||||
<i class="mdi mdi-refresh mr-2"></i>Show All Data
|
||||
</a>
|
||||
<button type="button" class="btn btn-info flex-fill" onclick="setQuickFilter('30')">
|
||||
<i class="mdi mdi-clock mr-2"></i>Last 30 Days
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Quick Filter Buttons Row -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-wrap justify-content-between gap-2 quick-filter-buttons">
|
||||
<button type="button" class="btn btn-outline-primary quick-filter-btn" onclick="setQuickFilter('7')">
|
||||
<i class="mdi mdi-calendar-week mr-2"></i>Last 7 Days
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary quick-filter-btn" onclick="setQuickFilter('90')">
|
||||
<i class="mdi mdi-calendar-range mr-2"></i>Last 3 Months
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary quick-filter-btn" onclick="setQuickFilter('365')">
|
||||
<i class="mdi mdi-calendar mr-2"></i>Last Year
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning quick-filter-btn" onclick="setCurrentMonth()">
|
||||
<i class="mdi mdi-calendar-today mr-2"></i>This Month
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success quick-filter-btn" onclick="setCurrentYear()">
|
||||
<i class="mdi mdi-calendar-check mr-2"></i>This Year
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<!-- Transaction Statistics -->
|
||||
<div class="row reports-stats">
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="avatar-sm rounded-circle bg-primary">
|
||||
<span class="avatar-title">
|
||||
<i class="mdi mdi-cash-multiple font-size-16"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0">Total Revenue</h6>
|
||||
<b>$<?php echo number_format($transaction_stats['total_revenue'], 2); ?></b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="avatar-sm rounded-circle bg-success">
|
||||
<span class="avatar-title">
|
||||
<i class="mdi mdi-check-circle font-size-16"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0">Successful</h6>
|
||||
<b><?php echo number_format($transaction_stats['successful_transactions']); ?></b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="avatar-sm rounded-circle bg-danger">
|
||||
<span class="avatar-title">
|
||||
<i class="mdi mdi-close-circle font-size-16"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0">Failed</h6>
|
||||
<b><?php echo number_format($transaction_stats['failed_transactions']); ?></b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="avatar-sm rounded-circle bg-warning">
|
||||
<span class="avatar-title">
|
||||
<i class="mdi mdi-clock font-size-16"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0">Pending</h6>
|
||||
<b><?php echo number_format($transaction_stats['pending_transactions']); ?></b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="avatar-sm rounded-circle bg-info">
|
||||
<span class="avatar-title">
|
||||
<i class="mdi mdi-repeat font-size-16"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0">Subscriptions</h6>
|
||||
<b><?php echo number_format($transaction_stats['subscription_transactions']); ?></b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="avatar-sm rounded-circle bg-purple">
|
||||
<span class="avatar-title">
|
||||
<i class="mdi mdi-receipt font-size-16"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 ms-3">
|
||||
<h6 class="mb-0">Expenses</h6>
|
||||
<b><?php echo number_format($transaction_stats['expense_transactions']); ?></b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<!-- Transactions Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">Unified Transaction History</h4>
|
||||
<a href="/reports/export<?php echo (!empty($filter_dates['from']) && !empty($filter_dates['to'])) ? '?from=' . urlencode($filter_dates['from']) . '&to=' . urlencode($filter_dates['to']) : ''; ?>" class="btn btn-success">Export to Excel</a>
|
||||
</div>
|
||||
|
||||
<table id="transactions-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>User</th>
|
||||
<th>Item/Service</th>
|
||||
<th>Vendor</th>
|
||||
<th>Category</th>
|
||||
<th>Amount</th>
|
||||
<th>Payment Method</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
<th>Billing Cycle</th>
|
||||
<th>Reference</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($transactions as $transaction): ?>
|
||||
<tr>
|
||||
<td><?php echo $transaction['id']; ?></td>
|
||||
<td>
|
||||
<span class="badge bg-<?php echo $transaction['transaction_type'] === 'subscription' ? 'info' : 'purple'; ?>">
|
||||
<?php echo ucfirst($transaction['transaction_type']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($transaction['user_name'] ?? 'Unknown'); ?></td>
|
||||
<td><?php echo htmlspecialchars($transaction['item_name'] ?? 'Unknown'); ?></td>
|
||||
<td><?php echo htmlspecialchars($transaction['vendor'] ?? '-'); ?></td>
|
||||
<td><?php echo htmlspecialchars($transaction['category_name'] ?? '-'); ?></td>
|
||||
<td><?php echo htmlspecialchars($transaction['amount'] . ' ' . $transaction['currency']); ?></td>
|
||||
<td>
|
||||
<span class="text-muted small"><?php echo ucfirst($transaction['payment_method_type'] ?? 'Unknown'); ?></span><br>
|
||||
<strong><?php echo htmlspecialchars($transaction['payment_method_name'] ?? 'Unknown'); ?></strong>
|
||||
</td>
|
||||
<td><span class="badge bg-<?php echo $transaction['status'] === 'successful' ? 'success' : ($transaction['status'] === 'failed' ? 'danger' : 'warning'); ?>"><?php echo htmlspecialchars(ucfirst($transaction['status'])); ?></span></td>
|
||||
<td><?php echo date('M d, Y H:i', strtotime($transaction['transaction_date'])); ?></td>
|
||||
<td><?php echo htmlspecialchars(ucfirst($transaction['billing_cycle'] ?? '-')); ?></td>
|
||||
<td>
|
||||
<span class="text-muted small"><?php echo htmlspecialchars($transaction['reference_number'] ?? '-'); ?></span>
|
||||
<?php if (!empty($transaction['description'])): ?>
|
||||
<br><small class="text-info"><?php echo htmlspecialchars(substr($transaction['description'], 0, 50)); ?><?php echo strlen($transaction['description']) > 50 ? '...' : ''; ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('dateFilterForm');
|
||||
const fromDate = document.getElementById('from_date');
|
||||
const toDate = document.getElementById('to_date');
|
||||
|
||||
// Form validation with SweetAlert2 (same as dashboard)
|
||||
form.addEventListener('submit', function(e) {
|
||||
const fromValue = fromDate.value;
|
||||
const toValue = toDate.value;
|
||||
|
||||
// Allow submission if both dates are empty (show all data)
|
||||
if (!fromValue && !toValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If one date is filled, both must be filled (same as dashboard)
|
||||
if (!fromValue || !toValue) {
|
||||
e.preventDefault();
|
||||
showDarkAlert({
|
||||
icon: 'warning',
|
||||
title: 'Date Selection Required',
|
||||
text: 'Please select both from and to dates, or leave both empty to show all data.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if (new Date(fromValue) > new Date(toValue)) {
|
||||
e.preventDefault();
|
||||
showDarkAlert({
|
||||
icon: 'error',
|
||||
title: 'Invalid Date Range',
|
||||
text: 'From date cannot be later than to date.',
|
||||
confirmButtonText: 'OK'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Set max date for from_date when to_date changes
|
||||
toDate.addEventListener('change', function() {
|
||||
fromDate.max = this.value;
|
||||
});
|
||||
|
||||
// Set min date for to_date when from_date changes
|
||||
fromDate.addEventListener('change', function() {
|
||||
toDate.min = this.value;
|
||||
});
|
||||
});
|
||||
|
||||
// Quick filter functions for common date ranges
|
||||
function setQuickFilter(days) {
|
||||
const toDate = new Date();
|
||||
const fromDate = new Date();
|
||||
fromDate.setDate(toDate.getDate() - parseInt(days));
|
||||
|
||||
document.getElementById('from_date').value = fromDate.toISOString().split('T')[0];
|
||||
document.getElementById('to_date').value = toDate.toISOString().split('T')[0];
|
||||
|
||||
// Add loading state to all quick filter buttons
|
||||
const quickFilterButtons = document.querySelectorAll('.quick-filter-btn');
|
||||
quickFilterButtons.forEach(btn => btn.classList.add('loading'));
|
||||
|
||||
// Show loading message and auto-submit the form
|
||||
showDarkAlert({
|
||||
title: 'Applying Filter...',
|
||||
text: `Loading transactions for the last ${days} days`,
|
||||
allowOutsideClick: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-submit the form
|
||||
setTimeout(() => {
|
||||
document.getElementById('dateFilterForm').submit();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Set current month filter
|
||||
function setCurrentMonth() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
document.getElementById('from_date').value = firstDay.toISOString().split('T')[0];
|
||||
document.getElementById('to_date').value = lastDay.toISOString().split('T')[0];
|
||||
|
||||
// Add loading state to all quick filter buttons
|
||||
const quickFilterButtons = document.querySelectorAll('.quick-filter-btn');
|
||||
quickFilterButtons.forEach(btn => btn.classList.add('loading'));
|
||||
|
||||
// Show loading message and auto-submit the form
|
||||
showDarkAlert({
|
||||
title: 'Applying Filter...',
|
||||
text: 'Loading transactions for this month',
|
||||
allowOutsideClick: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-submit the form
|
||||
setTimeout(() => {
|
||||
document.getElementById('dateFilterForm').submit();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Set current year filter
|
||||
function setCurrentYear() {
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), 0, 1);
|
||||
const lastDay = new Date(now.getFullYear(), 11, 31);
|
||||
|
||||
document.getElementById('from_date').value = firstDay.toISOString().split('T')[0];
|
||||
document.getElementById('to_date').value = lastDay.toISOString().split('T')[0];
|
||||
|
||||
// Add loading state to all quick filter buttons
|
||||
const quickFilterButtons = document.querySelectorAll('.quick-filter-btn');
|
||||
quickFilterButtons.forEach(btn => btn.classList.add('loading'));
|
||||
|
||||
// Show loading message and auto-submit the form
|
||||
showDarkAlert({
|
||||
title: 'Applying Filter...',
|
||||
text: 'Loading transactions for this year',
|
||||
allowOutsideClick: false,
|
||||
showConfirmButton: false,
|
||||
didOpen: () => {
|
||||
Swal.showLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-submit the form
|
||||
setTimeout(() => {
|
||||
document.getElementById('dateFilterForm').submit();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Add visual feedback for quick filter buttons
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const quickFilterButtons = document.querySelectorAll('.quick-filter-btn');
|
||||
quickFilterButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
// Add active state to clicked button
|
||||
quickFilterButtons.forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.quick-filter-btn {
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.quick-filter-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.quick-filter-btn.active {
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
|
||||
.bg-purple {
|
||||
background-color: #6f42c1 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.quick-filter-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.d-flex.flex-wrap {
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading animation for filter buttons */
|
||||
.quick-filter-btn.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.quick-filter-btn.loading::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-left: 8px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
125
app/Views/dashboard/subscriptions/create.php
Normal file
125
app/Views/dashboard/subscriptions/create.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add Subscription</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/subscriptions">Subscriptions</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Subscription Details</h4>
|
||||
<p class="card-title-desc">Fill out the form below to add a new subscription.</p>
|
||||
<form action="/subscriptions" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Subscription Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="amount" class="form-label">Amount</label>
|
||||
<input type="number" id="amount" name="amount" class="form-control" required step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="currency" class="form-label">Currency</label>
|
||||
<input type="text" id="currency" name="currency" class="form-control" required value="USD">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="credit_card_id" class="form-label">Credit Card</label>
|
||||
<select id="credit_card_id" name="credit_card_id" class="form-select" required>
|
||||
<option value="">Select a card</option>
|
||||
<?php foreach ($credit_cards as $card): ?>
|
||||
<option value="<?php echo $card['id']; ?>"><?php echo htmlspecialchars($card['name']); ?> (**** <?php echo $card['card_number_last4']; ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="billing_cycle" class="form-label">Billing Cycle</label>
|
||||
<select id="billing_cycle" name="billing_cycle" class="form-select" required>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option value="one-time">One-Time</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="quarterly">Quarterly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3" id="next_payment_date_group">
|
||||
<label for="next_payment_date" class="form-label">Next Payment Date</label>
|
||||
<input type="date" id="next_payment_date" name="next_payment_date" class="form-control" required>
|
||||
<small class="form-text text-muted">Not required for one-time payments</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select id="status" name="status" class="form-select" required>
|
||||
<option value="active">Active</option>
|
||||
<option value="expired">Expired</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Add Subscription</button>
|
||||
<a href="/subscriptions" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const billingCycleSelect = document.getElementById('billing_cycle');
|
||||
const nextPaymentDateInput = document.getElementById('next_payment_date');
|
||||
const nextPaymentDateGroup = document.getElementById('next_payment_date_group');
|
||||
|
||||
function toggleNextPaymentDate() {
|
||||
if (billingCycleSelect.value === 'one-time') {
|
||||
nextPaymentDateInput.removeAttribute('required');
|
||||
nextPaymentDateInput.value = '';
|
||||
nextPaymentDateGroup.style.opacity = '0.5';
|
||||
nextPaymentDateInput.disabled = true;
|
||||
} else {
|
||||
nextPaymentDateInput.setAttribute('required', 'required');
|
||||
nextPaymentDateGroup.style.opacity = '1';
|
||||
nextPaymentDateInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check
|
||||
toggleNextPaymentDate();
|
||||
|
||||
// Listen for changes
|
||||
billingCycleSelect.addEventListener('change', toggleNextPaymentDate);
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
125
app/Views/dashboard/subscriptions/edit.php
Normal file
125
app/Views/dashboard/subscriptions/edit.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Subscription</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/subscriptions">Subscriptions</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Update Subscription Details</h4>
|
||||
<form action="/subscriptions/<?php echo $subscription['id']; ?>" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Subscription Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" value="<?php echo htmlspecialchars($subscription['name']); ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control"><?php echo htmlspecialchars($subscription['description']); ?></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="amount" class="form-label">Amount</label>
|
||||
<input type="number" id="amount" name="amount" class="form-control" value="<?php echo htmlspecialchars($subscription['amount']); ?>" required step="0.01">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="currency" class="form-label">Currency</label>
|
||||
<input type="text" id="currency" name="currency" class="form-control" value="<?php echo htmlspecialchars($subscription['currency']); ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="credit_card_id" class="form-label">Credit Card</label>
|
||||
<select id="credit_card_id" name="credit_card_id" class="form-select" required>
|
||||
<option value="">Select a card</option>
|
||||
<?php foreach ($credit_cards as $card): ?>
|
||||
<option value="<?php echo $card['id']; ?>" <?php if ($card['id'] == $subscription['credit_card_id']) echo 'selected'; ?>>
|
||||
<?php echo htmlspecialchars($card['name']); ?> (**** <?php echo $card['card_number_last4']; ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="billing_cycle" class="form-label">Billing Cycle</label>
|
||||
<select id="billing_cycle" name="billing_cycle" class="form-select" required>
|
||||
<option value="monthly" <?php if ($subscription['billing_cycle'] == 'monthly') echo 'selected'; ?>>Monthly</option>
|
||||
<option value="yearly" <?php if ($subscription['billing_cycle'] == 'yearly') echo 'selected'; ?>>Yearly</option>
|
||||
<option value="one-time" <?php if ($subscription['billing_cycle'] == 'one-time') echo 'selected'; ?>>One-Time</option>
|
||||
<option value="weekly" <?php if ($subscription['billing_cycle'] == 'weekly') echo 'selected'; ?>>Weekly</option>
|
||||
<option value="quarterly" <?php if ($subscription['billing_cycle'] == 'quarterly') echo 'selected'; ?>>Quarterly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3" id="next_payment_date_group">
|
||||
<label for="next_payment_date" class="form-label">Next Payment Date</label>
|
||||
<input type="date" id="next_payment_date" name="next_payment_date" class="form-control" value="<?php echo htmlspecialchars($subscription['next_payment_date']); ?>" required>
|
||||
<small class="form-text text-muted">Not required for one-time payments</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="status" class="form-label">Status</label>
|
||||
<select id="status" name="status" class="form-select" required>
|
||||
<option value="active" <?php if ($subscription['status'] == 'active') echo 'selected'; ?>>Active</option>
|
||||
<option value="expired" <?php if ($subscription['status'] == 'expired') echo 'selected'; ?>>Expired</option>
|
||||
<option value="cancelled" <?php if ($subscription['status'] == 'cancelled') echo 'selected'; ?>>Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">Update Subscription</button>
|
||||
<a href="/subscriptions" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const billingCycleSelect = document.getElementById('billing_cycle');
|
||||
const nextPaymentDateInput = document.getElementById('next_payment_date');
|
||||
const nextPaymentDateGroup = document.getElementById('next_payment_date_group');
|
||||
|
||||
function toggleNextPaymentDate() {
|
||||
if (billingCycleSelect.value === 'one-time') {
|
||||
nextPaymentDateInput.removeAttribute('required');
|
||||
nextPaymentDateGroup.style.opacity = '0.5';
|
||||
nextPaymentDateInput.disabled = true;
|
||||
} else {
|
||||
nextPaymentDateInput.setAttribute('required', 'required');
|
||||
nextPaymentDateGroup.style.opacity = '1';
|
||||
nextPaymentDateInput.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial check
|
||||
toggleNextPaymentDate();
|
||||
|
||||
// Listen for changes
|
||||
billingCycleSelect.addEventListener('change', toggleNextPaymentDate);
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
67
app/Views/dashboard/subscriptions/index.php
Normal file
67
app/Views/dashboard/subscriptions/index.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Subscriptions</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Subscriptions</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Subscriptions</h4>
|
||||
<a href="/subscriptions/create" class="btn btn-primary">Add New Subscription</a>
|
||||
</div>
|
||||
|
||||
<table id="subscriptions-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>User</th>
|
||||
<th>Amount</th>
|
||||
<th>Billing Cycle</th>
|
||||
<th>Next Payment</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($subscriptions as $subscription): ?>
|
||||
<tr>
|
||||
<td><?php echo $subscription['id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($subscription['name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($subscription['user_name'] ?? 'Unknown'); ?></td>
|
||||
<td><?php echo htmlspecialchars($subscription['amount'] . ' ' . $subscription['currency']); ?></td>
|
||||
<td><?php echo htmlspecialchars(ucfirst($subscription['billing_cycle'])); ?></td>
|
||||
<td><?php echo htmlspecialchars($subscription['next_payment_date']); ?></td>
|
||||
<td><span class="badge bg-<?php echo $subscription['status'] == 'active' ? 'success' : 'danger'; ?>"><?php echo htmlspecialchars(ucfirst($subscription['status'])); ?></span></td>
|
||||
<td>
|
||||
<a href="/subscriptions/<?php echo $subscription['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<form action="/subscriptions/<?php echo $subscription['id']; ?>/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger delete-btn">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
133
app/Views/dashboard/tags/create.php
Normal file
133
app/Views/dashboard/tags/create.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add Tag</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/tags">Tags</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Tag Details</h4>
|
||||
<p class="card-title-desc">Fill out the form below to add a new expense tag.</p>
|
||||
|
||||
<form action="/tags" method="POST" class="tag-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Tag Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required maxlength="100" placeholder="Enter tag name">
|
||||
<div class="form-text">This will be the display name for the tag.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3" maxlength="500" placeholder="Optional description for this tag"></textarea>
|
||||
<div class="form-text">Optional description to help identify this tag.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="color" class="form-label">Color</label>
|
||||
<input type="color" id="color" name="color" class="form-control form-control-color" value="#10B981" title="Choose tag color">
|
||||
<div class="form-text">Select a color to visually identify this tag.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Default Tags</label>
|
||||
<div class="row">
|
||||
<?php foreach ($defaultTags as $default): ?>
|
||||
<div class="col-lg-4 col-md-6 col-12 mb-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input default-tag" type="radio" name="default_tag" value="<?php echo htmlspecialchars($default['name']); ?>" data-color="<?php echo htmlspecialchars($default['color']); ?>" data-description="<?php echo htmlspecialchars($default['description']); ?>">
|
||||
<label class="form-check-label">
|
||||
<span class="badge" style="background-color: <?php echo htmlspecialchars($default['color']); ?>; color: white;">
|
||||
<?php echo htmlspecialchars($default['name']); ?>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="form-text">Select a default tag to auto-fill the form, or create a custom tag.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="tag-preview">
|
||||
<label class="form-label">Preview</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="tag-preview-badge" class="badge" style="background-color: #10B981; color: white;">
|
||||
<span id="tag-preview-name">Tag Name</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="d-flex flex-column flex-sm-row gap-2">
|
||||
<button type="submit" class="btn btn-primary flex-sm-fill">
|
||||
<i class="fas fa-save me-2"></i>Add Tag
|
||||
</button>
|
||||
<a href="/tags" class="btn btn-secondary flex-sm-fill">
|
||||
<i class="fas fa-times me-2"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Elements
|
||||
const nameInput = document.getElementById('name');
|
||||
const colorInput = document.getElementById('color');
|
||||
const previewBadge = document.getElementById('tag-preview-badge');
|
||||
const previewName = document.getElementById('tag-preview-name');
|
||||
const defaultTagRadios = document.querySelectorAll('.default-tag');
|
||||
|
||||
// Update preview
|
||||
function updatePreview() {
|
||||
const name = nameInput.value || 'Tag Name';
|
||||
const color = colorInput.value || '#10B981';
|
||||
|
||||
previewBadge.style.backgroundColor = color;
|
||||
previewName.textContent = name;
|
||||
}
|
||||
|
||||
// Default tag selection
|
||||
defaultTagRadios.forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
nameInput.value = this.value;
|
||||
colorInput.value = this.dataset.color;
|
||||
document.getElementById('description').value = this.dataset.description;
|
||||
updatePreview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Live preview updates
|
||||
nameInput.addEventListener('input', updatePreview);
|
||||
colorInput.addEventListener('input', updatePreview);
|
||||
|
||||
// Initial preview
|
||||
updatePreview();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
127
app/Views/dashboard/tags/edit.php
Normal file
127
app/Views/dashboard/tags/edit.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Tag</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/tags">Tags</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Edit Tag Details</h4>
|
||||
<p class="card-title-desc">Update the tag information below.</p>
|
||||
|
||||
<form action="/tags/<?php echo $tag['id']; ?>" method="POST">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<input type="hidden" name="_method" value="PUT">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Tag Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="name" name="name" class="form-control" required maxlength="100" value="<?php echo htmlspecialchars($tag['name']); ?>" placeholder="Enter tag name">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Description</label>
|
||||
<textarea id="description" name="description" class="form-control" rows="3" maxlength="500" placeholder="Optional description for this tag"><?php echo htmlspecialchars($tag['description'] ?? ''); ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="color" class="form-label">Color</label>
|
||||
<input type="color" id="color" name="color" class="form-control form-control-color" value="<?php echo htmlspecialchars($tag['color']); ?>" title="Choose tag color">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Preview</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="tag-preview-badge" class="badge" style="background-color: <?php echo htmlspecialchars($tag['color']); ?>; color: white;">
|
||||
<span id="tag-preview-name"><?php echo htmlspecialchars($tag['name']); ?></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">Update Tag</button>
|
||||
<a href="/tags" class="btn btn-secondary">Cancel</a>
|
||||
<?php if (($tag['expense_count'] ?? 0) == 0): ?>
|
||||
<button type="button" class="btn btn-danger float-end" onclick="confirmDelete()">Delete Tag</button>
|
||||
<?php else: ?>
|
||||
<button type="button" class="btn btn-outline-danger float-end" disabled title="Cannot delete tag with expenses">Delete Tag</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<!-- Delete Form (hidden) -->
|
||||
<form id="delete-form" action="/tags/<?php echo $tag['id']; ?>/delete" method="POST" style="display: none;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const nameInput = document.getElementById('name');
|
||||
const colorInput = document.getElementById('color');
|
||||
const previewBadge = document.getElementById('tag-preview-badge');
|
||||
const previewName = document.getElementById('tag-preview-name');
|
||||
|
||||
function updatePreview() {
|
||||
const name = nameInput.value || 'Tag Name';
|
||||
const color = colorInput.value || '#10B981';
|
||||
|
||||
previewBadge.style.backgroundColor = color;
|
||||
previewName.textContent = name;
|
||||
}
|
||||
|
||||
nameInput.addEventListener('input', updatePreview);
|
||||
colorInput.addEventListener('input', updatePreview);
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
showDarkAlert({
|
||||
title: 'Delete Tag',
|
||||
text: 'Are you sure you want to delete this tag? This action cannot be undone and will remove the tag from all associated expenses.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
document.getElementById('delete-form').submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
120
app/Views/dashboard/tags/index.php
Normal file
120
app/Views/dashboard/tags/index.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Tags</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Tags</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Tags</h4>
|
||||
<a href="/tags/create" class="btn btn-primary">Add New Tag</a>
|
||||
</div>
|
||||
|
||||
<table id="tags-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Expenses</th>
|
||||
<th>Owner</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tags as $tag): ?>
|
||||
<tr>
|
||||
<td><?php echo $tag['id']; ?></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: <?php echo htmlspecialchars($tag['color']); ?>; color: white;">
|
||||
<?php echo htmlspecialchars($tag['name']); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($tag['description'] ?? ''); ?></td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="color-preview me-2" style="background-color: <?php echo htmlspecialchars($tag['color']); ?>; width: 20px; height: 20px; display: inline-block; border-radius: 50%; border: 1px solid #ccc;"></span>
|
||||
<span><?php echo htmlspecialchars($tag['color']); ?></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">
|
||||
<?php echo number_format($tag['expense_count'] ?? 0); ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo htmlspecialchars($tag['creator_name'] ?? 'Unknown'); ?></td>
|
||||
<td><?php echo date('M j, Y', strtotime($tag['created_at'])); ?></td>
|
||||
<td>
|
||||
<a href="/tags/<?php echo $tag['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<form action="/tags/<?php echo $tag['id']; ?>/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger delete-btn">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<script>
|
||||
// SweetAlert2 dark theme function
|
||||
function showDarkAlert(options) {
|
||||
const defaultOptions = {
|
||||
background: '#2d3748',
|
||||
color: '#ffffff',
|
||||
confirmButtonColor: '#4299e1',
|
||||
customClass: {
|
||||
popup: 'swal-dark-popup'
|
||||
}
|
||||
};
|
||||
|
||||
Swal.fire({...defaultOptions, ...options});
|
||||
}
|
||||
|
||||
// Delete confirmation
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const deleteButtons = document.querySelectorAll('.delete-btn');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const form = this.closest('form');
|
||||
showDarkAlert({
|
||||
title: 'Delete Tag',
|
||||
text: 'Are you sure you want to delete this tag? This action cannot be undone and will remove the tag from all associated expenses.',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#e74a3b'
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
56
app/Views/dashboard/users/create.php
Normal file
56
app/Views/dashboard/users/create.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Add Admin</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/users">Admins</a></li>
|
||||
<li class="breadcrumb-item active">Add New</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">New Admin Details</h4>
|
||||
<form action="/users" method="POST" class="user-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Role</label>
|
||||
<select id="role" name="role" class="form-select" required>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="superadmin">Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3 user-actions">
|
||||
<button type="submit" class="btn btn-primary">Add Admin</button>
|
||||
<a href="/users" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
57
app/Views/dashboard/users/edit.php
Normal file
57
app/Views/dashboard/users/edit.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Edit Admin</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item"><a href="/users">Admins</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Update Admin Details</h4>
|
||||
<form action="/users/<?php echo $user['id']; ?>" method="POST" class="user-form">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<input type="text" id="name" name="name" class="form-control" value="<?php echo htmlspecialchars($user['name']); ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" value="<?php echo htmlspecialchars($user['email']); ?>" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">New Password</label>
|
||||
<input type="password" id="password" name="password" class="form-control" placeholder="Leave blank to keep current password">
|
||||
<small class="form-text text-muted">Leave this field blank if you don't want to change the password.</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Role</label>
|
||||
<select id="role" name="role" class="form-select" required>
|
||||
<option value="admin" <?php if ($user['role'] == 'admin') echo 'selected'; ?>>Admin</option>
|
||||
<option value="superadmin" <?php if ($user['role'] == 'superadmin') echo 'selected'; ?>>Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3 user-actions">
|
||||
<button type="submit" class="btn btn-primary">Update Admin</button>
|
||||
<a href="/users" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
65
app/Views/dashboard/users/index.php
Normal file
65
app/Views/dashboard/users/index.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php defined('APP_RAN') or die('Direct access not allowed'); ?>
|
||||
<?php require_once __DIR__ . '/../../layouts/header.php'; ?>
|
||||
|
||||
<div class="container-fluid">
|
||||
<!-- Page-Title -->
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<div class="page-title-box">
|
||||
<h4 class="page-title">Admin Users</h4>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">Dashboard</a></li>
|
||||
<li class="breadcrumb-item active">Admins</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<h4 class="card-title">All Admins</h4>
|
||||
<a href="/users/create" class="btn btn-primary">Add New Admin</a>
|
||||
</div>
|
||||
|
||||
<table id="users-table" class="table table-striped table-bordered dt-responsive nowrap" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Joined On</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $user): ?>
|
||||
<tr>
|
||||
<td><?php echo $user['id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($user['name']); ?></td>
|
||||
<td><?php echo htmlspecialchars($user['email']); ?></td>
|
||||
<td><?php echo date('Y-m-d', strtotime($user['created_at'])); ?></td>
|
||||
<td><span class="badge bg-info"><?php echo htmlspecialchars(ucfirst($user['role'])); ?></span></td>
|
||||
<td>
|
||||
<a href="/users/<?php echo $user['id']; ?>/edit" class="btn btn-sm btn-info">Edit</a>
|
||||
<?php if ($user['id'] != $_SESSION['user']['id']): // Prevent self-delete button from even rendering ?>
|
||||
<form action="/users/<?php echo $user['id']; ?>/delete" method="POST" style="display:inline;">
|
||||
<input type="hidden" name="_token" value="<?php echo htmlspecialchars($csrf_token ?? ''); ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger delete-btn">Delete</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- end col -->
|
||||
</div> <!-- end row -->
|
||||
</div> <!-- container-fluid -->
|
||||
|
||||
<?php require_once __DIR__ . '/../../layouts/footer.php'; ?>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user