10 Commits
v3.0 ... 2.3.1

Author SHA1 Message Date
Darko Gjorgjijoski
88650c2f3e Bump version 2026-04-05 12:34:24 +02:00
Darko Gjorgjijoski
ee76f31138 Add log mail driver support to frontend
The default mail driver in config/mail.php is 'log', which had no
matching Vue component, causing the mail configuration step in the
install wizard (and settings page) to render empty.
2026-04-05 12:33:14 +02:00
Darko Gjorgjijoski
e1af9f56c4 Docker optimizations 2026-04-05 12:07:47 +02:00
mchev
fdd860c381 Merge pull request #612 from mchev/master
Ensure public/storage symlink exists in Docker production entrypoint
2026-04-04 18:57:09 +02:00
Darko Gjorgjijoski
5ff051fbb5 Bump version from 2.2.1 to 2.3.0 2026-04-04 17:12:13 +02:00
Darko Gjorgjijoski
0d7059fcf6 Fix logout/re-login CSRF mismatch and stale token issues
Cherry-picked from v3.0 branch. Three fixes:
1. Refresh CSRF cookie after logout (auth.js)
2. Clear auth.token and selectedCompany from localStorage on logout (auth.js)
3. Invalidate session and regenerate CSRF token on server-side logout (web.php)

Without these, logging out and back in as a different user would fail
with CSRF token mismatch and 401 Unauthenticated errors because the
browser held stale session cookies and localStorage tokens.
2026-04-03 23:53:56 +02:00
mchev
77fd96d499 Merge branch 'master' of https://github.com/mchev/InvoiceShelf 2026-04-02 16:43:57 +02:00
mchev
2e4e19dfc5 Remove testing image 2026-04-02 16:43:22 +02:00
mchev
76c02be219 Merge branch 'InvoiceShelf:master' into master 2026-04-02 16:42:13 +02:00
mchev
de4ba6bba0 Fix storage link on docker 2026-04-02 11:56:16 +02:00
1149 changed files with 59146 additions and 65185 deletions

View File

@@ -1,5 +0,0 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}

View File

@@ -21,8 +21,3 @@ TRUSTED_PROXIES="*"
# Dompdf: keep false so untrusted HTML in PDF notes cannot trigger outbound requests (SSRF).
# Set true only if you fully trust all PDF HTML and need remote images/CSS.
DOMPDF_ENABLE_REMOTE=false
# InvoiceShelf marketplace and updater base URL.
# Defaults to https://invoiceshelf.com. Override to point at a local website
# checkout for development:
# INVOICESHELF_BASE_URL=http://invoiceshelf-website.test

18
.github/release.yml vendored
View File

@@ -1,18 +0,0 @@
changelog:
categories:
- title: New Features
labels:
- enhancement
- feature
- title: Bug Fixes
labels:
- bug
- fix
- title: Maintenance
labels:
- chore
- dependencies
- ci
- title: Other Changes
labels:
- "*"

View File

@@ -1,96 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
name: Build & Release
runs-on: ubuntu-latest
env:
extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
extensions: ${{ env.extensions }}
tools: composer
- name: Install Composer dependencies
run: composer install --no-dev --optimize-autoloader --no-interaction
- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
- name: Install npm dependencies
run: npm ci
- name: Build frontend
run: npm run build
- name: Prepare release directory
run: |
mkdir -p /tmp/InvoiceShelf/public
cp -r app /tmp/InvoiceShelf/
cp -r bootstrap /tmp/InvoiceShelf/
cp -r config /tmp/InvoiceShelf/
cp -r database /tmp/InvoiceShelf/
cp -r public/build /tmp/InvoiceShelf/public/
cp -r public/favicons /tmp/InvoiceShelf/public/
cp public/.htaccess /tmp/InvoiceShelf/public/
cp public/index.php /tmp/InvoiceShelf/public/
cp public/robots.txt /tmp/InvoiceShelf/public/
cp public/web.config /tmp/InvoiceShelf/public/
cp -r resources /tmp/InvoiceShelf/
cp -r lang /tmp/InvoiceShelf/
cp -r routes /tmp/InvoiceShelf/
cp -r storage /tmp/InvoiceShelf/
cp -r vendor /tmp/InvoiceShelf/ 2>/dev/null || true
cp -r scripts /tmp/InvoiceShelf/
cp version.md /tmp/InvoiceShelf/
cp .env.example /tmp/InvoiceShelf/
cp artisan /tmp/InvoiceShelf/
cp composer.json /tmp/InvoiceShelf/
cp composer.lock /tmp/InvoiceShelf/
cp LICENSE /tmp/InvoiceShelf/
cp readme.md /tmp/InvoiceShelf/
cp SECURITY.md /tmp/InvoiceShelf/
cp server.php /tmp/InvoiceShelf/
# Clean up runtime artifacts
find /tmp/InvoiceShelf -wholename '*/[Tt]ests/*' -delete
find /tmp/InvoiceShelf -wholename '*/[Tt]est/*' -delete
rm -rf /tmp/InvoiceShelf/storage/framework/cache/data/* 2>/dev/null || true
rm -f /tmp/InvoiceShelf/storage/framework/sessions/* 2>/dev/null || true
rm -f /tmp/InvoiceShelf/storage/framework/views/* 2>/dev/null || true
rm -f /tmp/InvoiceShelf/storage/logs/* 2>/dev/null || true
touch /tmp/InvoiceShelf/storage/logs/laravel.log
- name: Generate manifest
run: php scripts/generate-manifest.php /tmp/InvoiceShelf
- name: Create zip
working-directory: /tmp
run: zip -r InvoiceShelf.zip InvoiceShelf/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: /tmp/InvoiceShelf.zip
generate_release_notes: true
make_latest: true

View File

@@ -64,8 +64,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -73,7 +73,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -147,8 +147,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -156,7 +156,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -230,8 +230,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -239,7 +239,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -313,8 +313,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -322,7 +322,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -396,8 +396,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -405,7 +405,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -479,8 +479,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -488,7 +488,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -562,8 +562,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -571,7 +571,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -645,8 +645,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -654,7 +654,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -728,8 +728,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -737,7 +737,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -811,8 +811,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -820,7 +820,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,
@@ -894,8 +894,8 @@ namespace PHPSTORM_META {
'flare.logger' => \Monolog\Logger::class,
'hash' => \Illuminate\Hashing\HashManager::class,
'hash.driver' => \Illuminate\Hashing\BcryptHasher::class,
'hashids' => \App\Services\Hashids\HashidsManager::class,
'hashids.factory' => \App\Services\Hashids\HashidsFactory::class,
'hashids' => \App\Hashids\HashidsManager::class,
'hashids.factory' => \App\Hashids\HashidsFactory::class,
'log' => \Illuminate\Log\LogManager::class,
'mail.manager' => \Illuminate\Mail\MailManager::class,
'mailer' => \Illuminate\Mail\Mailer::class,
@@ -903,7 +903,7 @@ namespace PHPSTORM_META {
'migration.creator' => \Illuminate\Database\Migrations\MigrationCreator::class,
'migration.repository' => \Illuminate\Database\Migrations\DatabaseMigrationRepository::class,
'migrator' => \Illuminate\Database\Migrations\Migrator::class,
'pdf.driver' => \App\Services\Pdf\PdfService::class,
'pdf.driver' => \App\Services\PDFService::class,
'pipeline' => \Illuminate\Pipeline\Pipeline::class,
'queue' => \Illuminate\Queue\QueueManager::class,
'queue.connection' => \Illuminate\Queue\SyncQueue::class,

View File

@@ -59,27 +59,6 @@ This project has domain-specific skills available. You MUST activate the relevan
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== invoiceshelf rules ===
# InvoiceShelf Architecture
## Service Pattern (Required)
All business logic must live in Service classes (`app/Services/`). This is mandatory — do not put business logic in Models or Controllers.
- **Controllers** are thin: authorize, call the service, return a response.
- **Models** only contain relationships, scopes, accessors, mutators, and constants.
- **Services** are injected into controllers via constructor injection.
- Check existing services in `app/Services/` for patterns before creating new ones.
## Testing (TDD)
InvoiceShelf follows TDD development style. Every new feature or bug fix must have tests.
- **Feature tests** (`tests/Feature/`) — test API routes end-to-end (HTTP requests, responses, database assertions). These are the primary test type.
- **Unit tests** (`tests/Unit/`) — test service classes and business logic in isolation.
- Write tests before or alongside implementation, not after.
## Roles
- **`super admin`** — global platform admin role (unscoped, manages all companies)
- **`owner`** — company-level admin role (scoped to a specific company via Bouncer, full access to that company)
=== boost rules ===
# Laravel Boost

View File

@@ -47,82 +47,33 @@ Three guards: `web` (session), `api` (Sanctum tokens for `/api/v1/`), `customer`
- **Web**: `routes/web.php` serves PDF endpoints, auth pages, and catch-all SPA routes (`/admin/{vue?}`, `/{company:slug}/customer/{vue?}`)
### Frontend
- Vue 3 + TypeScript + Pinia + vue-router + Tailwind v4 (`@tailwindcss/vite`)
- Entry point: `resources/scripts/main.ts` (single Vite input)
- Feature-folder layout under `resources/scripts/features/{admin,auth,company,customer-portal,...}` — each feature owns its own `routes.ts`, `views/`, `components/`
- Shared layers: `resources/scripts/{api,stores,components,composables,layouts,plugins,utils,types,config}`
- Path aliases: `@``resources/` (so most imports look like `@/scripts/api/client`, `@/scripts/stores/global.store`); `$fonts``resources/static/fonts`; `$images``resources/static/img`. There is no `@v2` alias — that was retired when the legacy v1 SPA was deleted.
- i18n: `lang/*.json` are dynamically imported by `resources/scripts/plugins/i18n.ts`. Locale-code → filename mismatches (e.g. `pt_BR``pt-br.json`) live in `LOCALE_FILE_MAP`. English is statically bundled; other locales lazy-load. Only edit `lang/en.json` directly — other locales are Crowdin-sourced.
- Vite dev server expects the `invoiceshelf.test` hostname (configured in `vite.config.js`)
### CSS Theme Tokens
The styling system uses **Tailwind v4 with CSS custom properties as the source of truth** — colors are not configured in JS, they live in CSS and are exposed to Tailwind via the `@theme` directive. Two files own this:
1. **`resources/css/themes.css`** — defines every color as a CSS custom property on `:root` (light) and `[data-theme="dark"]` (dark). This is where you change actual values.
2. **`resources/css/invoiceshelf.css`** — has an `@theme inline { ... }` block that **registers** each custom property as a Tailwind theme token (e.g. `--color-heading: var(--color-heading);`), making it available as utility classes (`bg-heading`, `text-heading`, `border-heading`, etc.). The block also uses the legacy `@theme { --spacing-88: 22rem; --font-base: Poppins, sans-serif; }` for non-color tokens.
**Token categories defined today:**
- `primary-{50…950}` — brand color scale
- `surface`, `surface-secondary`, `surface-tertiary`, `surface-muted` — background depth tiers
- `heading`, `body`, `muted`, `subtle` — text emphasis tiers
- `line-{light,default,strong}` — borders
- `hover`, `hover-strong` — hover backgrounds
- `header-from`, `header-to` — fixed header gradient stops (not dark-mode-aware)
- `btn-primary`, `btn-primary-hover` — button colors (fixed, always bold)
- `status-{yellow,green,blue,red,purple}` — status badge text colors
- `alert-{warning,error,success}-{bg,text}` — alert variants
**Dark mode** is toggled via the `[data-theme="dark"]` attribute on the `<html>` element. The same custom-property names get redefined under that selector — components do **not** need `dark:` variants or conditional logic, they just reference the semantic tokens and the right value is picked up automatically.
**Adding a new color token is a two-step ritual:**
1. Add the custom property to **both** `:root` and `[data-theme="dark"]` in `themes.css`
2. Add a matching `--color-X: var(--color-X);` line inside the `@theme inline` block in `invoiceshelf.css`
After that the token is usable as `bg-X` / `text-X` / `border-X` in Vue templates and as `var(--color-X)` in raw CSS. Skip step 2 and the value exists at the CSS level but Tailwind utility classes won't be generated.
**Convention — never hardcode hex/rgb values in components.** Use the semantic tokens: `text-heading` not `text-gray-900`, `bg-surface` not `bg-white`, `border-line-default` not `border-gray-300`. Hardcoded values won't follow dark-mode flips and will diverge from the rest of the app over time. There are **no exceptions** in the project — even the auth pages (which sit outside the admin chrome) use the same `bg-surface` / `text-heading` / `border-line-default` vocabulary as `BaseCard`, just composed differently.
- Entry point: `resources/scripts/main.js`
- Vue Router: `resources/scripts/admin/admin-router.js` (admin), `resources/scripts/customer/customer-router.js` (customer portal)
- State: Pinia stores in `resources/scripts/admin/stores/`
- Path aliases: `@` = `resources/`, `$fonts`, `$images` for static assets
- Vite dev server expects `invoiceshelf.test` hostname
### Backend Patterns
- **Authorization**: Silber/Bouncer with policies in `app/Policies/`. Controllers use `$this->authorize()`.
- **Validation**: Form Request classes, never inline validation
- **API responses**: Eloquent API Resources in `app/Http/Resources/`
- **PDF generation**: Pluggable driver — `dompdf` (default, via `GeneratesPdfTrait`) or `gotenberg` (headless Chromium). Driver chosen per company through the **PDF Generation** admin settings page.
- **Email**: Mailable classes with `EmailLog` tracking. Mail driver is configurable globally and may be overridden per-company.
- **File storage**: Spatie MediaLibrary backed by the **FileDisk** model — admins create named disk entries (local / S3 / Dropbox / DigitalOcean Spaces) and assign them to purposes (`media_storage`, `pdf_storage`, `backup_storage`) in **Admin → File Disks → Disk Assignments**. New uploads go to the assigned disk; existing files stay where they were and require `php artisan media:secure` to migrate.
- **Serial numbers**: `SerialNumberService`
- **PDF generation**: DomPDF (`GeneratesPdfTrait`) or Gotenberg
- **Email**: Mailable classes with `EmailLog` tracking
- **File storage**: Spatie MediaLibrary, supports local/S3/Dropbox
- **Serial numbers**: `SerialNumberFormatter` service
- **Company settings**: `CompanySetting` model (key-value per company)
- **User settings**: User-level preferences (notably `language`) stored as JSON via `setSettings()`. The sentinel value `'default'` means "inherit the company-level setting" — used for the per-user language preference so promoting/inviting members doesn't freeze a copy of the inviter's language.
### PDF Font System
PDFs ship with bundled **Noto Sans** (Latin / Greek / Cyrillic) as the default face. Non-Latin scripts come from on-demand **Font Packages** managed in **Admin → Font Packages** and defined in `FontService::FONT_PACKAGES` (`app/Services/FontService.php`). Currently shipped packages: `noto-sans` (bundled, marker only), `noto-sans-{sc,tc,jp,kr}` (CJK), `noto-sans-hebrew`, `noto-naskh-arabic` (covers `ar`/`fa`/`ur`), `noto-sans-devanagari` (`hi`), `sarabun` (Thai). `GeneratesPdfTrait::ensureFontsForLocale()` synchronously installs the matching package on the first PDF render for a given company language.
Two non-obvious constraints when extending the font system:
1. **dompdf's PHP-Font-Lib does not parse variable fonts** (`fvar`/`gvar` tables). Any new package must source **static TTF** files — Google Fonts' main repo ships variable fonts and produces empty boxes. Reliable static-TTF sources used today: `openmaptiles/fonts` for non-CJK Noto scripts, `life888888/cjk-fonts-ttf` for the CJK packages, `google/fonts/ofl/sarabun` for Thai.
2. **dompdf does not glyph-fall-back through the `font-family` chain** — it uses the *first* font for ALL characters. So locale-specific packages must be the **primary** font for that locale, not a fallback. Selection happens in `FontService::getFontFamilyForLocale()`. This is also why a Latin-locale company with a Hebrew customer name will still render boxes for the Hebrew text — solving that needs Gotenberg or a custom mid-render font-switching pass.
The bundled NotoSans is also surfaced as a `bundled: true` package entry (no download URL, files served from `resources/static/fonts/` instead of `storage/fonts/`) so it appears alongside the on-demand packages in the admin UI with a "Bundled" pill instead of an Install button.
### Database
Supports MySQL, PostgreSQL, and SQLite. Prefer Eloquent over raw queries. Use `Model::query()` instead of `DB::`. Use eager loading to prevent N+1 queries.
### Service Pattern
All business logic must live in Service classes (`app/Services/`), not in Models or Controllers. Controllers are thin — they authorize, call the service, and return a response. Models only contain relationships, scopes, accessors, mutators, and constants. Services are injected via constructor injection.
### Testing (TDD)
InvoiceShelf follows TDD development style:
- **Feature tests** (`tests/Feature/`) — test API routes end-to-end (HTTP requests, responses, database assertions)
- **Unit tests** (`tests/Unit/`) — test service classes and business logic in isolation
- Write tests before or alongside implementation. Every new feature or bug fix must have tests.
## Code Conventions
- PHP: snake_case, constructor property promotion, explicit return types, PHPDoc blocks over inline comments
- TS / Vue: camelCase, `<script setup lang="ts">`, prefer Composition API + Pinia stores over component-local state for anything cross-cutting
- JS: camelCase
- Always check sibling files for patterns before creating new ones
- Use `config()` helper, never `env()` outside config files
- Every change must have tests
- Every change must have tests (feature tests preferred over unit tests)
- Run `vendor/bin/pint --dirty --format agent` after modifying PHP files
- After editing `lang/en.json` or any file under `resources/scripts/`, rebuild via `npm run build` — the bundled chunks (including locale chunks) are content-hashed by Vite, so the browser will pick them up on hard refresh
## CI Pipeline

View File

@@ -1,68 +0,0 @@
# Contributing to InvoiceShelf
Thank you for your interest in contributing to InvoiceShelf! We welcome contributions from the community and appreciate your effort to improve this project.
## How to Contribute
1. **Fork the repository** and create a new branch from `master`
2. **Make your changes** — follow the existing code style and conventions
3. **Write tests** if applicable
4. **Run the test suite** to ensure nothing is broken
5. **Submit a Pull Request** with a clear description of the changes
## Development Setup
Please refer to the [README](README.md) for instructions on setting up the development environment.
## Code Style
- **PHP**: Follow PSR-12 standards. Run `vendor/bin/pint --dirty` before committing
- **TypeScript/Vue**: Follow the existing patterns in `resources/scripts/`
- **Tests**: Every new feature or bug fix should include tests
## Contributor License Agreement (CLA)
By submitting a pull request, patch, commit, or any other contribution (collectively, "Contribution") to this repository, you agree to the following terms:
### Grant of Rights
You hereby grant to **Ideologix Media Dooel** (the maintainer and owner of InvoiceShelf), and to recipients of software distributed by Ideologix Media Dooel, a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to:
- Reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute your Contribution and any derivative works thereof
- Use your Contribution for any purpose, including but not limited to incorporating it into InvoiceShelf or any other product or service
- Relicense your Contribution under any license, including proprietary licenses
### Copyright Assignment
You agree that your Contribution, once merged, becomes part of the InvoiceShelf project and that **Ideologix Media Dooel** retains full rights to the combined work, including the right to:
- Offer the software under dual licensing (open-source and commercial)
- Enforce the license terms against third parties
- Make licensing decisions without requiring additional permission from contributors
### Representations
By submitting a Contribution, you represent that:
1. You have the legal right to grant the above license
2. Your Contribution is your original work, or you have sufficient rights to submit it
3. Your Contribution does not violate any third-party intellectual property rights
4. You understand that your Contribution is provided voluntarily and is not confidential
### Scope
This agreement applies to all Contributions made to any repository owned by InvoiceShelf / Ideologix Media Dooel, including but not limited to code, documentation, translations, and design assets.
## Reporting Issues
- Use [GitHub Issues](https://github.com/InvoiceShelf/InvoiceShelf/issues) for bug reports and feature requests
- Include steps to reproduce for bug reports
- Check existing issues before creating a new one
## Questions?
If you have questions about contributing, feel free to open a discussion on GitHub.
---
*InvoiceShelf is maintained by [Ideologix Media Dooel](https://ideologix.com/) and licensed under the [AGPL-3.0 License](LICENSE).*

View File

@@ -29,7 +29,6 @@ dist-gen: clean composer npm-build
@cp -r routes InvoiceShelf
@cp -r storage InvoiceShelf
@cp -r vendor InvoiceShelf 2> /dev/null || true
@cp -r scripts InvoiceShelf
@cp -r version.md InvoiceShelf
@cp -r .env.example InvoiceShelf
@cp -r artisan InvoiceShelf
@@ -48,7 +47,6 @@ dist-clean: dist-gen
@rm InvoiceShelf/storage/framework/sessions/* 2> /dev/null || true
@rm InvoiceShelf/storage/framework/views/* 2> /dev/null || true
@rm InvoiceShelf/storage/logs/* 2> /dev/null || true
@php scripts/generate-manifest.php InvoiceShelf
dist: dist-clean
@zip -r InvoiceShelf.zip InvoiceShelf

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Support;
namespace App\Bouncer\Scopes;
use Silber\Bouncer\Database\Scope\Scope;
class BouncerDefaultScope extends Scope
class DefaultScope extends Scope
{
public function applyToModelQuery($query, $table = null)
{

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Services\Pdf\PdfTemplateUtils;
use App\Space\PdfTemplateUtils;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Services\Module\ModuleInstaller;
use App\Space\ModuleInstaller;
use Illuminate\Console\Command;
class InstallModuleCommand extends Command

View File

@@ -1,132 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class MigrateMediaToPrivateDisk extends Command
{
protected $signature = 'media:secure {--dry-run : Show what would be moved without moving}';
protected $description = 'Move sensitive media files (receipts) from the public disk to the private disk';
public function handle(): int
{
$targetDisk = $this->resolveTargetDisk();
if (! $targetDisk) {
$this->error('No target disk found. Set a default FileDisk or configure media_disk_id setting.');
return self::FAILURE;
}
$targetDiskName = app(FileDiskService::class)->registerDisk($targetDisk);
$targetRoot = config('filesystems.disks.'.$targetDiskName.'.root');
if (! $targetRoot) {
$this->error('Could not resolve target disk root path.');
return self::FAILURE;
}
$records = DB::table('media')
->where('disk', 'public')
->where(function ($query) {
$query->where('collection_name', 'receipts');
})
->get();
if ($records->isEmpty()) {
$this->info('No media files to migrate.');
return self::SUCCESS;
}
$this->info('Found '.$records->count().' file(s) to migrate.');
if ($this->option('dry-run')) {
foreach ($records as $record) {
$this->line(" Would move: media/{$record->id}/{$record->file_name}");
}
return self::SUCCESS;
}
$moved = 0;
$skipped = 0;
$publicMediaRoot = public_path('media');
$bar = $this->output->createProgressBar($records->count());
$bar->start();
foreach ($records as $record) {
$relativePath = $record->id.DIRECTORY_SEPARATOR.$record->file_name;
$sourcePath = $publicMediaRoot.DIRECTORY_SEPARATOR.$relativePath;
$destPath = $targetRoot.DIRECTORY_SEPARATOR.$relativePath;
if (! file_exists($sourcePath)) {
$skipped++;
$bar->advance();
continue;
}
$destDir = dirname($destPath);
if (! File::isDirectory($destDir)) {
File::makeDirectory($destDir, 0755, true);
}
File::move($sourcePath, $destPath);
DB::table('media')
->where('id', $record->id)
->update(['disk' => $targetDiskName]);
$moved++;
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Done. Moved: {$moved}, Skipped (missing): {$skipped}");
// Clean up empty directories in public/media
$this->cleanEmptyDirectories($publicMediaRoot);
return self::SUCCESS;
}
private function resolveTargetDisk(): ?FileDisk
{
$mediaDiskId = Setting::getSetting('media_disk_id');
if ($mediaDiskId) {
return FileDisk::find($mediaDiskId);
}
return FileDisk::where('set_as_default', true)->first();
}
private function cleanEmptyDirectories(string $path): void
{
if (! File::isDirectory($path)) {
return;
}
$directories = File::directories($path);
foreach ($directories as $dir) {
$this->cleanEmptyDirectories($dir);
if (count(File::allFiles($dir)) === 0 && count(File::directories($dir)) === 0) {
File::deleteDirectory($dir);
}
}
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Services\Update\Updater;
use App\Space\Updater;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;

View File

@@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Facade;
/**
* @method static \Psr\Http\Message\ResponseInterface loadView(string $template)
*/
class Pdf extends Facade
class PDF extends Facade
{
protected static function getFacadeAccessor()
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Support;
namespace App\Generators;
use App\Models\Estimate;
use App\Models\Invoice;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Services\Hashids;
namespace App\Hashids;
use Hashids\Hashids;
use Illuminate\Support\Arr;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Services\Hashids;
namespace App\Hashids;
use Hashids\Hashids;
use Illuminate\Contracts\Config\Repository;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Services\Hashids;
namespace App\Hashids;
use Hashids\Hashids as HashidsClient;
use Illuminate\Contracts\Container\Container;

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Company;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class AdminDashboardController extends Controller
{
public function index(): JsonResponse
{
$version = preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
$dbDriver = config('database.default');
$dbVersion = $this->getDatabaseVersion($dbDriver);
return response()->json([
'app_version' => $version,
'php_version' => phpversion(),
'database' => [
'driver' => $dbDriver,
'version' => $dbVersion,
],
'counts' => [
'companies' => Company::count(),
'users' => User::count(),
],
]);
}
private function getDatabaseVersion(string $driver): ?string
{
try {
return match ($driver) {
'mysql' => DB::selectOne('SELECT VERSION() as version')?->version,
'pgsql' => DB::selectOne('SHOW server_version')?->server_version,
'sqlite' => DB::selectOne('SELECT sqlite_version() as version')?->version,
default => null,
};
} catch (\Throwable) {
return null;
}
}
}

View File

@@ -1,117 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\CreateBackupJob;
use App\Rules\Backup\PathToZip;
use App\Services\Backup\BackupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\Helpers\Format;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BackupsController extends Controller
{
public function __construct(
private readonly BackupService $backupService,
) {}
public function index(Request $request): JsonResponse
{
$this->authorize('manage backups');
try {
$destination = $this->backupService->getDestination($request->file_disk_id);
$backups = $destination
->backups()
->map(function (Backup $backup) {
return [
'path' => $backup->path(),
'created_at' => $backup->date()->format('Y-m-d H:i:s'),
'size' => Format::humanReadableSize($backup->sizeInBytes()),
];
})
->toArray();
return response()->json([
'backups' => $backups,
]);
} catch (\Exception $e) {
return response()->json([
'backups' => [],
'error' => 'invalid_disk_credentials',
'error_message' => $e->getMessage(),
]);
}
}
public function store(Request $request): JsonResponse
{
$this->authorize('manage backups');
$data = $request->all();
dispatch(new CreateBackupJob($data))->onQueue(config('backup.queue.name'));
return response()->json(['success' => true]);
}
public function destroy($disk, Request $request): JsonResponse
{
$this->authorize('manage backups');
$validated = $request->validate([
'path' => ['required', new PathToZip],
]);
$destination = $this->backupService->getDestination($request->file_disk_id);
$destination
->backups()
->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
})
->delete();
return response()->json(['success' => true]);
}
public function download(Request $request): Response|StreamedResponse
{
$this->authorize('manage backups');
$validated = $request->validate([
'path' => ['required', new PathToZip],
]);
$destination = $this->backupService->getDestination($request->file_disk_id);
$backup = $destination->backups()->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
});
if (! $backup) {
return response('Backup not found', 422);
}
$fileName = pathinfo($backup->path(), PATHINFO_BASENAME);
return response()->stream(function () use ($backup) {
$stream = $backup->stream();
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
}, 200, [
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
'Content-Type' => 'application/zip',
'Content-Length' => $backup->sizeInBytes(),
'Content-Disposition' => 'attachment; filename="'.$fileName.'"',
'Pragma' => 'public',
]);
}
}

