Currency dropdowns now display the most-traded currencies (USD, EUR, GBP,
JPY, CAD, AUD, CHF, CNY, INR, BRL) at the top, followed by the rest
alphabetically. The install wizard defaults to USD instead of EUR and
formats currency names as "USD - US Dollar" for consistency with the
rest of the app.
The sidebar gains a new section that lists each currently-activated module
as a direct shortcut to its settings page. This is the always-visible
companion to the company-context Active Modules index — both surface the
same set of modules, but the index is the catalog landing page and the
sidebar group is the per-module quick access.
- BootstrapController returns module_menu populated from
\InvoiceShelf\Modules\Registry::allMenu(), but only on the company-context
branch — not on the super-admin branch (lines 53-69), since super admins
don't see the dynamic group. Because nwidart only boots service providers
for currently-activated modules, the registry naturally contains only
active modules at request time, no extra filtering needed.
- bootstrap.service.ts BootstrapResponse type extended with
module_menu?: ModuleMenuItem[]; new ModuleMenuItem interface
(title/link/icon) — shaped distinctly from MenuItem because module entries
use namespaced i18n keys and don't carry group/ability metadata.
- global.store.ts exposes a moduleMenu ref + a hasActiveModules computed.
- SiteSidebar.vue appends a new "Modules" section after the existing
menuGroups output, in both the mobile (Dialog) and desktop branches. The
section is hidden when hasActiveModules is false. Uses the
modules.sidebar.section_title i18n key added in the previous commit.
Adds the read-only company "Active Modules" index page (lists every
instance-activated module with a Settings shortcut) and the schema-driven
settings framework (generic BaseSchemaForm.vue renderer + per-company
persistence in CompanySetting). Bundled because they share the same
routes/api.php edit and the index page's Settings button targets the
settings page.
Backend:
- CompanyModulesController::index() returns every Module::enabled = true row
with a kebab-case slug (via Str::kebab()) and a has_settings flag computed
from \InvoiceShelf\Modules\Registry::settingsFor(). nwidart stores module
names in PascalCase ("HelloWorld") but URLs and registry keys use kebab
("hello-world") — the controller normalizes so module authors can call
Registry::registerSettings('hello-world') naturally without thinking
about the storage format.
- ModuleSettingsController::show(\$slug) returns the registered Schema +
per-company values from CompanySetting (defaults flow through when nothing
has been saved yet). update(\$slug) builds Laravel validator rules from
the Schema's per-field rules arrays — with type-rule fallbacks for
switch -> boolean, number -> numeric, multiselect -> array — silently
drops unknown keys, and persists via CompanySetting::setSettings() under
the module.{slug}.{key} prefix. Activation is instance-global, but
settings are per-company: two companies on the same instance can
configure the same activated module differently.
- routes/api.php mounts GET /api/v1/company-modules at the root of the
company API group and GET/PUT /api/v1/modules/{slug}/settings inside the
existing modules prefix.
Frontend:
- BaseSchemaForm.vue is the central new component — a generic schema-driven
form renderer that maps schema fields to BaseInput / BaseTextarea /
BaseSwitch / BaseMultiselect by type, and builds Vuelidate rules
dynamically from each field's rules array (supports required, email, url,
numeric, min:N, max:N). New fields are added by extending the type ->
component map.
- CompanyModulesIndexView.vue fetches /company-modules and renders a card
grid (with empty/loading states); CompanyModuleCard.vue is the per-row
component with the Settings button. ModuleSettingsView.vue fetches
/modules/{slug}/settings, hands {schema, values} to BaseSchemaForm, and
posts back on submit.
- Company-context routes.ts is rebuilt after the previous commit relocated
the marketplace browser away. It now declares modules.index +
modules.settings, both gated by manage-module ability.
- New api/services/{companyModules,moduleSettings}.service.ts thin clients.
- lang/en.json adds modules.index.{description,empty_title,empty_description},
modules.settings.{title,open,saved,not_found,none}, and
modules.sidebar.section_title. The sidebar key is added here even though
the dynamic sidebar rendering lands in the next commit — keeping all i18n
additions in one file edit avoids hunk-splitting lang/en.json.
The vestigial App\Services\Module\Module static class — with its unused
\$scripts / \$styles / \$settings registries — never had any of its helpers
wired up. The new InvoiceShelf\Modules\Registry shipped from the
invoiceshelf/modules package supersedes it cleanly: same static-array surface
(\$menu, \$settings, \$scripts, \$styles), but lives outside the host app so
third-party modules can depend on it without importing v3-app internals.
Three consumers in the host app are migrated to the new namespace:
- ScriptController and StyleController (the HTTP endpoints that serve
module-registered JS/CSS assets at /modules/scripts/{name} and
/modules/styles/{name}) now look up paths via Registry::scriptFor() and
Registry::styleFor() instead of Arr::get(ModuleFacade::all*(), \$name).
Also tightens type hints — Request import + Response return type.
- resources/views/app.blade.php iterates Registry::allStyles() /
Registry::allScripts() to inject module-supplied <link>/<script> tags into
the main layout. Same Akaunting-style asset injection mechanism, just
reading from the new namespace.
Both Module and ModuleFacade are deleted — they had no remaining callers
after this migration.
Ports the net behaviour from three master commits into v3.0 as a single change, because v3.0 has already diverged structurally (controller moved from V1/Admin/Report to Company/Report, blade has its own CSS rework using the bundled fonts partial, and v3.0's App\Facades\Pdf replaces Barryvdh\DomPDF\Facade\Pdf). The three source commits are: 834b53ea (grouped itemized expenses), e22050bc (DomPDF facade + Pint — adapted to v3.0's App\Facades\Pdf), 0e9f18d4 (expenses.uncategorized + pdf_expense_group_total_label i18n keys + View|Response return type).
Controller: replaces the expenseCategories aggregate fetch with an itemized Expense query ordered by date, groups by category name with expenses.uncategorized fallback, and shares an expenseGroups collection of {name, expenses, total} plus the overall totalExpense. Adds expense_category_id to applyFilters. Updates the docblock return type from JsonResponse to View|Response. Keeps v3.0's App\Facades\Pdf.
Blade: replaces the single expenseCategories aggregate table with a per-group itemized table (date / note / amount columns + per-group total line using the new pdf_expense_group_total_label i18n key). Adds the item-table-* CSS classes and removes the old expense-total-table bottom block.
lang/en.json: adds expenses.uncategorized = "Uncategorized" and pdf_expense_group_total_label = "Group total:".
Existing accounts inherited the company language at creation time and there was no way to change UI language per user. Add a 'Default (Company Language)' entry to the language selector in UserGeneralView, persist the choice through userStore.updateUserSettings and reload the i18n bundle via window.loadLanguage. The 'default' sentinel keeps the user opted in to the company-wide setting.
Bootstrap (global.store) now syncs userForm from current_user data and resolves the active UI language as user > company > 'en'. RegisterController, InvitationRegistrationController and MemberService seed new users with language=default instead of copying the current company setting, so promoting/inviting members no longer leaks the inviter's frozen language.
Bundle Noto Sans (Regular/Bold/Italic/BoldItalic) under resources/static/fonts/ as the default PDF face — it covers Latin, Cyrillic, Greek, Arabic, Thai and Hindi out of the box, replacing the limited DejaVu Sans fallback. Move all @font-face declarations into app.pdf.partials.fonts and include it from every invoice/estimate/payment/report template, dropping per-template font-family hardcodes and the conditional Thai locale include.
Introduce FontService + FontController to download static Noto Sans CJK packages (zh, zh_CN, ja, ko) from life888888/cjk-fonts-ttf on demand. GeneratesPdfTrait::ensureFontsForLocale primes the family before rendering and the partial emits @font-face rules for installed packages so dompdf resolves them through standard CSS — no separate registerFont() instance required. Static TTFs are mandatory because dompdf's PHP-Font-Lib does not parse variable fonts (fvar/gvar tables), which is why Google Fonts' NotoSansTC[wght].ttf rendered empty boxes.
Expose status/install via /api/v1/fonts/status and /api/v1/fonts/{package}/install with matching FONTS_STATUS / FONTS_INSTALL constants in scripts-v2/api/endpoints.ts. Flip DOMPDF_ENABLE_REMOTE default to true for remote asset loading.
Major changes to the file disk subsystem:
- Each FileDisk now gets a unique Laravel disk name (disk_{id}) instead
of temp_{driver}, fixing the bug where multiple local disks with
different roots overwrote each other's config.
- Move disk registration logic from FileDisk model to FileDiskService
(registerDisk, getDiskName). Model keeps only getDecodedCredentials
and a deprecated setConfig() wrapper.
- Add Disk Assignments admin UI (File Disk tab) with three purpose
dropdowns: Media Storage, PDF Storage, Backup Storage. Stored as
settings (media_disk_id, pdf_disk_id, backup_disk_id).
- Backup tab now uses the assigned backup disk instead of a per-backup
dropdown. BackupsController refactored to use BackupService which
centralizes disk resolution. Removed stale 4-second cache.
- Add local_public disk to config/filesystems.php so system disks
are properly defined.
- Local disk roots stored relative to storage/app/ with hint text
in the admin modal explaining the convention.
- Fix BaseModal watchEffect -> watch to prevent infinite request
loops on the File Disk page.
- Fix string/number comparison for disk purpose IDs from settings.
- Add safeguards: prevent deleting disks with files, warn on
purpose change, prevent deleting system disks.
Spatie Media Library now uses the default FileDisk (local_private) for
new uploads instead of the public disk. Expense receipts are no longer
directly web-accessible.
- AppServiceProvider configures media-library disk from FileDisk on boot
- Change media-library fallback from 'public' to 'local'
- Expense receipt URL accessor returns authenticated route instead of
direct file URL
- Add registerMediaCollections() to Expense model
- Prevent deleting FileDisk that contains files or is a system disk
- Add media:secure command to migrate existing receipts to private disk
Fixes#187
The customer portal bootstrap now returns current_company_currency
alongside the customer's own currency. The store falls back to the
company currency when the customer has no currency assigned.
Fixes#142
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