New backend endpoint POST /invoices/{id}/convert-to-estimate that
creates a draft estimate from an invoice, copying items, taxes,
custom fields, and financial data. Frontend wired with dropdown
action, store method, and API service call.
- Add manifest.json generation script (scripts/generate-manifest.php)
- Add Updater::cleanStaleFiles() that removes files not in manifest
- Add /api/v1/update/clean endpoint with backward compatibility
- Add configurable update_protected_paths in config/invoiceshelf.php
- Update frontend to use clean step instead of delete step
- Add GitHub Actions release workflow triggered on version tags
- Add .github/release.yml for auto-generated changelog categories
- Update Makefile to include manifest generation and scripts directory
Super admin users with no company associations now receive their
administration menu items in the bootstrap response instead of
empty arrays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When inviting an email without an InvoiceShelf account, the email now
links to a registration page (/register?invitation={token}) instead of
login. After registering, the invitation is auto-accepted.
Backend:
- InvitationRegistrationController: public details() and register()
endpoints. Registration validates token + email match, creates account,
auto-accepts invitation, returns Sanctum token.
- AuthController: login now accepts optional invitation_token param to
auto-accept invitation for existing users clicking the email link.
- CompanyInvitationMail: conditional URL based on user existence.
- Web route for /invitations/{token}/decline (email decline link).
Frontend:
- RegisterWithInvitation.vue: fetches invitation details, shows company
name + role, registration form with pre-filled email.
- Router: /register route added.
Tests: 3 new tests (invitation details, register + accept, email mismatch).
Redistribute methods:
- show() -> BootstrapController::currentCompany()
- store(), destroy(), userCompanies() -> Admin\CompaniesController
- transferOwnership() -> CompanySettingsController
Security fix: introduce 'owner' role for company-level admin, distinct
from 'super admin' which is now global platform admin only.
- CompanyService::setupRoles() creates 'owner' role per company
- Company creation assigns scoped 'owner' role instead of global 'super admin'
- Seeders updated to assign 'owner'
Migration renames all existing company-scoped 'super admin' roles to
'owner' and ensures every company owner has the role assigned.
Merge InvoicePdfController, EstimatePdfController, PaymentPdfController
into DocumentPdfController with invoice(), estimate(), payment() methods.
Delete DownloadInvoicePdfController and DownloadPaymentPdfController
(dead code — not mapped in any routes).
Move DownloadReceiptController logic to ExpensesController::downloadReceipt()
(expense receipts, not PDF documents).
Merge CompanyCurrencyCheckTransactionsController into
CompanySettingsController as checkTransactions() method.
Merge UserSettingsController into UserProfileController as
showSettings() and updateSettings() methods — both operate on
the authenticated user (/me routes).
ensureOwner() checked isOwner() which only verifies company ownership,
not super admin status. Replace with authorize('manage update app')
which uses the proper Bouncer ability gate for platform administration.
Merge 7 single-action pipeline controllers (checkVersion, download,
unzip, copy, delete, migrate, finish) into UpdateController with named
methods. Remove dead UpdateController that duplicated the same logic
but wasn't referenced in routes. Extract shared owner check into
private ensureOwner() helper. Route URLs unchanged.
Replace duplicated switch/case blocks across 4 methods with a clean
abstract driver pattern:
- ExchangeRateDriver (abstract): defines getExchangeRate(),
getSupportedCurrencies(), validateConnection()
- CurrencyFreakDriver, CurrencyLayerDriver, OpenExchangeRateDriver,
CurrencyConverterDriver: concrete implementations
- ExchangeRateDriverFactory: resolves driver name to class, with
register() method for module extensibility
Delete ExchangeRateProvidersTrait — all logic now lives in driver
classes and ExchangeRateProviderService. Adding a new exchange rate
provider only requires implementing ExchangeRateDriver and calling
ExchangeRateDriverFactory::register() in a module service provider.
The Mobile namespace only contained an API auth controller (Sanctum token
login/logout/check) that is not mobile-specific. Relocated to
Company/Auth/AuthController alongside the other auth controllers.
V1/Admin -> Company (company-scoped controllers)
V1/SuperAdmin -> Admin (platform-wide admin controllers)
V1/Customer -> CustomerPortal (customer-facing portal)
V1/Installation -> Setup (installation wizard)
V1/PDF -> Pdf (consistent casing)
V1/Modules -> Modules (drop V1 prefix)
V1/Webhook -> Webhook (drop V1 prefix)
The V1 prefix served no purpose - API versioning is in the route prefix
(/api/v1/), not the controller namespace. "Admin" was misleading for
company-scoped controllers. "SuperAdmin" is now simply "Admin" for
platform administration.
Backend:
- Extract user profile methods (show, update, uploadAvatar) from
CompanyController into new UserProfileController
- CompanyController now only handles company concerns (updateCompany,
uploadCompanyLogo)
- Remove Account Settings from setting_menu config
Frontend:
- New /admin/user-settings page with 3 tabs: General, Profile Photo,
Security (password change)
- User dropdown now links to /admin/user-settings instead of
/admin/settings/account-settings
- Settings sidebar defaults to Company Information as first item
- Remove old monolithic AccountSetting.vue
Merge GetCompanySettingsController + UpdateCompanySettingsController into
CompanySettingsController with show() and update() methods.
Merge GetUserSettingsController + UpdateUserSettingsController into
UserSettingsController with show() and update() methods.
Absorb GetCompanyMailConfigurationController into
CompanyMailConfigurationController as getDefaultConfig() method.
Removes 5 single-action controllers, down to 4 Settings controllers.
Backup, Update, Modules, and global Settings controllers (mail config,
PDF config, disk management, global settings) are application-wide features
not scoped to a company. Move them from Admin/ to SuperAdmin/ to match the
v3.0 UI structure where these live under Administration.
Company-scoped settings controllers remain in Admin/Settings/.
Split PDFService.php (3 classes + 2 interfaces in one file) into separate
files. Move GotenbergPDFDriver from app/Services/PDFDrivers/ into
app/Services/Pdf/. Normalize casing from ALL-CAPS PDF to Pdf throughout:
facade, provider, service, driver factory, and Gotenberg driver.
Fix PaymentService using Barryvdh DomPDF facade directly instead of the
app's PDF facade (bypassed the driver factory). Report controllers also
updated to use the app facade.
Relocate all 14 files from the catch-all app/Space namespace into proper
locations: data providers to Support/Formatters, installation utilities to
Services/Installation, PDF utils to Services/Pdf, module/update classes to
Services/Module and Services/Update, SiteApi trait to Traits, and helpers
to Support.
Extract ~1,400 lines of business logic from 8 fat models (Invoice, Payment,
Estimate, RecurringInvoice, Company, Customer, Expense, User) into 9 new
service classes with constructor injection. Controllers now depend on
services instead of calling static model methods. Shared item/tax creation
logic consolidated into DocumentItemService.
Add scopeWhereCompany() to User model using whereHas through the
user_company pivot table. Apply it in UsersController::index() and
SearchController so users only see members of their current company.
Previously, the users page showed ALL users across all companies.
Ref #574
Verify the source record belongs to the current company before cloning.
Previously, users could clone invoices/estimates from other companies,
leaking sensitive data (amounts, customer details, items, taxes, notes).
The view policy already includes hasCompany() check, so authorizing
view on the source record gates both ability and company ownership.
Ref #574
Bulk delete: filter IDs through whereCompany() before deleting in all
controllers (Invoices, Payments, Items, Expenses, Estimates, Recurring
Invoices). Previously, any user could delete records from other companies
by providing cross-company IDs.
Transfer ownership: fix inverted hasCompany() check that allowed
transferring company ownership to users who do NOT belong to the company,
while blocking users who DO belong.
Ref #567
* Fix CustomerPolicy missing hasCompany() check (cross-company IDOR)
Add $user->hasCompany($customer->company_id) check to view, update,
delete, restore, and forceDelete methods in CustomerPolicy, matching
the pattern used by all other policies (InvoicePolicy, PaymentPolicy,
EstimatePolicy, etc.).
Without this check, a user in Company A with view-customer ability
could access customers belonging to Company B by providing the target
customer's ID.
Add cross-company authorization tests to verify the fix.
Closes#565
* Scope bulk delete to current company to prevent cross-company deletion
Filter customer IDs through whereCompany() before passing to
deleteCustomers(), ensuring users cannot delete customers belonging
to other companies via the bulk delete endpoint.
- Add Administration sidebar section (super-admin only) with Companies, Users, and Global Settings pages
- Add super-admin middleware, controllers, and API routes under /api/v1/super-admin/
- Allow super-admins to manage all companies and users across tenants
- Add user impersonation with short-lived tokens, audit logging, and UI banner
- Move system-level settings (Mail, PDF, Backup, Update, File Disk) from per-company to Administration > Global Settings
- Convert save_pdf_to_disk from CompanySetting to global Setting
- Add per-company mail configuration overrides (optional, falls back to global)
- Add CompanyMailConfigService to apply company mail config before sending emails
* docs: add CLAUDE.md for Claude Code guidance
* fix: handle missing settings table in installation middlewares
RedirectIfInstalled crashed with "no such table: settings" when the
database_created marker file existed but the database was empty.
Changed to use isDbCreated() which verifies actual tables, and added
try-catch around Setting queries in both middlewares.
* feat: pre-select database driver from env in installation wizard
The database step now reads DB_CONNECTION from the environment and
pre-selects the matching driver on load, including correct defaults
for hostname and port.
* feat: pre-select mail driver and config from env in installation wizard
The email step now fetches the current mail configuration on load
instead of hardcoding the driver to 'mail'. SMTP fields fall back
to Laravel config values from the environment.
* refactor: remove file-based DB marker in favor of direct DB checks
The database_created marker file was a second source of truth that
could drift out of sync with the actual database. InstallUtils now
checks the database directly via Schema::hasTable which is cached
per-request and handles all error cases gracefully.
Three bugs prevented backups stored on remote disks (Dropbox, S3, etc.)
from ever appearing in the Settings > Backup listing:
1. Typo in BackupSetting.vue: `filed_disk_id` was sent to the API
instead of `file_disk_id`, so the backend never received the selected
disk ID and always fell back to the local filesystem.
2. Wrong default disk selection in loadDisksData(): `set_as_default == 0`
selected the first disk that is NOT default (local_public), instead of
the disk that IS default. Changed to `set_as_default == 1` with a
fallback to the first disk.
3. BackupsController::index() did not call setConfig() on the FileDisk
before querying backups, so even when the correct file_disk_id arrived
it still read from the default local filesystem. Added the same disk
bootstrap logic already present in CreateBackupJob and destroy().
Made-with: Cursor
* Update CustomersController.php
Fix: replace GROUP BY + SUM join with withSum() to avoid ONLY_FULL_GROUP_BY; no API changes (same aliases)
* Update CustomersController.php
style: apply Laravel Pint formatting