View File

@@ -1,113 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Facades\Hashids;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminCompanyUpdateRequest;
use App\Http\Requests\CompaniesRequest;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use App\Services\CompanyService;
use Illuminate\Http\Request;
use Silber\Bouncer\BouncerFacade;
class CompaniesController extends Controller
{
public function __construct(
private readonly CompanyService $companyService,
) {}
public function index(Request $request)
{
$companies = Company::query()
->with(['owner', 'address'])
->when($request->has('search'), function ($query) use ($request) {
$query->where('name', 'like', '%'.$request->search.'%');
})
->when($request->has('orderByField') && $request->has('orderBy'), function ($query) use ($request) {
$query->orderBy($request->orderByField, $request->orderBy);
}, function ($query) {
$query->orderBy('name', 'asc');
})
->paginate($request->input('limit', 10));
return CompanyResource::collection($companies);
}
public function show(Company $company)
{
$company->load(['owner', 'address']);
return new CompanyResource($company);
}
public function update(AdminCompanyUpdateRequest $request, Company $company)
{
$company->update([
'name' => $request->name,
'vat_id' => $request->vat_id,
'tax_id' => $request->tax_id,
'owner_id' => $request->owner_id,
]);
if ($request->has('address')) {
$company->address()->updateOrCreate(
['company_id' => $company->id],
$request->address,
);
}
$company->load(['owner', 'address']);
return new CompanyResource($company);
}
public function store(CompaniesRequest $request)
{
$this->authorize('create company');
$user = $request->user();
$company = Company::create($request->getCompanyPayload());
$company->unique_hash = Hashids::connection(Company::class)->encode($company->id);
$company->save();
$this->companyService->setupDefaults($company);
$user->companies()->attach($company->id);
BouncerFacade::scope()->to($company->id);
$user->assign('owner');
if ($request->address) {
$company->address()->create($request->address);
}
return new CompanyResource($company);
}
public function destroy(Request $request)
{
$company = Company::find($request->header('company'));
$this->authorize('delete company', $company);
$user = $request->user();
if ($request->name !== $company->name) {
return respondJson('company_name_must_match_with_given_name', 'Company name must match with given name');
}
$this->companyService->delete($company, $user);
return response()->json([
'success' => true,
]);
}
public function userCompanies(Request $request)
{
$companies = $request->user()->companies;
return CompanyResource::collection($companies);
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Resources\CurrencyResource;
use App\Services\CurrencyService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class CurrenciesController extends Controller
{
public function __construct(
private readonly CurrencyService $currencyService,
) {}
public function __invoke(Request $request): AnonymousResourceCollection
{
$currencies = $this->currencyService->getAllWithCommonFirst();
return CurrencyResource::collection($currencies);
}
}

View File

@@ -1,52 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\FontService;
use Illuminate\Http\JsonResponse;
class FontController extends Controller
{
public function __construct(
private readonly FontService $fontService,
) {}
public function status(): JsonResponse
{
$this->authorize('manage settings');
return response()->json([
'packages' => $this->fontService->getPackageStatuses(),
]);
}
public function install(string $package): JsonResponse
{
$this->authorize('manage settings');
if (! isset(FontService::FONT_PACKAGES[$package])) {
return response()->json(['error' => 'Unknown font package'], 404);
}
$pkg = FontService::FONT_PACKAGES[$package];
if ($this->fontService->isInstalled($pkg)) {
return response()->json(['success' => true, 'message' => 'Already installed']);
}
try {
$this->fontService->downloadPackage($pkg);
return response()->json([
'success' => true,
'installed' => true,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Modules;
use App\Http\Controllers\Controller;
use App\Http\Requests\UnzipUpdateRequest;
use App\Http\Requests\UploadModuleRequest;
use App\Services\Module\ModuleInstaller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ModuleInstallationController extends Controller
{
public function download(Request $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::download(
(string) $request->slug,
(string) $request->version,
$request->checksum_sha256 ? (string) $request->checksum_sha256 : null,
);
return response()->json($response);
}
public function upload(UploadModuleRequest $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::upload($request);
return response()->json($response);
}
public function unzip(UnzipUpdateRequest $request): JsonResponse
{
$this->authorize('manage modules');
$path = ModuleInstaller::unzip($request->module_name ?? $request->module, $request->path);
return response()->json([
'success' => true,
'path' => $path,
]);
}
public function copy(Request $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::copyFiles($request->module_name ?? $request->module, $request->path);
return response()->json([
'success' => $response,
]);
}
public function complete(Request $request): JsonResponse
{
$this->authorize('manage modules');
$response = ModuleInstaller::complete($request->module_name ?? $request->module, $request->version);
return response()->json([
'success' => $response,
]);
}
}

View File

@@ -1,86 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Modules;
use App\Events\ModuleDisabledEvent;
use App\Events\ModuleEnabledEvent;
use App\Http\Controllers\Controller;
use App\Http\Resources\ModuleResource;
use App\Models\Module as ModelsModule;
use App\Services\Module\ModuleInstaller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Nwidart\Modules\Facades\Module;
class ModulesController extends Controller
{
public function index(Request $request)
{
$this->authorize('manage modules');
$response = ModuleInstaller::getModules();
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->modules)) {
return response()->json(['error' => 'marketplace_unavailable'], 503);
}
return ModuleResource::collection(collect($response['body']->modules));
}
public function show(Request $request, string $module)
{
$this->authorize('manage modules');
$response = ModuleInstaller::getModule($module);
if (($response['status'] ?? 0) === 404) {
return response()->json(['error' => 'not_found'], 404);
}
if (($response['status'] ?? 0) !== 200 || ! isset($response['body']->data)) {
return response()->json(['error' => 'marketplace_unavailable'], 503);
}
return (new ModuleResource($response['body']->data))
->additional(['meta' => [
'modules' => ModuleResource::collection(
collect($response['body']->meta->modules ?? [])
),
]]);
}
public function checkToken(Request $request): JsonResponse
{
$this->authorize('manage modules');
return ModuleInstaller::checkToken($request->api_token);
}
public function enable(Request $request, string $module): JsonResponse
{
$this->authorize('manage modules');
$module = ModelsModule::where('name', $module)->first();
$module->update(['enabled' => true]);
$installedModule = Module::find($module->name);
$installedModule->enable();
ModuleEnabledEvent::dispatch($module);
return response()->json(['success' => true]);
}
public function disable(Request $request, string $module): JsonResponse
{
$this->authorize('manage modules');
$module = ModelsModule::where('name', $module)->first();
$module->update(['enabled' => false]);
$installedModule = Module::find($module->name);
$installedModule->disable();
ModuleDisabledEvent::dispatch($module);
return response()->json(['success' => true]);
}
}

View File

@@ -1,268 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\DiskEnvironmentRequest;
use App\Http\Resources\FileDiskResource;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\DB;
class DiskController extends Controller
{
public function __construct(
private readonly FileDiskService $fileDiskService,
) {}
/**
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('manage file disk');
$limit = $request->has('limit') ? $request->limit : 5;
$disks = FileDisk::applyFilters($request->all())
->latest()
->paginateData($limit);
return FileDiskResource::collection($disks);
}
/**
* @return JsonResponse
*
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function store(DiskEnvironmentRequest $request): JsonResponse|FileDiskResource
{
$this->authorize('manage file disk');
if (! $this->fileDiskService->validateCredentials($request->credentials, $request->driver)) {
return respondJson('invalid_credentials', 'Invalid Credentials.');
}
$disk = $this->fileDiskService->create($request);
return new FileDiskResource($disk);
}
/**
* @throws AuthorizationException
*/
public function update(FileDisk $disk, Request $request): JsonResponse|FileDiskResource
{
$this->authorize('manage file disk');
$credentials = $request->credentials;
$driver = $request->driver;
if ($credentials && $driver && $disk->type !== 'SYSTEM') {
if (! $this->fileDiskService->validateCredentials($credentials, $driver)) {
return respondJson('invalid_credentials', 'Invalid Credentials.');
}
$this->fileDiskService->update($disk, $request);
} elseif ($request->set_as_default) {
$this->fileDiskService->setAsDefault($disk);
}
return new FileDiskResource($disk);
}
/**
* @param Request $request
*
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function show($disk): JsonResponse
{
$this->authorize('manage file disk');
$diskData = [];
switch ($disk) {
case 'local':
// Path is relative to storage/app/.
// e.g., "backups" resolves to storage/app/backups/ at runtime.
$diskData = [
'root' => '',
];
break;
case 's3':
$diskData = [
'key' => '',
'secret' => '',
'region' => '',
'bucket' => '',
'root' => '',
];
break;
case 's3compat':
$diskData = [
'endpoint' => '',
'key' => '',
'secret' => '',
'region' => '',
'bucket' => '',
'root' => '',
];
case 'doSpaces':
$diskData = [
'key' => '',
'secret' => '',
'region' => '',
'bucket' => '',
'endpoint' => '',
'root' => '',
];
break;
case 'dropbox':
$diskData = [
'token' => '',
'key' => '',
'secret' => '',
'app' => '',
'root' => '',
];
break;
}
$data = array_merge($diskData);
return response()->json($data);
}
/**
* Remove the specified resource from storage.
*
* @param FileDisk $taxType
*
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function destroy(FileDisk $disk): JsonResponse
{
$this->authorize('manage file disk');
if ($disk->type === 'SYSTEM') {
return respondJson('not_allowed', 'System disks cannot be deleted.');
}
if ($disk->setAsDefault()) {
return respondJson('not_allowed', 'The default disk cannot be deleted.');
}
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
$diskName = $prefix.$disk->driver;
$mediaCount = DB::table('media')
->where('disk', $diskName)
->orWhere('disk', $disk->driver)
->count();
if ($mediaCount > 0) {
return respondJson('disk_has_files', 'Cannot delete this disk — it contains '.$mediaCount.' file(s). Migrate files first.');
}
$disk->delete();
return response()->json([
'success' => true,
]);
}
/**
* @throws AuthorizationException
* @throws AuthorizationException
*/
public function getDiskDrivers(): JsonResponse
{
$this->authorize('manage file disk');
$drivers = [
[
'name' => 'Local',
'value' => 'local',
],
[
'name' => 'Amazon S3',
'value' => 's3',
],
[
'name' => 'S3 Compatible Storage',
'value' => 's3compat',
],
[
'name' => 'Digital Ocean Spaces',
'value' => 'doSpaces',
],
[
'name' => 'Dropbox',
'value' => 'dropbox',
],
];
$defaultDisk = FileDisk::where('set_as_default', true)->first();
return response()->json([
'drivers' => $drivers,
'default' => $defaultDisk?->driver ?? 'local',
]);
}
public function getDiskPurposes(): JsonResponse
{
$this->authorize('manage file disk');
$defaultDisk = FileDisk::where('set_as_default', true)->first();
$defaultId = $defaultDisk?->id;
return response()->json([
'media_disk_id' => Setting::getSetting('media_disk_id') ?? $defaultId,
'pdf_disk_id' => Setting::getSetting('pdf_disk_id') ?? $defaultId,
'backup_disk_id' => Setting::getSetting('backup_disk_id') ?? $defaultId,
]);
}
public function updateDiskPurposes(Request $request): JsonResponse
{
$this->authorize('manage file disk');
$request->validate([
'media_disk_id' => 'nullable|exists:file_disks,id',
'pdf_disk_id' => 'nullable|exists:file_disks,id',
'backup_disk_id' => 'nullable|exists:file_disks,id',
]);
if ($request->has('media_disk_id')) {
Setting::setSetting('media_disk_id', $request->media_disk_id);
}
if ($request->has('pdf_disk_id')) {
Setting::setSetting('pdf_disk_id', $request->pdf_disk_id);
}
if ($request->has('backup_disk_id')) {
Setting::setSetting('backup_disk_id', $request->backup_disk_id);
}
return response()->json(['success' => true]);
}
}

View File

@@ -1,94 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\MailEnvironmentRequest;
use App\Mail\TestMail;
use App\Models\Setting;
use App\Services\MailConfigurationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class MailConfigurationController extends Controller
{
public function __construct(private readonly MailConfigurationService $mailConfigurationService) {}
/**
* Save the mail environment variables
*
*
*
* @throws AuthorizationException
*/
public function saveMailEnvironment(MailEnvironmentRequest $request): JsonResponse
{
$this->authorize('manage email config');
$setting = Setting::getSetting('profile_complete');
$this->mailConfigurationService->saveGlobalConfig($request->validated());
if ($setting !== 'COMPLETED') {
Setting::setSetting('profile_complete', 4);
}
return response()->json([
'success' => 'mail_variables_save_successfully',
]);
}
/**
* Return the mail environment variables
*
*
* @throws AuthorizationException
*/
public function getMailEnvironment(): JsonResponse
{
$this->authorize('manage email config');
return response()->json($this->mailConfigurationService->getGlobalConfig());
}
/**
* Return the available mail drivers
*
*
* @throws AuthorizationException
*/
public function getMailDrivers(): JsonResponse
{
$this->authorize('manage email config');
return response()->json($this->mailConfigurationService->getAvailableDrivers());
}
/**
* Test the email configuration
*
*
*
* @throws AuthorizationException
* @throws ValidationException
*/
public function testEmailConfig(Request $request): JsonResponse
{
$this->authorize('manage email config');
$this->validate($request, [
'to' => 'required|email',
'subject' => 'required',
'message' => 'required',
]);
Mail::to($request->to)->send(new TestMail($request->subject, $request->message));
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\GetSettingRequest;
use App\Http\Requests\SettingRequest;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
class SettingsController extends Controller
{
public function show(GetSettingRequest $request): JsonResponse
{
$this->authorize('manage settings');
$setting = Setting::getSetting($request->key);
return response()->json([
$request->key => $setting,
]);
}
public function update(SettingRequest $request): JsonResponse
{
$this->authorize('manage settings');
Setting::setSettings($request->settings);
return response()->json([
'success' => true,
$request->settings,
]);
}
}

View File

@@ -1,116 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\Update\Updater;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
class UpdateController extends Controller
{
public function checkVersion(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
set_time_limit(600);
$channel = $request->get('channel', 'stable');
$version = preg_replace('~[\r\n]+~', '', File::get(base_path('version.md')));
return response()->json(Updater::checkForUpdate($version, $channel));
}
public function download(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate(['version' => 'required']);
return response()->json([
'success' => true,
'path' => Updater::download($request->version),
]);
}
public function unzip(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate(['path' => 'required']);
try {
return response()->json([
'success' => true,
'path' => Updater::unzip($request->path),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => $e->getMessage(),
], 500);
}
}
public function copy(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate(['path' => 'required']);
return response()->json([
'success' => true,
'path' => Updater::copyFiles($request->path),
]);
}
public function delete(Request $request): JsonResponse
{
return $this->clean($request);
}
public function clean(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
// Backward compatibility: use deleted_files when no manifest exists
if (! File::exists(base_path('manifest.json'))
&& isset($request->deleted_files)
&& ! empty($request->deleted_files)) {
Updater::deleteFiles($request->deleted_files);
return response()->json(['success' => true, 'cleaned' => 0]);
}
$result = Updater::cleanStaleFiles();
return response()->json($result);
}
public function migrate(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
Updater::migrateUpdate();
return response()->json(['success' => true]);
}
public function finish(Request $request): JsonResponse
{
$this->ensureSuperAdmin();
$request->validate([
'installed' => 'required',
'version' => 'required',
]);
return response()->json(Updater::finishUpdate($request->installed, $request->version));
}
private function ensureSuperAdmin(): void
{
$this->authorize('manage update app');
}
}

View File

@@ -1,101 +0,0 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminUserUpdateRequest;
use App\Http\Resources\UserResource;
use App\Models\ImpersonationLog;
use App\Models\User;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;
class UsersController extends Controller
{
public function index(Request $request)
{
$limit = $request->has('limit') ? $request->limit : 10;
$users = User::with('companies')
->applyFilters($request->all())
->latest()
->paginate($limit);
return UserResource::collection($users);
}
public function show(User $user)
{
$user->load('companies');
return new UserResource($user);
}
public function update(AdminUserUpdateRequest $request, User $user)
{
$data = $request->only(['name', 'email', 'phone']);
if ($request->filled('password')) {
$data['password'] = $request->password;
}
$user->update($data);
return new UserResource($user);
}
public function impersonate(Request $request, User $user)
{
$admin = $request->user();
if ($admin->id === $user->id) {
return response()->json([
'error' => 'cannot_impersonate_self',
'message' => 'You cannot impersonate yourself.',
], 422);
}
$token = $user->createToken(
'impersonation-by-'.$admin->id,
['*'],
now()->addHours(2),
);
$log = ImpersonationLog::create([
'admin_id' => $admin->id,
'user_id' => $user->id,
'ip_address' => $request->ip(),
'token_id' => $token->accessToken->id,
]);
return response()->json([
'token' => $token->plainTextToken,
'impersonation_log_id' => $log->id,
'user' => new UserResource($user),
]);
}
public function stopImpersonating(Request $request)
{
$token = $request->user()->currentAccessToken();
if ($token instanceof PersonalAccessToken && str_starts_with($token->name, 'impersonation-by-')) {
$log = ImpersonationLog::where('token_id', $token->id)
->whereNull('stopped_at')
->first();
if ($log) {
$log->update(['stopped_at' => now()]);
}
$token->delete();
return response()->json(['success' => true]);
}
return response()->json([
'error' => 'not_impersonating',
'message' => 'No active impersonation session.',
], 422);
}
}

View File

@@ -1,85 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Auth;
use App\Http\Controllers\Controller;
use App\Models\CompanyInvitation;
use App\Models\User;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class InvitationRegistrationController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
/**
* Get invitation details by token (public endpoint for registration page).
*/
public function details(string $token): JsonResponse
{
$invitation = CompanyInvitation::where('token', $token)
->pending()
->with(['company', 'role'])
->first();
if (! $invitation) {
return response()->json([
'error' => 'Invitation not found or expired.',
], 404);
}
return response()->json([
'email' => $invitation->email,
'company_name' => $invitation->company->name,
'role_name' => $invitation->role->title,
]);
}
/**
* Register a new user and auto-accept the invitation.
*/
public function register(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
'invitation_token' => 'required|string',
]);
$invitation = CompanyInvitation::where('token', $request->invitation_token)
->pending()
->first();
if (! $invitation) {
throw ValidationException::withMessages([
'invitation_token' => ['Invitation not found or expired.'],
]);
}
if ($invitation->email !== $request->email) {
throw ValidationException::withMessages([
'email' => ['Email does not match the invitation.'],
]);
}
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $request->password,
]);
$user->setSettings(['language' => 'default']);
$this->invitationService->accept($invitation, $user);
return response()->json([
'type' => 'Bearer',
'token' => $user->createToken('web')->plainTextToken,
]);
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Customer;
use App\Http\Controllers\Controller;
use App\Http\Resources\CustomerResource;
use App\Models\Customer;
use App\Services\CustomerService;
use Illuminate\Http\Request;
class CustomerStatsController extends Controller
{
public function __construct(
private readonly CustomerService $customerService,
) {}
public function __invoke(Request $request, Customer $customer)
{
$this->authorize('view', $customer);
$chartData = $this->customerService->getStats(
$customer,
$request->header('company'),
$request->has('previous_year')
);
$customer = Customer::find($customer->id);
return (new CustomerResource($customer))
->additional(['meta' => [
'chartData' => $chartData,
]]);
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteEstimatesRequest;
use App\Http\Requests\EstimatesRequest;
use App\Http\Requests\SendEstimatesRequest;
use App\Http\Resources\EstimateResource;
use App\Http\Resources\InvoiceResource;
use App\Jobs\GenerateEstimatePdfJob;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Services\EstimateService;
use Illuminate\Http\Request;
use Illuminate\Mail\Markdown;
class EstimatesController extends Controller
{
public function __construct(
private readonly EstimateService $estimateService,
) {}
public function index(Request $request)
{
$this->authorize('viewAny', Estimate::class);
$limit = $request->has('limit') ? $request->limit : 10;
$estimates = Estimate::whereCompany()
->join('customers', 'customers.id', '=', 'estimates.customer_id')
->applyFilters($request->all())
->select('estimates.*', 'customers.name')
->latest()
->paginateData($limit);
return EstimateResource::collection($estimates)
->additional(['meta' => [
'estimate_total_count' => Estimate::whereCompany()->count(),
]]);
}
public function store(EstimatesRequest $request)
{
$this->authorize('create', Estimate::class);
$estimate = $this->estimateService->create($request);
if ($request->has('estimateSend')) {
$this->estimateService->send($estimate, $request->only(['title', 'body']));
}
GenerateEstimatePdfJob::dispatch($estimate);
return new EstimateResource($estimate);
}
public function show(Request $request, Estimate $estimate)
{
$this->authorize('view', $estimate);
return new EstimateResource($estimate);
}
public function update(EstimatesRequest $request, Estimate $estimate)
{
$this->authorize('update', $estimate);
$estimate = $this->estimateService->update($estimate, $request);
GenerateEstimatePdfJob::dispatch($estimate, true);
return new EstimateResource($estimate);
}
public function delete(DeleteEstimatesRequest $request)
{
$this->authorize('delete multiple estimates');
$ids = Estimate::whereCompany()
->whereIn('id', $request->ids)
->pluck('id');
Estimate::destroy($ids);
return response()->json([
'success' => true,
]);
}
public function send(SendEstimatesRequest $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$response = $this->estimateService->send($estimate, $request->all());
return response()->json($response);
}
public function sendPreview(SendEstimatesRequest $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$markdown = new Markdown(view(), config('mail.markdown'));
$data = $this->estimateService->sendEstimateData($estimate, $request->all());
$data['url'] = $estimate->estimatePdfUrl;
return $markdown->render('emails.send.estimate', ['data' => $data]);
}
public function clone(Request $request, Estimate $estimate)
{
$this->authorize('view', $estimate);
$this->authorize('create', Estimate::class);
$newEstimate = $this->estimateService->clone($estimate);
return new EstimateResource($newEstimate);
}
public function convertToInvoice(Request $request, Estimate $estimate)
{
$this->authorize('create', Invoice::class);
$invoice = $this->estimateService->convertToInvoice($estimate);
return new InvoiceResource($invoice);
}
public function changeStatus(Request $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$this->estimateService->changeStatus($estimate, $request->status);
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,359 +0,0 @@
<?php
namespace App\Http\Controllers\Company\ExchangeRate;
use App\Http\Controllers\Controller;
use App\Http\Requests\BulkExchangeRateRequest;
use App\Http\Requests\ExchangeRateProviderRequest;
use App\Http\Resources\ExchangeRateProviderResource;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\Estimate;
use App\Models\ExchangeRateLog;
use App\Models\ExchangeRateProvider;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Tax;
use App\Services\ExchangeRateProviderService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
class ExchangeRateProviderController extends Controller
{
public function __construct(
private readonly ExchangeRateProviderService $exchangeRateProviderService,
) {}
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
$limit = $request->has('limit') ? $request->limit : 5;
$exchangeRateProviders = ExchangeRateProvider::whereCompany()->paginate($limit);
return ExchangeRateProviderResource::collection($exchangeRateProviders);
}
/**
* Store a newly created resource in storage.
*
* @param Request $request
* @return Response
*/
public function store(ExchangeRateProviderRequest $request)
{
$this->authorize('create', ExchangeRateProvider::class);
$query = $this->exchangeRateProviderService->checkActiveCurrencies($request);
if (count($query) !== 0) {
return respondJson('currency_used', 'Currency used.');
}
$checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request);
if ($checkConverterApi->status() == 200) {
$exchangeRateProvider = $this->exchangeRateProviderService->create($request);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
return $checkConverterApi;
}
/**
* Display the specified resource.
*
* @return Response
*/
public function show(ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('view', $exchangeRateProvider);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
/**
* Update the specified resource in storage.
*
* @param Request $request
* @return Response
*/
public function update(ExchangeRateProviderRequest $request, ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('update', $exchangeRateProvider);
$query = $this->exchangeRateProviderService->checkUpdateActiveCurrencies($exchangeRateProvider, $request);
if (count($query) !== 0) {
return respondJson('currency_used', 'Currency used.');
}
$checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request);
if ($checkConverterApi->status() == 200) {
$this->exchangeRateProviderService->update($exchangeRateProvider, $request);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
return $checkConverterApi;
}
/**
* Remove the specified resource from storage.
*
* @return Response
*/
public function destroy(ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('delete', $exchangeRateProvider);
if ($exchangeRateProvider->active == true) {
return respondJson('provider_active', 'Provider Active.');
}
$exchangeRateProvider->delete();
return response()->json([
'success' => true,
]);
}
public function activeProvider(Request $request, Currency $currency)
{
$query = ExchangeRateProvider::whereCompany()->whereJsonContains('currencies', $currency->code)
->where('active', true)
->get();
if (count($query) !== 0) {
return response()->json([
'success' => true,
'message' => 'provider_active',
], 200);
}
return response()->json([
'error' => 'no_active_provider',
], 200);
}
public function getRate(Request $request, Currency $currency)
{
$settings = CompanySetting::getSettings(['currency'], $request->header('company'));
$baseCurrency = Currency::findOrFail($settings['currency']);
$query = ExchangeRateProvider::whereJsonContains('currencies', $currency->code)
->where('active', true)
->get()
->toArray();
$exchangeRate = ExchangeRateLog::where('base_currency_id', $currency->id)
->where('currency_id', $baseCurrency->id)
->orderBy('created_at', 'desc')
->value('exchange_rate');
if ($query) {
$filter = Arr::only($query[0], ['key', 'driver', 'driver_config']);
$result = $this->exchangeRateProviderService->getExchangeRate(
$filter['driver'],
$filter['key'],
$filter['driver_config'] ?? [],
$currency->code,
$baseCurrency->code
);
if ($result->status() == 200) {
return $result;
}
}
if ($exchangeRate) {
return response()->json([
'exchangeRate' => [$exchangeRate],
], 200);
}
return response()->json([
'error' => 'no_exchange_rate_available',
], 200);
}
public function supportedCurrencies(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
return $this->exchangeRateProviderService->getSupportedCurrencies(
$request->driver,
$request->key,
$request->driver_config ?? []
);
}
public function usedCurrencies(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
$providerId = $request->provider_id;
$activeExchangeRateProviders = ExchangeRateProvider::where('active', true)
->whereCompany()
->when($providerId, function ($query) use ($providerId) {
return $query->where('id', '<>', $providerId);
})
->pluck('currencies');
$activeExchangeRateProvider = [];
foreach ($activeExchangeRateProviders as $data) {
if (is_array($data)) {
for ($limit = 0; $limit < count($data); $limit++) {
$activeExchangeRateProvider[] = $data[$limit];
}
}
}
$allExchangeRateProviders = ExchangeRateProvider::whereCompany()->pluck('currencies');
$allExchangeRateProvider = [];
foreach ($allExchangeRateProviders as $data) {
if (is_array($data)) {
for ($limit = 0; $limit < count($data); $limit++) {
$allExchangeRateProvider[] = $data[$limit];
}
}
}
return response()->json([
'allUsedCurrencies' => $allExchangeRateProvider ? $allExchangeRateProvider : [],
'activeUsedCurrencies' => $activeExchangeRateProvider ? $activeExchangeRateProvider : [],
]);
}
public function usedCurrenciesWithoutRate(Request $request)
{
$invoices = Invoice::where('exchange_rate', null)->pluck('currency_id')->toArray();
$taxes = Tax::where('exchange_rate', null)->pluck('currency_id')->toArray();
$estimates = Estimate::where('exchange_rate', null)->pluck('currency_id')->toArray();
$payments = Payment::where('exchange_rate', null)->pluck('currency_id')->toArray();
$currencies = array_merge($invoices, $taxes, $estimates, $payments);
return response()->json([
'currencies' => Currency::whereIn('id', $currencies)->get(),
]);
}
public function bulkUpdate(BulkExchangeRateRequest $request)
{
$bulkExchangeRate = CompanySetting::getSetting('bulk_exchange_rate_configured', $request->header('company'));
if ($bulkExchangeRate == 'NO') {
if ($request->currencies) {
foreach ($request->currencies as $currency) {
$currency['exchange_rate'] = $currency['exchange_rate'] ?? 1;
$invoices = Invoice::where('currency_id', $currency['id'])->get();
if ($invoices) {
foreach ($invoices as $invoice) {
$invoice->update([
'exchange_rate' => $currency['exchange_rate'],
'base_discount_val' => $invoice->sub_total * $currency['exchange_rate'],
'base_sub_total' => $invoice->sub_total * $currency['exchange_rate'],
'base_total' => $invoice->total * $currency['exchange_rate'],
'base_tax' => $invoice->tax * $currency['exchange_rate'],
'base_due_amount' => $invoice->due_amount * $currency['exchange_rate'],
]);
$this->updateItemsExchangeRate($invoice);
}
}
$estimates = Estimate::where('currency_id', $currency['id'])->get();
if ($estimates) {
foreach ($estimates as $estimate) {
$estimate->update([
'exchange_rate' => $currency['exchange_rate'],
'base_discount_val' => $estimate->sub_total * $currency['exchange_rate'],
'base_sub_total' => $estimate->sub_total * $currency['exchange_rate'],
'base_total' => $estimate->total * $currency['exchange_rate'],
'base_tax' => $estimate->tax * $currency['exchange_rate'],
]);
$this->updateItemsExchangeRate($estimate);
}
}
$taxes = Tax::where('currency_id', $currency['id'])->get();
if ($taxes) {
foreach ($taxes as $tax) {
$tax->base_amount = $tax->base_amount * $currency['exchange_rate'];
$tax->save();
}
}
$payments = Payment::where('currency_id', $currency['id'])->get();
if ($payments) {
foreach ($payments as $payment) {
$payment->exchange_rate = $currency['exchange_rate'];
$payment->base_amount = $payment->amount * $currency['exchange_rate'];
$payment->save();
}
}
}
}
$settings = [
'bulk_exchange_rate_configured' => 'YES',
];
CompanySetting::setSettings($settings, $request->header('company'));
return response()->json([
'success' => true,
]);
}
return response()->json([
'error' => false,
]);
}
private function updateItemsExchangeRate($model): void
{
foreach ($model->items as $item) {
$item->update([
'exchange_rate' => $model->exchange_rate,
'base_discount_val' => $item->discount_val * $model->exchange_rate,
'base_price' => $item->price * $model->exchange_rate,
'base_tax' => $item->tax * $model->exchange_rate,
'base_total' => $item->total * $model->exchange_rate,
]);
$this->updateTaxesExchangeRate($item);
}
$this->updateTaxesExchangeRate($model);
}
private function updateTaxesExchangeRate($model): void
{
if ($model->taxes()->exists()) {
$model->taxes->map(function ($tax) use ($model) {
$tax->update([
'exchange_rate' => $model->exchange_rate,
'base_amount' => $tax->amount * $model->exchange_rate,
]);
});
}
}
}

View File

@@ -1,156 +0,0 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Http\Resources\CompanyResource;
use App\Http\Resources\UserResource;
use App\Models\Company;
use App\Models\CompanyInvitation;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\Module;
use App\Models\Setting;
use App\Traits\GeneratesMenuTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Silber\Bouncer\BouncerFacade;
class BootstrapController extends Controller
{
use GeneratesMenuTrait;
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(Request $request)
{
$current_user = $request->user();
$current_user_settings = $current_user->getAllSettings();
$companies = $current_user->companies;
$pendingInvitations = CompanyInvitation::forUser($current_user)
->pending()
->with(['company', 'role', 'invitedBy'])
->get();
$global_settings = Setting::getSettings([
'api_token',
'admin_portal_theme',
'admin_portal_logo',
'login_page_logo',
'login_page_heading',
'login_page_description',
'admin_page_title',
'copyright_text',
'save_pdf_to_disk',
'show_sidebar_group_labels',
]);
// Super admin mode — return admin-only menu with all companies listed
if ($current_user->isSuperAdmin() && $request->has('admin_mode')) {
return response()->json([
'current_user' => new UserResource($current_user),
'current_user_settings' => $current_user_settings,
'current_user_abilities' => [],
'companies' => CompanyResource::collection($companies),
'current_company' => null,
'current_company_settings' => [],
'current_company_currency' => Currency::first(),
'config' => config('invoiceshelf'),
'global_settings' => $global_settings,
'main_menu' => $this->generateMenu('admin_menu', $current_user),
'setting_menu' => [],
'modules' => [],
'admin_mode' => true,
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
]);
}
// User has no companies — return minimal bootstrap
if ($companies->isEmpty()) {
return response()->json([
'current_user' => new UserResource($current_user),
'current_user_settings' => $current_user_settings,
'current_user_abilities' => [],
'companies' => [],
'current_company' => null,
'current_company_settings' => [],
'current_company_currency' => Currency::first(),
'config' => config('invoiceshelf'),
'global_settings' => $global_settings,
'main_menu' => [],
'setting_menu' => [],
'modules' => [],
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
]);
}
$main_menu = $this->generateMenu('main_menu', $current_user);
$setting_menu = $this->generateMenu('setting_menu', $current_user);
// Merge module-registered menu items into the main menu so they
// participate in the unified group + priority ordering.
foreach (ModuleRegistry::allMenu() as $slug => $item) {
$main_menu[] = [
'title' => __($item['title']),
'link' => $item['link'],
'icon' => $item['icon'],
'name' => 'module-'.$slug,
'group' => $item['group'] ?? 'modules',
'group_label' => $item['group_label'] ?? 'navigation.modules',
'priority' => $item['priority'] ?? 100,
];
}
$current_company = Company::find($request->header('company'));
if ((! $current_company) || ($current_company && ! $current_user->hasCompany($current_company->id))) {
$current_company = $current_user->companies()->first();
}
$current_company_settings = CompanySetting::getAllSettings($current_company->id);
$current_company_currency = $current_company_settings->has('currency')
? Currency::find($current_company_settings->get('currency'))
: Currency::first();
BouncerFacade::refreshFor($current_user);
return response()->json([
'current_user' => new UserResource($current_user),
'current_user_settings' => $current_user_settings,
'current_user_abilities' => $current_user->getAbilities(),
'companies' => CompanyResource::collection($companies),
'current_company' => new CompanyResource($current_company),
'current_company_settings' => $current_company_settings,
'current_company_currency' => $current_company_currency,
'config' => config('invoiceshelf'),
'global_settings' => $global_settings,
'main_menu' => $main_menu,
'setting_menu' => $setting_menu,
'modules' => Module::where('enabled', true)->pluck('name'),
'user_menu' => collect(ModuleRegistry::allUserMenu())
->map(fn (array $item, string $slug) => [
...$item,
'title' => __($item['title']),
'name' => 'module-'.$slug,
])
->sortBy('priority')
->values()
->all(),
'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations),
]);
}
public function currentCompany(Request $request)
{
$company = Company::find($request->header('company'));
return new CompanyResource($company);
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry;
class ConfigController extends Controller
{
/**
* Handle the incoming request.
*/
public function __invoke(Request $request): JsonResponse
{
if ($request->key === 'exchange_rate_drivers') {
return response()->json([
'exchange_rate_drivers' => $this->exchangeRateDrivers(),
]);
}
return response()->json([
$request->key => config('invoiceshelf.'.$request->key),
]);
}
/**
* Build the exchange rate driver list from the module Registry.
*
* Returns enriched objects (with label, website, and config_fields) so the
* frontend can render driver-specific configuration forms without hardcoding
* any per-driver UI.
*
* @return array<int, array<string, mixed>>
*/
protected function exchangeRateDrivers(): array
{
return collect(Registry::allDrivers('exchange_rate'))
->map(fn (array $meta, string $name) => [
'value' => $name,
'label' => $meta['label'] ?? $name,
'website' => $meta['website'] ?? '',
'config_fields' => $meta['config_fields'] ?? [],
])
->values()
->all();
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Support\Formatters\DateFormatter;
use App\Support\Formatters\TimeFormatter;
use App\Support\Formatters\TimeZones;
use Illuminate\Http\JsonResponse;
class FormatsController extends Controller
{
public function dateFormats(): JsonResponse
{
return response()->json([
'date_formats' => DateFormatter::get_list(),
]);
}
public function timeFormats(): JsonResponse
{
return response()->json([
'time_formats' => TimeFormatter::get_list(),
]);
}
public function timezones(): JsonResponse
{
return response()->json([
'time_zones' => TimeZones::get_list(),
]);
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Models\CompanyInvitation;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvitationResponseController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
/**
* Get pending invitations for the authenticated user.
*/
public function pending(Request $request): JsonResponse
{
$invitations = $this->invitationService->getPendingForUser($request->user());
return response()->json([
'invitations' => CompanyInvitationResource::collection($invitations),
]);
}
/**
* Accept a company invitation.
*/
public function accept(Request $request, CompanyInvitation $invitation): JsonResponse
{
$this->invitationService->accept($invitation, $request->user());
return response()->json(['success' => true]);
}
/**
* Decline a company invitation.
*/
public function decline(Request $request, CompanyInvitation $invitation): JsonResponse
{
$this->invitationService->decline($invitation, $request->user());
return response()->json(['success' => true]);
}
}

View File

@@ -1,76 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Modules;
use App\Http\Controllers\Controller;
use App\Models\Module;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
/**
* Read-only company-context Active Modules index.
*
* Lists every module the super admin has activated on this instance
* (Module::enabled = true) and reports whether each one has registered a
* settings schema. The frontend uses this to render the company-context
* "Modules" landing page with a Settings button per active module.
*
* Activation is instance-global; per-company customization happens through
* settings (per CompanySetting under the module.{slug}.* prefix).
*
* Slug convention: nwidart stores the module's PascalCase class name in
* `modules.name` (e.g. "HelloWorld"), but URLs and registry keys use the
* kebab-case form ("hello-world") for readability. We normalize via
* Str::kebab() so module authors can call Registry::registerMenu('hello-world')
* naturally without thinking about the storage format.
*/
class CompanyModulesController extends Controller
{
public function index(): JsonResponse
{
$this->authorize('manage modules');
$modules = Module::query()
->where('enabled', true)
->get()
->map(function (Module $module) {
$slug = Str::kebab($module->name);
$menu = ModuleRegistry::menuFor($slug);
$translatedMenuTitle = $this->translateMenuTitle($menu['title'] ?? null);
$displayName = $translatedMenuTitle ?? Str::headline($module->name);
return [
'slug' => $slug,
'name' => $module->name,
'display_name' => $displayName,
'version' => $module->version,
'has_settings' => ModuleRegistry::settingsFor($slug) !== null,
'menu' => $menu === null
? null
: [
...$menu,
'title' => $translatedMenuTitle ?? $menu['title'],
],
];
})
->values();
return response()->json(['data' => $modules]);
}
private function translateMenuTitle(?string $title): ?string
{
if ($title === null) {
return null;
}
$translatedTitle = __($title);
if (! is_string($translatedTitle) || $translatedTitle === $title) {
return null;
}
return $translatedTitle;
}
}

View File

@@ -1,176 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Modules;
use App\Http\Controllers\Controller;
use App\Models\CompanySetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use InvoiceShelf\Modules\Settings\Schema;
/**
* Schema-driven module settings backend.
*
* Each active module's ServiceProvider::boot() calls
* Registry::registerSettings($slug, $schema) once at app boot. This controller
* exposes that schema to the frontend, validates submitted values against the
* schema's per-field rules, and persists per-company values into CompanySetting
* under the key prefix `module.{slug}.{field_key}`.
*
* Activation is instance-global, but settings are per-company — two companies
* on the same instance can configure the same activated module differently.
*/
class ModuleSettingsController extends Controller
{
public function show(Request $request, string $slug): JsonResponse
{
$this->authorize('manage modules');
$schema = ModuleRegistry::settingsFor($slug);
if ($schema === null) {
abort(404, "Module '{$slug}' has not registered a settings schema.");
}
$values = collect($schema->fields())
->mapWithKeys(fn (array $field) => [
$field['key'] => CompanySetting::getSetting(
"module.{$slug}.{$field['key']}",
$request->header('company')
) ?? $field['default'],
])
->all();
return response()->json([
'schema' => $this->translateSchema($schema->toArray()),
'values' => $values,
]);
}
public function update(Request $request, string $slug): JsonResponse
{
$this->authorize('manage modules');
$schema = ModuleRegistry::settingsFor($slug);
if ($schema === null) {
abort(404, "Module '{$slug}' has not registered a settings schema.");
}
$rules = $this->buildRules($schema);
$allowedKeys = array_keys($rules);
$validated = $request->validate($rules);
$companyId = $request->header('company');
// Only persist keys the schema knows about — silently drop unknown keys
// rather than letting modules write arbitrary settings.
$settingsToWrite = [];
foreach ($allowedKeys as $key) {
if (array_key_exists($key, $validated)) {
$settingsToWrite["module.{$slug}.{$key}"] = $this->normalizeForStorage($validated[$key]);
}
}
if ($settingsToWrite !== []) {
CompanySetting::setSettings($settingsToWrite, $companyId);
}
return response()->json(['success' => true]);
}
/**
* Convert a Schema's field rule arrays into a flat Laravel validator rules array.
*
* Field rules are passed through verbatim — a field declared as
* `'rules' => ['required', 'string', 'max:255']` becomes
* `['my_field' => ['required', 'string', 'max:255']]`. The frontend's
* BaseSchemaForm.vue understands a subset of these for client-side validation;
* the backend validator is the source of truth.
*
* @return array<string, array<int, string>>
*/
private function buildRules(Schema $schema): array
{
$rules = [];
foreach ($schema->fields() as $field) {
$rules[$field['key']] = $this->withTypeRule($field);
}
return $rules;
}
/**
* Prepend a sensible per-type validation rule so booleans must be booleans,
* numbers must be numeric, etc., even if the module didn't declare it.
*
* @param array<string, mixed> $field
* @return array<int, string>
*/
private function withTypeRule(array $field): array
{
/** @var array<int, string> $declared */
$declared = $field['rules'] ?? [];
$typeRule = match ($field['type']) {
'switch' => 'boolean',
'number' => 'numeric',
'multiselect' => 'array',
default => 'nullable',
};
// Avoid duplicating the type rule if the module already declared it
if (in_array($typeRule, $declared, true)) {
return $declared;
}
return array_merge([$typeRule], $declared);
}
/**
* CompanySetting stores everything as strings. Cast booleans, ints, and
* arrays to a representation that round-trips through getSetting/setSetting
* without losing information. Reads happen in show() above and naturally
* return strings; the frontend handles re-coercion in BaseSchemaForm.vue.
*/
/**
* Translate section titles and field labels in the schema so the
* frontend receives ready-to-display strings instead of Laravel
* translation keys it cannot resolve (e.g. `helloworld::settings.greeting`).
*
* @param array{sections: list<array<string, mixed>>} $schema
* @return array{sections: list<array<string, mixed>>}
*/
private function translateSchema(array $schema): array
{
foreach ($schema['sections'] as &$section) {
if (isset($section['title'])) {
$section['title'] = __($section['title']);
}
foreach ($section['fields'] as &$field) {
if (isset($field['label'])) {
$field['label'] = __($field['label']);
}
}
}
return $schema;
}
private function normalizeForStorage(mixed $value): string
{
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_array($value)) {
return json_encode($value, JSON_UNESCAPED_SLASHES) ?: '[]';
}
return (string) ($value ?? '');
}
}

View File

@@ -1,53 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\CompanyLogoRequest;
use App\Http\Requests\CompanyRequest;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
class CompanyController extends Controller
{
public function updateCompany(CompanyRequest $request)
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
$company->update($request->getCompanyPayload());
$company->address()->updateOrCreate(['company_id' => $company->id], $request->address);
return new CompanyResource($company);
}
public function uploadCompanyLogo(CompanyLogoRequest $request)
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
$data = json_decode($request->company_logo);
if (isset($request->is_company_logo_removed) && (bool) $request->is_company_logo_removed) {
$company->clearMediaCollection('logo');
}
if ($data) {
$company = Company::find($request->header('company'));
if ($company) {
$company->clearMediaCollection('logo');
$company->addMediaFromBase64($data->data)
->usingFileName($data->name)
->toMediaCollection('logo');
}
}
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\CompanyMailConfigurationRequest;
use App\Mail\TestMail;
use App\Services\CompanyMailConfigService;
use App\Services\MailConfigurationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
class CompanyMailConfigurationController extends Controller
{
public function __construct(private readonly MailConfigurationService $mailConfigurationService) {}
public function getDefaultConfig(Request $request): JsonResponse
{
return response()->json($this->mailConfigurationService->getDefaultConfig());
}
public function getMailConfig(Request $request): JsonResponse
{
return response()->json(
$this->mailConfigurationService->getCompanyConfig($request->header('company'))
);
}
public function saveMailConfig(CompanyMailConfigurationRequest $request): JsonResponse
{
$this->authorize('owner only');
$this->mailConfigurationService->saveCompanyConfig(
$request->header('company'),
$request->validated()
);
return response()->json(['success' => true]);
}
public function testMailConfig(Request $request): JsonResponse
{
$this->authorize('owner only');
$this->validate($request, [
'to' => 'required|email',
'subject' => 'required',
'message' => 'required',
]);
CompanyMailConfigService::apply($request->header('company'));
Mail::to($request->to)->send(new TestMail($request->subject, $request->message));
return response()->json(['success' => true]);
}
}

View File

@@ -1,81 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\GetSettingsRequest;
use App\Http\Requests\UpdateSettingsRequest;
use App\Models\Company;
use App\Models\CompanySetting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Silber\Bouncer\BouncerFacade;
class CompanySettingsController extends Controller
{
public function show(GetSettingsRequest $request): JsonResponse
{
$settings = CompanySetting::getSettings((array) $request->settings, $request->header('company'));
return response()->json($settings);
}
public function update(UpdateSettingsRequest $request): JsonResponse
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
$data = $request->settings;
if (
Arr::exists($data, 'currency') &&
(CompanySetting::getSetting('currency', $company->id) !== $data['currency']) &&
$company->hasTransactions()
) {
return response()->json([
'success' => false,
'message' => 'Cannot update company currency after transactions are created.',
]);
}
CompanySetting::setSettings($data, $request->header('company'));
return response()->json([
'success' => true,
]);
}
public function checkTransactions(Request $request): JsonResponse
{
$company = Company::find($request->header('company'));
$this->authorize('manage company', $company);
return response()->json([
'has_transactions' => $company->hasTransactions(),
]);
}
public function transferOwnership(Request $request, User $user): JsonResponse
{
$company = Company::find($request->header('company'));
$this->authorize('transfer company ownership', $company);
if (! $user->hasCompany($company->id)) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this company.',
]);
}
$company->update(['owner_id' => $user->id]);
BouncerFacade::scope()->to($company->id);
BouncerFacade::sync($user)->roles(['owner']);
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Models\Company;
use App\Models\CompanyInvitation;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvitationController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
public function index(Request $request): JsonResponse
{
$company = Company::find($request->header('company'));
$invitations = CompanyInvitation::where('company_id', $company->id)
->pending()
->with(['role', 'invitedBy'])
->latest()
->get();
return response()->json([
'invitations' => CompanyInvitationResource::collection($invitations),
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email',
'role_id' => 'required|exists:roles,id',
]);
$company = Company::find($request->header('company'));
$invitation = $this->invitationService->invite(
$company,
$request->email,
$request->role_id,
$request->user()
);
return response()->json([
'success' => true,
'invitation' => new CompanyInvitationResource($invitation->load(['company', 'role', 'invitedBy'])),
]);
}
public function destroy(CompanyInvitation $companyInvitation): JsonResponse
{
if ($companyInvitation->status !== CompanyInvitation::STATUS_PENDING) {
return response()->json([
'success' => false,
'message' => 'Only pending invitations can be cancelled.',
], 422);
}
$companyInvitation->delete();
return response()->json(['success' => true]);
}
}

View File

@@ -1,73 +0,0 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\AvatarRequest;
use App\Http\Requests\GetSettingsRequest;
use App\Http\Requests\ProfileRequest;
use App\Http\Requests\UpdateSettingsRequest;
use App\Http\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UserProfileController extends Controller
{
public function show(Request $request)
{
return new UserResource($request->user());
}
public function update(ProfileRequest $request)
{
$user = $request->user();
$user->update($request->validated());
return new UserResource($user);
}
public function uploadAvatar(AvatarRequest $request)
{
$user = auth()->user();
if (isset($request->is_admin_avatar_removed) && (bool) $request->is_admin_avatar_removed) {
$user->clearMediaCollection('admin_avatar');
}
if ($user && $request->hasFile('admin_avatar')) {
$user->clearMediaCollection('admin_avatar');
$user->addMediaFromRequest('admin_avatar')
->toMediaCollection('admin_avatar');
}
if ($user && $request->has('avatar')) {
$data = json_decode($request->avatar);
$user->clearMediaCollection('admin_avatar');
$user->addMediaFromBase64($data->data)
->usingFileName($data->name)
->toMediaCollection('admin_avatar');
}
return new UserResource($user);
}
public function showSettings(GetSettingsRequest $request): JsonResponse
{
$user = $request->user();
return response()->json($user->getSettings((array) $request->settings));
}
public function updateSettings(UpdateSettingsRequest $request): JsonResponse
{
$user = $request->user();
$user->setSettings($request->settings);
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers\Modules;
use App\Http\Controllers\Controller;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ScriptController extends Controller
{
/**
* Serve the requested module-registered script.
*
* Modules call \InvoiceShelf\Modules\Registry::registerScript($name, $path)
* from their ServiceProvider::boot() to inject custom JS into the host app.
*
* @throws NotFoundHttpException
*/
public function __invoke(Request $request, string $script): Response
{
$path = ModuleRegistry::scriptFor($script);
abort_if($path === null, 404);
return response(
file_get_contents($path),
200,
[
'Content-Type' => 'application/javascript',
]
)->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Http\Controllers\Modules;
use App\Http\Controllers\Controller;
use DateTime;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use InvoiceShelf\Modules\Registry as ModuleRegistry;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class StyleController extends Controller
{
/**
* Serve the requested module-registered stylesheet.
*
* Modules call \InvoiceShelf\Modules\Registry::registerStyle($name, $path)
* from their ServiceProvider::boot() to inject custom CSS into the host app.
*
* @throws NotFoundHttpException
*/
public function __invoke(Request $request, string $style): Response
{
$path = ModuleRegistry::styleFor($style);
abort_if($path === null, 404);
return response(
file_get_contents($path),
200,
[
'Content-Type' => 'text/css',
]
)->setLastModified(DateTime::createFromFormat('U', (string) filemtime($path)));
}
}

View File

@@ -1,46 +0,0 @@
<?php
namespace App\Http\Controllers\Pdf;
use App\Http\Controllers\Controller;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\EstimateService;
use App\Services\InvoiceService;
use Illuminate\Http\Request;
class DocumentPdfController extends Controller
{
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly EstimateService $estimateService,
) {}
public function invoice(Request $request, Invoice $invoice)
{
if ($request->has('preview')) {
return $this->invoiceService->getPdfData($invoice);
}
return $invoice->getGeneratedPDFOrStream('invoice');
}
public function estimate(Request $request, Estimate $estimate)
{
if ($request->has('preview')) {
return $this->estimateService->getPdfData($estimate);
}
return $estimate->getGeneratedPDFOrStream('estimate');
}
public function payment(Request $request, Payment $payment)
{
if ($request->has('preview')) {
return view('app.pdf.payment.payment');
}
return $payment->getGeneratedPDFOrStream('payment');
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Http\Controllers\Setup;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\InstallWizardAuth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = User::where('role', 'super admin')->first();
if (! $user) {
return response()->json([
'message' => 'Super admin user not found.',
], 404);
}
$company = $user->companies()->first();
if (! $company) {
return response()->json([
'message' => 'Super admin company not found.',
], 422);
}
Auth::guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$user->tokens()->where('name', InstallWizardAuth::TOKEN_NAME)->delete();
$token = $user->createToken(
InstallWizardAuth::TOKEN_NAME,
[InstallWizardAuth::TOKEN_ABILITY],
)->plainTextToken;
return response()->json([
'success' => true,
'type' => 'Bearer',
'token' => $token,
'user' => $user,
'company' => $company,
]);
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Http\Controllers\Setup;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SessionLoginController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
if (Auth::guard('web')->check()) {
Auth::guard('web')->logout();
}
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
Auth::guard('web')->login($user);
if ($request->hasSession()) {
$request->session()->regenerate();
}
return response()->json([
'success' => true,
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Auth;
namespace App\Http\Controllers\V1\Admin\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Auth;
namespace App\Http\Controllers\V1\Admin\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Auth;
namespace App\Http\Controllers\V1\Admin\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Auth;
namespace App\Http\Controllers\V1\Admin\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
@@ -61,14 +61,10 @@ class RegisterController extends Controller
*/
protected function create(array $data)
{
$user = User::create([
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
]);
$user->setSettings(['language' => 'default']);
return $user;
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Auth;
namespace App\Http\Controllers\V1\Admin\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Auth;
namespace App\Http\Controllers\V1\Admin\Auth;
use App\Http\Controllers\Controller;
use App\Providers\AppServiceProvider;

View File

@@ -0,0 +1,18 @@
<?php
// Implementation taken from nova-backup-tool - https://github.com/spatie/nova-backup-tool/
namespace App\Http\Controllers\V1\Admin\Backup;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ApiController extends Controller
{
public function respondSuccess(): JsonResponse
{
return response()->json([
'success' => true,
]);
}
}

View File

@@ -0,0 +1,111 @@
<?php
// Implementation taken from nova-backup-tool - https://github.com/spatie/nova-backup-tool/
namespace App\Http\Controllers\V1\Admin\Backup;
use App\Jobs\CreateBackupJob;
use App\Models\FileDisk;
use App\Rules\Backup\PathToZip;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Helpers\Format;
class BackupsController extends ApiController
{
/**
* Display a listing of the resource.
*
* @return JsonResponse
*/
public function index(Request $request)
{
$this->authorize('manage backups');
$configuredBackupDisks = config('backup.backup.destination.disks');
try {
if ($request->file_disk_id) {
$fileDisk = FileDisk::find($request->file_disk_id);
if ($fileDisk) {
$fileDisk->setConfig();
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
config(['backup.backup.destination.disks' => [$prefix.$fileDisk->driver]]);
$configuredBackupDisks = config('backup.backup.destination.disks');
}
}
$backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name'));
$backups = Cache::remember("backups-{$request->file_disk_id}", now()->addSeconds(4), function () use ($backupDestination) {
return $backupDestination
->backups()
->map(function (Backup $backup) {
return [
'path' => $backup->path(),
'created_at' => $backup->date()->format('Y-m-d H:i:s'),
'size' => Format::humanReadableSize($backup->sizeInBytes()),
];
})
->toArray();
});
return response()->json([
'backups' => $backups,
'disks' => $configuredBackupDisks,
]);
} catch (\Exception $e) {
return response()->json([
'backups' => [],
'error' => 'invalid_disk_credentials',
'error_message' => $e->getMessage(),
'disks' => $configuredBackupDisks,
]);
}
}
/**
* Store a newly created resource in storage.
*
* @return JsonResponse
*/
public function store(Request $request)
{
$this->authorize('manage backups');
$data = $request->all();
$data['company'] = $request->header('company');
dispatch(new CreateBackupJob($data))->onQueue(config('backup.queue.name'));
return $this->respondSuccess();
}
/**
* Remove the specified resource from storage.
*
* @return JsonResponse
*/
public function destroy($disk, Request $request)
{
$this->authorize('manage backups');
$validated = $request->validate([
'path' => ['required', new PathToZip],
]);
$backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name'));
$backupDestination
->backups()
->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
})
->delete();
return $this->respondSuccess();
}
}

View File

@@ -0,0 +1,59 @@
<?php
// Implementation taken from nova-backup-tool - https://github.com/spatie/nova-backup-tool/
namespace App\Http\Controllers\V1\Admin\Backup;
use App\Rules\Backup\PathToZip;
use Illuminate\Http\Request;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupDestination;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadBackupController extends ApiController
{
public function __invoke(Request $request)
{
$this->authorize('manage backups');
$validated = $request->validate([
'path' => ['required', new PathToZip],
]);
$backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name'));
$backup = $backupDestination->backups()->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
});
if (! $backup) {
return response('Backup not found', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $this->respondWithBackupStream($backup);
}
public function respondWithBackupStream(Backup $backup): StreamedResponse
{
$fileName = pathinfo($backup->path(), PATHINFO_BASENAME);
$downloadHeaders = [
'Cache-Control' => 'must-revalidate, post-check=0, pre-check=0',
'Content-Type' => 'application/zip',
'Content-Length' => $backup->sizeInBytes(),
'Content-Disposition' => 'attachment; filename="'.$fileName.'"',
'Pragma' => 'public',
];
return response()->stream(function () use ($backup) {
$stream = $backup->stream();
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
}, 200, $downloadHeaders);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\V1\Admin\Company;
use App\Facades\Hashids;
use App\Http\Controllers\Controller;
use App\Http\Requests\CompaniesRequest;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use App\Models\User;
use Illuminate\Http\Request;
use Silber\Bouncer\BouncerFacade;
class CompaniesController extends Controller
{
public function store(CompaniesRequest $request)
{
$this->authorize('create company');
$user = $request->user();
$company = Company::create($request->getCompanyPayload());
$company->unique_hash = Hashids::connection(Company::class)->encode($company->id);
$company->save();
$company->setupDefaultData();
$user->companies()->attach($company->id);
$user->assign('super admin');
if ($request->address) {
$company->address()->create($request->address);
}
return new CompanyResource($company);
}
public function destroy(Request $request)
{
$company = Company::find($request->header('company'));
$this->authorize('delete company', $company);
$user = $request->user();
if ($request->name !== $company->name) {
return respondJson('company_name_must_match_with_given_name', 'Company name must match with given name');
}
if ($user->loadCount('companies')->companies_count <= 1) {
return respondJson('You_cannot_delete_all_companies', 'You cannot delete all companies');
}
$company->deleteCompany($user);
return response()->json([
'success' => true,
]);
}
public function transferOwnership(Request $request, User $user)
{
$company = Company::find($request->header('company'));
$this->authorize('transfer company ownership', $company);
if (! $user->hasCompany($company->id)) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this company.',
]);
}
$company->update(['owner_id' => $user->id]);
BouncerFacade::sync($user)->roles(['super admin']);
return response()->json([
'success' => true,
]);
}
public function getUserCompanies(Request $request)
{
$companies = $request->user()->companies;
return CompanyResource::collection($companies);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\V1\Admin\Company;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyResource;
use App\Models\Company;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CompanyController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
$company = Company::find($request->header('company'));
return new CompanyResource($company);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Config;
namespace App\Http\Controllers\V1\Admin\Config;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Config;
namespace App\Http\Controllers\V1\Admin\Config;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Config;
namespace App\Http\Controllers\V1\Admin\Config;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

View File

@@ -1,21 +1,16 @@
<?php
namespace App\Http\Controllers\Company\CustomField;
namespace App\Http\Controllers\V1\Admin\CustomField;
use App\Http\Controllers\Controller;
use App\Http\Requests\CustomFieldRequest;
use App\Http\Resources\CustomFieldResource;
use App\Models\CustomField;
use App\Services\CustomFieldService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CustomFieldsController extends Controller
{
public function __construct(
private readonly CustomFieldService $customFieldService,
) {}
/**
* Display a listing of the resource.
*
@@ -45,7 +40,7 @@ class CustomFieldsController extends Controller
{
$this->authorize('create', CustomField::class);
$customField = $this->customFieldService->create($request);
$customField = CustomField::createCustomField($request);
return new CustomFieldResource($customField);
}
@@ -74,7 +69,7 @@ class CustomFieldsController extends Controller
{
$this->authorize('update', $customField);
$this->customFieldService->update($customField, $request);
$customField->updateCustomField($request);
return new CustomFieldResource($customField);
}

View File

@@ -1,7 +1,9 @@
<?php
namespace App\Services;
namespace App\Http\Controllers\V1\Admin\Customer;
use App\Http\Controllers\Controller;
use App\Http\Resources\CustomerResource;
use App\Models\CompanySetting;
use App\Models\Customer;
use App\Models\Expense;
@@ -9,122 +11,19 @@ use App\Models\Invoice;
use App\Models\Payment;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Illuminate\Http\Response;
class CustomerService
class CustomerStatsController extends Controller
{
public function create(Request $request): Customer
{
$customer = Customer::create($request->getCustomerPayload());
if ($request->shipping) {
if ($request->hasAddress($request->shipping)) {
$customer->addresses()->create($request->getShippingAddress());
}
}
if ($request->billing) {
if ($request->hasAddress($request->billing)) {
$customer->addresses()->create($request->getBillingAddress());
}
}
$customFields = $request->customFields;
if ($customFields) {
$customer->addCustomFields($customFields);
}
return Customer::with('billingAddress', 'shippingAddress', 'fields')->find($customer->id);
}
/**
* @throws ValidationException
* Handle the incoming request.
*
* @return Response
*/
public function update(Request $request, Customer $customer): Customer
public function __invoke(Request $request, Customer $customer)
{
$condition = $customer->estimates()->exists() || $customer->invoices()->exists() || $customer->payments()->exists() || $customer->recurringInvoices()->exists();
$this->authorize('view', $customer);
if (($customer->currency_id !== $request->currency_id) && $condition) {
throw ValidationException::withMessages([
'currency_id' => ['you_cannot_edit_currency'],
]);
}
$customer->update($request->getCustomerPayload());
$customer->addresses()->delete();
if ($request->shipping) {
if ($request->hasAddress($request->shipping)) {
$customer->addresses()->create($request->getShippingAddress());
}
}
if ($request->billing) {
if ($request->hasAddress($request->billing)) {
$customer->addresses()->create($request->getBillingAddress());
}
}
$customFields = $request->customFields;
if ($customFields) {
$customer->updateCustomFields($customFields);
}
return Customer::with('billingAddress', 'shippingAddress', 'fields')->find($customer->id);
}
public function delete(Collection $ids): bool
{
foreach ($ids as $id) {
$customer = Customer::find($id);
if ($customer->estimates()->exists()) {
$customer->estimates()->delete();
}
if ($customer->invoices()->exists()) {
$customer->invoices->map(function ($invoice) {
if ($invoice->transactions()->exists()) {
$invoice->transactions()->delete();
}
$invoice->delete();
});
}
if ($customer->payments()->exists()) {
$customer->payments()->delete();
}
if ($customer->addresses()->exists()) {
$customer->addresses()->delete();
}
if ($customer->expenses()->exists()) {
$customer->expenses()->delete();
}
if ($customer->recurringInvoices()->exists()) {
foreach ($customer->recurringInvoices as $recurringInvoice) {
if ($recurringInvoice->items()->exists()) {
$recurringInvoice->items()->delete();
}
$recurringInvoice->delete();
}
}
$customer->delete();
}
return true;
}
public function getStats(Customer $customer, int $companyId, bool $previousYear = false): array
{
$i = 0;
$months = [];
$invoiceTotals = [];
@@ -132,7 +31,7 @@ class CustomerService
$receiptTotals = [];
$netProfits = [];
$monthCounter = 0;
$fiscalYear = CompanySetting::getSetting('fiscal_year', $companyId);
$fiscalYear = CompanySetting::getSetting('fiscal_year', $request->header('company'));
$startDate = Carbon::now();
$start = Carbon::now();
$end = Carbon::now();
@@ -149,12 +48,11 @@ class CustomerService
$end->subYear()->month($companyStartMonth)->endOfMonth();
}
if ($previousYear) {
if ($request->has('previous_year')) {
$startDate->subYear()->startOfMonth();
$start->subYear()->startOfMonth();
$end->subYear()->endOfMonth();
}
while ($monthCounter < 12) {
array_push(
$invoiceTotals,
@@ -223,7 +121,7 @@ class CustomerService
->sum('base_amount');
$netProfit = (int) $totalReceipts - (int) $totalExpenses;
return [
$chartData = [
'months' => $months,
'invoiceTotals' => $invoiceTotals,
'expenseTotals' => $expenseTotals,
@@ -234,5 +132,12 @@ class CustomerService
'totalReceipts' => $totalReceipts,
'totalExpenses' => $totalExpenses,
];
$customer = Customer::find($customer->id);
return (new CustomerResource($customer))
->additional(['meta' => [
'chartData' => $chartData,
]]);
}
}

View File

@@ -1,22 +1,17 @@
<?php
namespace App\Http\Controllers\Company\Customer;
namespace App\Http\Controllers\V1\Admin\Customer;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\Http\Requests\DeleteCustomersRequest;
use App\Http\Resources\CustomerResource;
use App\Models\Customer;
use App\Services\CustomerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CustomersController extends Controller
{
public function __construct(
private readonly CustomerService $customerService,
) {}
/**
* Display a listing of the resource.
*
@@ -51,7 +46,7 @@ class CustomersController extends Controller
{
$this->authorize('create', Customer::class);
$customer = $this->customerService->create($request);
$customer = Customer::createCustomer($request);
return new CustomerResource($customer);
}
@@ -78,7 +73,11 @@ class CustomersController extends Controller
{
$this->authorize('update', $customer);
$customer = $this->customerService->update($request, $customer);
$customer = Customer::updateCustomer($request, $customer);
if (is_string($customer)) {
return respondJson('you_cannot_edit_currency', 'Cannot change currency once transactions created');
}
return new CustomerResource($customer);
}
@@ -97,7 +96,7 @@ class CustomersController extends Controller
->whereIn('id', $request->ids)
->pluck('id');
$this->customerService->delete($ids);
Customer::deleteCustomers($ids);
return response()->json([
'success' => true,

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Dashboard;
namespace App\Http\Controllers\V1\Admin\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Company;
@@ -60,27 +60,39 @@ class DashboardController extends Controller
}
while ($monthCounter < 12) {
$invoice_totals[] = Invoice::whereBetween(
'invoice_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_total');
$expense_totals[] = Expense::whereBetween(
'expense_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount');
$receipt_totals[] = Payment::whereBetween(
'payment_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount');
$net_income_totals[] = ($receipt_totals[$i] - $expense_totals[$i]);
array_push(
$invoice_totals,
Invoice::whereBetween(
'invoice_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_total')
);
array_push(
$expense_totals,
Expense::whereBetween(
'expense_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount')
);
array_push(
$receipt_totals,
Payment::whereBetween(
'payment_date',
[$start->format('Y-m-d'), $end->format('Y-m-d')]
)
->whereCompany()
->sum('base_amount')
);
array_push(
$net_income_totals,
($receipt_totals[$i] - $expense_totals[$i])
);
$i++;
$months[] = $start->translatedFormat('M');
array_push($months, $start->translatedFormat('M'));
$monthCounter++;
$end->startOfMonth();
$start->addMonth()->startOfMonth();

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Http\Controllers\Controller;
use App\Models\Estimate;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ChangeEstimateStatusController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$estimate->update($request->only('status'));
return response()->json([
'success' => true,
]);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Facades\Hashids;
use App\Http\Controllers\Controller;
use App\Http\Resources\EstimateResource;
use App\Models\CompanySetting;
use App\Models\Estimate;
use App\Services\SerialNumberFormatter;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CloneEstimateController extends Controller
{
/**
* Mail a specific invoice to the corresponding customer's email address.
*
* @return JsonResponse
*/
public function __invoke(Request $request, Estimate $estimate)
{
$this->authorize('view', $estimate);
$this->authorize('create', Estimate::class);
$date = Carbon::now();
$serial = (new SerialNumberFormatter)
->setModel($estimate)
->setCompany($estimate->company_id)
->setCustomer($estimate->customer_id)
->setNextNumbers();
$due_date = null;
$dueDateEnabled = CompanySetting::getSetting(
'estimate_set_expiry_date_automatically',
$request->header('company')
);
if ($dueDateEnabled === 'YES') {
$dueDateDays = intval(CompanySetting::getSetting(
'estimate_expiry_date_days',
$request->header('company')
));
$due_date = Carbon::now()->addDays($dueDateDays)->format('Y-m-d');
}
$exchange_rate = $estimate->exchange_rate;
$newEstimate = Estimate::create([
'estimate_date' => $date->format('Y-m-d'),
'expiry_date' => $due_date,
'estimate_number' => $serial->getNextNumber(),
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
'reference_number' => $estimate->reference_number,
'customer_id' => $estimate->customer_id,
'company_id' => $request->header('company'),
'template_name' => $estimate->template_name,
'status' => Estimate::STATUS_DRAFT,
'sub_total' => $estimate->sub_total,
'discount' => $estimate->discount,
'discount_type' => $estimate->discount_type,
'discount_val' => $estimate->discount_val,
'total' => $estimate->total,
'due_amount' => $estimate->total,
'tax_per_item' => $estimate->tax_per_item,
'discount_per_item' => $estimate->discount_per_item,
'tax' => $estimate->tax,
'notes' => $estimate->notes,
'exchange_rate' => $exchange_rate,
'base_total' => $estimate->total * $exchange_rate,
'base_discount_val' => $estimate->discount_val * $exchange_rate,
'base_sub_total' => $estimate->sub_total * $exchange_rate,
'base_tax' => $estimate->tax * $exchange_rate,
'base_due_amount' => $estimate->total * $exchange_rate,
'currency_id' => $estimate->currency_id,
'sales_tax_type' => $estimate->sales_tax_type,
'sales_tax_address_type' => $estimate->sales_tax_address_type,
]);
$newEstimate->unique_hash = Hashids::connection(Estimate::class)->encode($newEstimate->id);
$newEstimate->save();
$estimate->load('items.taxes');
$estimateItems = $estimate->items->toArray();
foreach ($estimateItems as $estimateItem) {
$estimateItem['company_id'] = $request->header('company');
$estimateItem['name'] = $estimateItem['name'];
$estimateItem['exchange_rate'] = $exchange_rate;
$estimateItem['base_price'] = $estimateItem['price'] * $exchange_rate;
$estimateItem['base_discount_val'] = $estimateItem['discount_val'] * $exchange_rate;
$estimateItem['base_tax'] = $estimateItem['tax'] * $exchange_rate;
$estimateItem['base_total'] = $estimateItem['total'] * $exchange_rate;
$item = $newEstimate->items()->create($estimateItem);
if (array_key_exists('taxes', $estimateItem) && $estimateItem['taxes']) {
foreach ($estimateItem['taxes'] as $tax) {
$tax['company_id'] = $request->header('company');
if ($tax['amount']) {
$item->taxes()->create($tax);
}
}
}
}
if ($estimate->taxes) {
foreach ($estimate->taxes->toArray() as $tax) {
$tax['company_id'] = $request->header('company');
$newEstimate->taxes()->create($tax);
}
}
if ($estimate->fields()->exists()) {
$customFields = [];
foreach ($estimate->fields as $data) {
$customFields[] = [
'id' => $data->custom_field_id,
'value' => $data->defaultAnswer,
];
}
$newEstimate->addCustomFields($customFields);
}
return new EstimateResource($newEstimate);
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Facades\Hashids;
use App\Http\Controllers\Controller;
use App\Http\Resources\InvoiceResource;
use App\Models\CompanySetting;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Services\SerialNumberFormatter;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
class ConvertEstimateController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request, Estimate $estimate, Invoice $invoice)
{
$this->authorize('create', Invoice::class);
$estimate->load(['items', 'items.taxes', 'customer', 'taxes']);
$invoice_date = Carbon::now();
$due_date = null;
$dueDateEnabled = CompanySetting::getSetting(
'invoice_set_due_date_automatically',
$request->header('company')
);
if ($dueDateEnabled === 'YES') {
$dueDateDays = intval(CompanySetting::getSetting(
'invoice_due_date_days',
$request->header('company')
));
$due_date = Carbon::now()->addDays($dueDateDays)->format('Y-m-d');
}
$serial = (new SerialNumberFormatter)
->setModel($invoice)
->setCompany($estimate->company_id)
->setCustomer($estimate->customer_id)
->setNextNumbers();
$templateName = $estimate->getInvoiceTemplateName();
$exchange_rate = $estimate->exchange_rate;
$invoice = Invoice::create([
'creator_id' => Auth::id(),
'invoice_date' => $invoice_date->format('Y-m-d'),
'due_date' => $due_date,
'invoice_number' => $serial->getNextNumber(),
'sequence_number' => $serial->nextSequenceNumber,
'customer_sequence_number' => $serial->nextCustomerSequenceNumber,
'reference_number' => $serial->getNextNumber(),
'customer_id' => $estimate->customer_id,
'company_id' => $request->header('company'),
'template_name' => $templateName,
'status' => Invoice::STATUS_DRAFT,
'paid_status' => Invoice::STATUS_UNPAID,
'sub_total' => $estimate->sub_total,
'discount' => $estimate->discount,
'discount_type' => $estimate->discount_type,
'discount_val' => $estimate->discount_val,
'total' => $estimate->total,
'due_amount' => $estimate->total,
'tax_per_item' => $estimate->tax_per_item,
'discount_per_item' => $estimate->discount_per_item,
'tax' => $estimate->tax,
'notes' => $estimate->notes,
'exchange_rate' => $exchange_rate,
'base_discount_val' => $estimate->discount_val * $exchange_rate,
'base_sub_total' => $estimate->sub_total * $exchange_rate,
'base_total' => $estimate->total * $exchange_rate,
'base_tax' => $estimate->tax * $exchange_rate,
'currency_id' => $estimate->currency_id,
'sales_tax_type' => $estimate->sales_tax_type,
'sales_tax_address_type' => $estimate->sales_tax_address_type,
]);
$invoice->unique_hash = Hashids::connection(Invoice::class)->encode($invoice->id);
$invoice->save();
$invoiceItems = $estimate->items->toArray();
foreach ($invoiceItems as $invoiceItem) {
$invoiceItem['company_id'] = $request->header('company');
$invoiceItem['name'] = $invoiceItem['name'];
$estimateItem['exchange_rate'] = $exchange_rate;
$estimateItem['base_price'] = $invoiceItem['price'] * $exchange_rate;
$estimateItem['base_discount_val'] = $invoiceItem['discount_val'] * $exchange_rate;
$estimateItem['base_tax'] = $invoiceItem['tax'] * $exchange_rate;
$estimateItem['base_total'] = $invoiceItem['total'] * $exchange_rate;
$item = $invoice->items()->create($invoiceItem);
if (array_key_exists('taxes', $invoiceItem) && $invoiceItem['taxes']) {
foreach ($invoiceItem['taxes'] as $tax) {
$tax['company_id'] = $request->header('company');
if ($tax['amount']) {
$item->taxes()->create($tax);
}
}
}
}
if ($estimate->taxes) {
foreach ($estimate->taxes->toArray() as $tax) {
$tax['company_id'] = $request->header('company');
$tax['exchange_rate'] = $exchange_rate;
$tax['base_amount'] = $tax['amount'] * $exchange_rate;
$tax['currency_id'] = $estimate->currency_id;
unset($tax['estimate_id']);
$invoice->taxes()->create($tax);
}
}
$estimate->checkForEstimateConvertAction();
$invoice = Invoice::find($invoice->id);
return new InvoiceResource($invoice);
}
}

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Http\Controllers\Company\Estimate;
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Http\Controllers\Controller;
use App\Models\Estimate;
use App\Services\Pdf\PdfTemplateUtils;
use App\Space\PdfTemplateUtils;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteEstimatesRequest;
use App\Http\Requests\EstimatesRequest;
use App\Http\Resources\EstimateResource;
use App\Jobs\GenerateEstimatePdfJob;
use App\Models\Estimate;
use Illuminate\Http\Request;
class EstimatesController extends Controller
{
public function index(Request $request)
{
$this->authorize('viewAny', Estimate::class);
$limit = $request->has('limit') ? $request->limit : 10;
$estimates = Estimate::whereCompany()
->join('customers', 'customers.id', '=', 'estimates.customer_id')
->applyFilters($request->all())
->select('estimates.*', 'customers.name')
->latest()
->paginateData($limit);
return EstimateResource::collection($estimates)
->additional(['meta' => [
'estimate_total_count' => Estimate::whereCompany()->count(),
]]);
}
public function store(EstimatesRequest $request)
{
$this->authorize('create', Estimate::class);
$estimate = Estimate::createEstimate($request);
if ($request->has('estimateSend')) {
$estimate->send($request->title, $request->body);
}
GenerateEstimatePdfJob::dispatch($estimate);
return new EstimateResource($estimate);
}
public function show(Request $request, Estimate $estimate)
{
$this->authorize('view', $estimate);
return new EstimateResource($estimate);
}
public function update(EstimatesRequest $request, Estimate $estimate)
{
$this->authorize('update', $estimate);
$estimate = $estimate->updateEstimate($request);
GenerateEstimatePdfJob::dispatch($estimate, true);
return new EstimateResource($estimate);
}
public function delete(DeleteEstimatesRequest $request)
{
$this->authorize('delete multiple estimates');
$ids = Estimate::whereCompany()
->whereIn('id', $request->ids)
->pluck('id');
Estimate::destroy($ids);
return response()->json([
'success' => true,
]);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Requests\SendEstimatesRequest;
use App\Models\Estimate;
use Illuminate\Http\JsonResponse;
class SendEstimateController extends Controller
{
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(SendEstimatesRequest $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$response = $estimate->send($request->all());
return response()->json($response);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\V1\Admin\Estimate;
use App\Http\Controllers\Controller;
use App\Http\Requests\SendEstimatesRequest;
use App\Models\Estimate;
use Illuminate\Http\JsonResponse;
use Illuminate\Mail\Markdown;
class SendEstimatePreviewController extends Controller
{
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(SendEstimatesRequest $request, Estimate $estimate)
{
$this->authorize('send estimate', $estimate);
$markdown = new Markdown(view(), config('mail.markdown'));
$data = $estimate->sendEstimateData($request->all());
$data['url'] = $estimate->estimatePdfUrl;
return $markdown->render('emails.send.estimate', ['data' => $data]);
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\V1\Admin\ExchangeRate;
use App\Http\Controllers\Controller;
use App\Http\Requests\ExchangeRateProviderRequest;
use App\Http\Resources\ExchangeRateProviderResource;
use App\Models\ExchangeRateProvider;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ExchangeRateProviderController extends Controller
{
/**
* Display a listing of the resource.
*
* @return Response
*/
public function index(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
$limit = $request->has('limit') ? $request->limit : 5;
$exchangeRateProviders = ExchangeRateProvider::whereCompany()->paginate($limit);
return ExchangeRateProviderResource::collection($exchangeRateProviders);
}
/**
* Store a newly created resource in storage.
*
* @param Request $request
* @return Response
*/
public function store(ExchangeRateProviderRequest $request)
{
$this->authorize('create', ExchangeRateProvider::class);
$query = ExchangeRateProvider::checkActiveCurrencies($request);
if (count($query) !== 0) {
return respondJson('currency_used', 'Currency used.');
}
$checkConverterApi = ExchangeRateProvider::checkExchangeRateProviderStatus($request);
if ($checkConverterApi->status() == 200) {
$exchangeRateProvider = ExchangeRateProvider::createFromRequest($request);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
return $checkConverterApi;
}
/**
* Display the specified resource.
*
* @return Response
*/
public function show(ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('view', $exchangeRateProvider);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
/**
* Update the specified resource in storage.
*
* @param Request $request
* @return Response
*/
public function update(ExchangeRateProviderRequest $request, ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('update', $exchangeRateProvider);
$query = $exchangeRateProvider->checkUpdateActiveCurrencies($request);
if (count($query) !== 0) {
return respondJson('currency_used', 'Currency used.');
}
$checkConverterApi = ExchangeRateProvider::checkExchangeRateProviderStatus($request);
if ($checkConverterApi->status() == 200) {
$exchangeRateProvider->updateFromRequest($request);
return new ExchangeRateProviderResource($exchangeRateProvider);
}
return $checkConverterApi;
}
/**
* Remove the specified resource from storage.
*
* @return Response
*/
public function destroy(ExchangeRateProvider $exchangeRateProvider)
{
$this->authorize('delete', $exchangeRateProvider);
if ($exchangeRateProvider->active == true) {
return respondJson('provider_active', 'Provider Active.');
}
$exchangeRateProvider->delete();
return response()->json([
'success' => true,
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\V1\Admin\ExchangeRate;
use App\Http\Controllers\Controller;
use App\Models\Currency;
use App\Models\ExchangeRateProvider;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class GetActiveProviderController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request, Currency $currency)
{
$query = ExchangeRateProvider::whereCompany()->whereJsonContains('currencies', $currency->code)
->where('active', true)
->get();
if (count($query) !== 0) {
return response()->json([
'success' => true,
'message' => 'provider_active',
], 200);
}
return response()->json([
'error' => 'no_active_provider',
], 200);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\V1\Admin\ExchangeRate;
use App\Http\Controllers\Controller;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\ExchangeRateLog;
use App\Models\ExchangeRateProvider;
use App\Traits\ExchangeRateProvidersTrait;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
class GetExchangeRateController extends Controller
{
use ExchangeRateProvidersTrait;
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request, Currency $currency)
{
$settings = CompanySetting::getSettings(['currency'], $request->header('company'));
$baseCurrency = Currency::findOrFail($settings['currency']);
$query = ExchangeRateProvider::whereJsonContains('currencies', $currency->code)
->where('active', true)
->get()
->toArray();
$exchange_rate = ExchangeRateLog::where('base_currency_id', $currency->id)
->where('currency_id', $baseCurrency->id)
->orderBy('created_at', 'desc')
->value('exchange_rate');
if ($query) {
$filter = Arr::only($query[0], ['key', 'driver', 'driver_config']);
$exchange_rate_value = $this->getExchangeRate($filter, $currency->code, $baseCurrency->code);
if ($exchange_rate_value->status() == 200) {
return $exchange_rate_value;
}
}
if ($exchange_rate) {
return response()->json([
'exchangeRate' => [$exchange_rate],
], 200);
}
return response()->json([
'error' => 'no_exchange_rate_available',
], 200);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\V1\Admin\ExchangeRate;
use App\Http\Controllers\Controller;
use App\Models\ExchangeRateProvider;
use App\Traits\ExchangeRateProvidersTrait;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class GetSupportedCurrenciesController extends Controller
{
use ExchangeRateProvidersTrait;
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
return $this->getSupportedCurrencies($request);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\V1\Admin\ExchangeRate;
use App\Http\Controllers\Controller;
use App\Models\ExchangeRateProvider;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class GetUsedCurrenciesController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
$this->authorize('viewAny', ExchangeRateProvider::class);
$providerId = $request->provider_id;
$activeExchangeRateProviders = ExchangeRateProvider::where('active', true)
->whereCompany()
->when($providerId, function ($query) use ($providerId) {
return $query->where('id', '<>', $providerId);
})
->pluck('currencies');
$activeExchangeRateProvider = [];
foreach ($activeExchangeRateProviders as $data) {
if (is_array($data)) {
for ($limit = 0; $limit < count($data); $limit++) {
$activeExchangeRateProvider[] = $data[$limit];
}
}
}
$allExchangeRateProviders = ExchangeRateProvider::whereCompany()->pluck('currencies');
$allExchangeRateProvider = [];
foreach ($allExchangeRateProviders as $data) {
if (is_array($data)) {
for ($limit = 0; $limit < count($data); $limit++) {
$allExchangeRateProvider[] = $data[$limit];
}
}
}
return response()->json([
'allUsedCurrencies' => $allExchangeRateProvider ? $allExchangeRateProvider : [],
'activeUsedCurrencies' => $activeExchangeRateProvider ? $activeExchangeRateProvider : [],
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\Expense;
namespace App\Http\Controllers\V1\Admin\Expense;
use App\ExpensesCategory;
use App\Http\Controllers\Controller;

View File

@@ -1,23 +1,17 @@
<?php
namespace App\Http\Controllers\Company\Expense;
namespace App\Http\Controllers\V1\Admin\Expense;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteExpensesRequest;
use App\Http\Requests\ExpenseRequest;
use App\Http\Requests\UploadExpenseReceiptRequest;
use App\Http\Resources\ExpenseResource;
use App\Models\Expense;
use App\Services\ExpenseService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ExpensesController extends Controller
{
public function __construct(
private readonly ExpenseService $expenseService,
) {}
/**
* Display a listing of the resource.
*
@@ -52,7 +46,7 @@ class ExpensesController extends Controller
{
$this->authorize('create', Expense::class);
$expense = $this->expenseService->create($request);
$expense = Expense::createExpense($request);
return new ExpenseResource($expense);
}
@@ -78,7 +72,7 @@ class ExpensesController extends Controller
{
$this->authorize('update', $expense);
$this->expenseService->update($expense, $request);
$expense->updateExpense($request);
return new ExpenseResource($expense);
}
@@ -97,62 +91,4 @@ class ExpensesController extends Controller
'success' => true,
]);
}
public function showReceipt(Expense $expense)
{
$this->authorize('view', $expense);
if ($expense) {
$media = $expense->getFirstMedia('receipts');
if ($media) {
return response()->file($media->getPath());
}
return respondJson('receipt_does_not_exist', 'Receipt does not exist.');
}
}
public function uploadReceipt(UploadExpenseReceiptRequest $request, Expense $expense)
{
$this->authorize('update', $expense);
$data = json_decode($request->attachment_receipt);
if ($data) {
if ($request->type === 'edit') {
$expense->clearMediaCollection('receipts');
}
$expense->addMediaFromBase64($data->data)
->usingFileName($data->name)
->toMediaCollection('receipts');
}
return response()->json([
'success' => 'Expense receipts uploaded successfully',
], 200);
}
public function downloadReceipt(Expense $expense)
{
$this->authorize('view', $expense);
if ($expense) {
$media = $expense->getFirstMedia('receipts');
if ($media) {
$imagePath = $media->getPath();
$response = \Response::download($imagePath, $media->file_name);
if (ob_get_contents()) {
ob_end_clean();
}
return $response;
}
}
return response()->json([
'error' => 'receipt_not_found',
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\V1\Admin\Expense;
use App\Http\Controllers\Controller;
use App\Models\Expense;
use Illuminate\Http\JsonResponse;
class ShowReceiptController extends Controller
{
/**
* Retrieve details of an expense receipt from storage.
*
* @return JsonResponse
*/
public function __invoke(Expense $expense)
{
$this->authorize('view', $expense);
if ($expense) {
$media = $expense->getFirstMedia('receipts');
if ($media) {
return response()->file($media->getPath());
}
return respondJson('receipt_does_not_exist', 'Receipt does not exist.');
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\V1\Admin\Expense;
use App\Http\Controllers\Controller;
use App\Http\Requests\ExpenseRequest;
use App\Http\Requests\UploadExpenseReceiptRequest;
use App\Models\Expense;
use Illuminate\Http\JsonResponse;
class UploadReceiptController extends Controller
{
/**
* Upload the expense receipts to storage.
*
* @param ExpenseRequest $request
* @return JsonResponse
*/
public function __invoke(UploadExpenseReceiptRequest $request, Expense $expense)
{
$this->authorize('update', $expense);
$data = json_decode($request->attachment_receipt);
if ($data) {
if ($request->type === 'edit') {
$expense->clearMediaCollection('receipts');
}
$expense->addMediaFromBase64($data->data)
->usingFileName($data->name)
->toMediaCollection('receipts');
}
return response()->json([
'success' => 'Expense receipts uploaded successfully',
], 200);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyResource;
use App\Http\Resources\UserResource;
use App\Models\Company;
use App\Models\CompanySetting;
use App\Models\Currency;
use App\Models\Module;
use App\Models\Setting;
use App\Traits\GeneratesMenuTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Silber\Bouncer\BouncerFacade;
class BootstrapController extends Controller
{
use GeneratesMenuTrait;
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(Request $request)
{
$current_user = $request->user();
$current_user_settings = $current_user->getAllSettings();
$main_menu = $this->generateMenu('main_menu', $current_user);
$setting_menu = $this->generateMenu('setting_menu', $current_user);
$companies = $current_user->companies;
$current_company = Company::find($request->header('company'));
if ((! $current_company) || ($current_company && ! $current_user->hasCompany($current_company->id))) {
$current_company = $current_user->companies()->first();
}
$current_company_settings = CompanySetting::getAllSettings($current_company->id);
$current_company_currency = $current_company_settings->has('currency')
? Currency::find($current_company_settings->get('currency'))
: Currency::first();
BouncerFacade::refreshFor($current_user);
$global_settings = Setting::getSettings([
'api_token',
'admin_portal_theme',
'admin_portal_logo',
'login_page_logo',
'login_page_heading',
'login_page_description',
'admin_page_title',
'copyright_text',
]);
return response()->json([
'current_user' => new UserResource($current_user),
'current_user_settings' => $current_user_settings,
'current_user_abilities' => $current_user->getAbilities(),
'companies' => CompanyResource::collection($companies),
'current_company' => new CompanyResource($current_company),
'current_company_settings' => $current_company_settings,
'current_company_currency' => $current_company_currency,
'config' => config('invoiceshelf'),
'global_settings' => $global_settings,
'main_menu' => $main_menu,
'setting_menu' => $setting_menu,
'modules' => Module::where('enabled', true)->pluck('name'),
]);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Http\Requests\BulkExchangeRateRequest;
use App\Models\CompanySetting;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Tax;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class BulkExchangeRateController extends Controller
{
/**
* Handle the incoming request.
*
* @param Request $request
* @return Response
*/
public function __invoke(BulkExchangeRateRequest $request)
{
$bulkExchangeRate = CompanySetting::getSetting('bulk_exchange_rate_configured', $request->header('company'));
if ($bulkExchangeRate == 'NO') {
if ($request->currencies) {
foreach ($request->currencies as $currency) {
$currency['exchange_rate'] = $currency['exchange_rate'] ?? 1;
$invoices = Invoice::where('currency_id', $currency['id'])->get();
if ($invoices) {
foreach ($invoices as $invoice) {
$invoice->update([
'exchange_rate' => $currency['exchange_rate'],
'base_discount_val' => $invoice->sub_total * $currency['exchange_rate'],
'base_sub_total' => $invoice->sub_total * $currency['exchange_rate'],
'base_total' => $invoice->total * $currency['exchange_rate'],
'base_tax' => $invoice->tax * $currency['exchange_rate'],
'base_due_amount' => $invoice->due_amount * $currency['exchange_rate'],
]);
$this->items($invoice);
}
}
$estimates = Estimate::where('currency_id', $currency['id'])->get();
if ($estimates) {
foreach ($estimates as $estimate) {
$estimate->update([
'exchange_rate' => $currency['exchange_rate'],
'base_discount_val' => $estimate->sub_total * $currency['exchange_rate'],
'base_sub_total' => $estimate->sub_total * $currency['exchange_rate'],
'base_total' => $estimate->total * $currency['exchange_rate'],
'base_tax' => $estimate->tax * $currency['exchange_rate'],
]);
$this->items($estimate);
}
}
$taxes = Tax::where('currency_id', $currency['id'])->get();
if ($taxes) {
foreach ($taxes as $tax) {
$tax->base_amount = $tax->base_amount * $currency['exchange_rate'];
$tax->save();
}
}
$payments = Payment::where('currency_id', $currency['id'])->get();
if ($payments) {
foreach ($payments as $payment) {
$payment->exchange_rate = $currency['exchange_rate'];
$payment->base_amount = $payment->amount * $currency['exchange_rate'];
$payment->save();
}
}
}
}
$settings = [
'bulk_exchange_rate_configured' => 'YES',
];
CompanySetting::setSettings($settings, $request->header('company'));
return response()->json([
'success' => true,
]);
}
return response()->json([
'error' => false,
]);
}
public function items($model)
{
foreach ($model->items as $item) {
$item->update([
'exchange_rate' => $model->exchange_rate,
'base_discount_val' => $item->discount_val * $model->exchange_rate,
'base_price' => $item->price * $model->exchange_rate,
'base_tax' => $item->tax * $model->exchange_rate,
'base_total' => $item->total * $model->exchange_rate,
]);
$this->taxes($item);
}
$this->taxes($model);
}
public function taxes($model)
{
if ($model->taxes()->exists()) {
$model->taxes->map(function ($tax) use ($model) {
$tax->update([
'exchange_rate' => $model->exchange_rate,
'base_amount' => $tax->amount * $model->exchange_rate,
]);
});
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ConfigController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
return response()->json([
$request->key => config('invoiceshelf.'.$request->key),
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Admin;
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CountryResource;

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CurrencyResource;
use App\Models\Currency;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CurrenciesController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
$currencies = Currency::latest()->get();
return CurrencyResource::collection($currencies);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Space\DateFormatter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class DateFormatsController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
return response()->json([
'date_formats' => DateFormatter::get_list(),
]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Models\Currency;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Tax;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class GetAllUsedCurrenciesController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
$invoices = Invoice::where('exchange_rate', null)->pluck('currency_id')->toArray();
$taxes = Tax::where('exchange_rate', null)->pluck('currency_id')->toArray();
$estimates = Estimate::where('exchange_rate', null)->pluck('currency_id')->toArray();
$payments = Payment::where('exchange_rate', null)->pluck('currency_id')->toArray();
$currencies = array_merge($invoices, $taxes, $estimates, $payments);
return response()->json([
'currencies' => Currency::whereIn('id', $currencies)->get(),
]);
}
}

View File

@@ -1,22 +1,27 @@
<?php
namespace App\Http\Controllers\Company\General;
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Models\Estimate;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\SerialNumberService;
use Illuminate\Http\JsonResponse;
use App\Services\SerialNumberFormatter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class SerialNumberController extends Controller
class NextNumberController extends Controller
{
public function nextNumber(Request $request, Invoice $invoice, Estimate $estimate, Payment $payment): JsonResponse
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request, Invoice $invoice, Estimate $estimate, Payment $payment)
{
$key = $request->key;
$nextNumber = null;
$serial = (new SerialNumberService)
$serial = (new SerialNumberFormatter)
->setCompany($request->header('company'))
->setCustomer($request->userId);
@@ -44,9 +49,7 @@ class SerialNumberController extends Controller
break;
default:
return response()->json([
'success' => false,
]);
return;
}
} catch (\Exception $exception) {
return response()->json([
@@ -60,18 +63,4 @@ class SerialNumberController extends Controller
'nextNumber' => $nextNumber,
]);
}
public function placeholders(Request $request): JsonResponse
{
if ($request->input('format')) {
$placeholders = SerialNumberService::getPlaceholders($request->input('format'));
} else {
$placeholders = [];
}
return response()->json([
'success' => true,
'placeholders' => $placeholders,
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\General;
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Http\Requests\NotesRequest;

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Services\SerialNumberFormatter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class NumberPlaceholdersController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
if ($request->format) {
$placeholders = SerialNumberFormatter::getPlaceholders($request->format);
} else {
$placeholders = [];
}
return response()->json([
'success' => true,
'placeholders' => $placeholders,
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Http\Controllers\Company\General;
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Models\Customer;
@@ -36,15 +36,4 @@ class SearchController extends Controller
'users' => $users ?? [],
]);
}
public function users(Request $request)
{
$this->authorize('create', User::class);
$users = User::whereEmail($request->email)
->latest()
->paginate(10);
return response()->json(['users' => $users]);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class SearchUsersController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
$this->authorize('create', User::class);
$users = User::whereEmail($request->email)
->latest()
->paginate(10);
return response()->json(['users' => $users]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Space\TimeFormatter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TimeFormatsController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
return response()->json([
'time_formats' => TimeFormatter::get_list(),
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\V1\Admin\General;
use App\Http\Controllers\Controller;
use App\Space\TimeZones;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TimezonesController extends Controller
{
/**
* Handle the incoming request.
*
* @return Response
*/
public function __invoke(Request $request)
{
return response()->json([
'time_zones' => TimeZones::get_list(),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\V1\Admin\Invoice;
use App\Http\Controllers\Controller;
use App\Models\Invoice;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ChangeInvoiceStatusController extends Controller
{
/**
* Handle the incoming request.
*
* @return JsonResponse
*/
public function __invoke(Request $request, Invoice $invoice)
{
$this->authorize('send invoice', $invoice);
if ($request->status == Invoice::STATUS_SENT) {
$invoice->status = Invoice::STATUS_SENT;
$invoice->sent = true;
$invoice->save();
} elseif ($request->status == Invoice::STATUS_COMPLETED) {
$invoice->status = Invoice::STATUS_COMPLETED;
$invoice->paid_status = Invoice::STATUS_PAID;
$invoice->due_amount = 0;
$invoice->save();
}
return response()->json([
'success' => true,
]);
}
}

Some files were not shown because too many files have changed in this diff Show More