Commit Graph

289 Commits

Author SHA1 Message Date
Darko Gjorgjijoski
9e496102d4 feat(updater): disable the in-app updater in containerized installs
The Docker image already injects CONTAINERIZED=true; consume it via config('invoiceshelf.containerized'), expose it on /app/version, block the update endpoints + console command, and show a 'docker compose pull' panel instead of the updater. Adds missing i18n keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
d3202b8b2a fix(members): scope member view & update to the acting company
Member view/update bound the target user by global id and authorized only that the requester owns their active company, not that the target belonged to it. Bind the route model under the members param and require shared company membership in UserPolicy so an owner of one company can no longer read or modify users of another.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
e41ee8083d fix(documents): preserve edited line-item description (#658)
The per-line item description typed into the row's textarea was never
saved. BaseItemSelect's description field pushes edits out only via an
`update:description` emit, but DocumentItemRow (the single shared row used
by invoices, estimates, and recurring invoices) wired only `@search` and
`@select` — so the emit was dropped and `form.items[index].description`
never updated. On submit the field was lost; a catalog item's description
also reverted on the next re-render.

Capture the event and route it through the existing `updateItemAttribute`
store updater, mirroring how name/quantity/price/discount already sync.
One fix covers all three document types since they share the row.

Also add the missing `items.*.description` nullable rule to
RecurringInvoiceRequest for parity with the invoice/estimate requests.

The backend already persisted and returned description correctly; this was
purely a dropped frontend event.
2026-06-12 11:27:56 +02:00
Darko Gjorgjijoski
84524ce247 fix(security): recompute document totals server-side (GHSA-8c69) (#672)
v3 port. Invoice/estimate/recurring creation and update accepted total,
sub_total, tax and due_amount straight from the request with no recalculation,
letting a client persist financial totals that don't match the line items
(and corrupt the invoice update due-amount/paid-amount logic which keyed off
the client total).

- Adds App\Support\DocumentTotals (mirrors the front-end calc) trusting only
  price/quantity/discounts/tax-line amounts.
- getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the
  client totals; the shared DocumentItemService::createItems recomputes each
  item total; InvoiceService::update keys its due-amount logic off the
  recomputed total.

Adds DocumentTotals unit tests + a feature test proving a tampered invoice
total is ignored; existing create/update tests no longer assert the now
server-authoritative derived totals.
2026-06-12 11:02:23 +02:00
Darko Gjorgjijoski
99ea898e88 fix(security): block SSRF via the Gotenberg host setting (GHSA-mfxg) (#671)
v3 port. The Gotenberg PDF driver was missed when the SSRF guards were added
to the AI, exchange-rate and file-disk drivers: gotenberg_host was validated
only with 'url', and the driver POSTs the rendered HTML to it.

Reuses the existing infrastructure (consistency with the other drivers):
- Wires App\Rules\PublicHttpUrl into the gotenberg_host validation rule.
- Adds PrivateNetworkGuard::assertAllowed() in GotenbergPdfDriver before the
  outbound call (covers env/seed/stale config + DNS rebinding).

Adds a unit test asserting the gotenberg_host rule rejects private/loopback/
link-local addresses and allows a public one.
2026-06-12 11:02:19 +02:00
Darko Gjorgjijoski
e56b6b4fe5 fix(security): harden public EmailLog token endpoints (GHSA-73q7) (#669)
v3 port. Customer PDF controllers resolved the target document by raw
mailable_id ignoring mailable_type, and skipped expiry on the JSON endpoints.

- Resolve via $emailLog->mailable + assert the expected type (404) to close
  cross-type disclosure.
- Enforce isExpired() (403) on every public path incl. the JSON endpoints.
- Harden EmailLog::isExpired() against a null/unresolvable mailable.

Adds tests for cross-type 404, JSON-path expiry 403, and the valid path.
2026-06-12 11:02:11 +02:00
Darko Gjorgjijoski
c6a00df120 fix(security): enforce company scope on notes, doc-convert, and member bulk-delete (#668)
v3 port of the v2 authorization fixes.

- Notes IDOR (GHSA-85wc): NotePolicy checks the note's company_id and
  NotesController passes the bound model to authorize() on show/update/destroy.
- Estimate<->Invoice convert IDOR (GHSA-j2vg): EstimatesController::convertToInvoice
  and InvoicesController::convertToEstimate authorize 'view' on the source
  document before creating the target.
- Member bulk-delete (GHSA-wxrv): MembersController scopes ids via
  User::whereCompany() before MemberService::delete.

Adds feature tests for cross-company 403s + same-company happy paths.
2026-06-12 11:02:08 +02:00
Darko Gjorgjijoski
ac2a8ca939 fix(security): gate AI tools by user ability and block admin-URL SSRF
The AI chat assistant scoped tool queries by company but ignored the
per-user Bouncer abilities the rest of the app enforces, so any `use ai`
holder could read customers, invoices, payments, and company financials
their role couldn't otherwise see. Each AiTool now declares a required
ability (entity-aligned); the registry hides unauthorized tools from the
model and refuses to execute them as a backstop.

Separately, admin/owner-supplied URLs were fetched server-side with no
guard against private/reserved targets (SSRF): the AI base URL, the
CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk
endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl
validation rule (save-time) and runtime guards in each driver.

- AiTool::requiredAbility() + mapping across all 12 tools
- AiToolRegistry filters schemas() by ability and re-checks in execute()
- PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new)
- Rule wired into AI config (service + 3 controllers), exchange-rate,
  and file-disk endpoints; runtime guards in OpenRouterDriver,
  CurrencyConverterDriver, and FileDiskService
- Tests for ability filtering, the guard, the rule, and 422 rejections
2026-06-05 00:01:09 +02:00
Darko Gjorgjijoski
b761ea9931 feat(ai): Phase 3 — text generation popup on WYSIWYG editors
Third and final phase of the AI feature. A SparklesIcon button is added to every Tiptap WYSIWYG editor (invoice notes, email body compose, note templates — ~6 places where RichEditor is used) that opens a modal with a prompt input, optional 'use current content as context' toggle, preview area, and Insert / Replace / Regenerate actions.

**Backend (thin)** — AiTextGenerationService is stateless: resolve config → check text_generation_enabled → instantiate driver → call textCompletion() with a system-prompt-wrapped user instruction. The system prompt is terse and opinionated: 'Return only the requested text. No preamble, no explanation, no markdown code fences.' When context is provided, it's included as a separate framed block ('Context (current content the user is working with):') so the model knows it's operating on existing copy.

**GenerationController** — POST /api/v1/ai/generate with {prompt, context?}. Validates prompt required (max 4000 chars) and context optional (max 20000 chars). Rate-limited via the same 'ai' RateLimiter from Phase 2 (30/min per user/company). Gated by 'use ai' Bouncer ability + AiConfigurationService resolution. Returns {text} on success or {error, message} with 422 on any AiException.

**Frontend modal (AiTextGenerationModal.vue)** — mounted globally in CompanyLayout when bootstrap reports ai.enabled && text_generation_enabled. Uses the existing modalStore pattern: self-registers on componentName='AiTextGenerationModal'. Modal state includes prompt, useContext toggle, generatedText preview. Callers (currently RichEditor) pass onInsert/onReplace callbacks via modalStore.data; the modal invokes them with the final text and closes — it knows nothing about tiptap or ProseMirror.

**RichEditor integration** — the Sparkles toolbar button is pushed onto the existing editorButtons ref at setup time, gated on globalStore.ai.enabled && text_generation_enabled. The button opens the modal with the editor's current getHTML() as context and callbacks that use the tiptap chain API: insertContent for Insert, selectAll().deleteSelection().insertContent for Replace. No reactivity on the flag check — it's set once at bootstrap and doesn't change during a session.

**Tests** (7 new) — AiGenerationTest with a dedicated TextGenDriver test double that tracks the exact prompt passed to textCompletion(). Covers: happy path, context inclusion/omission, AI globally disabled rejection, text_generation role disabled rejection, prompt/context length validation, response whitespace trimming.

395 tests pass (was 388, +7 new). Pint clean. npm run build clean. The AI feature is now complete end-to-end: provider configuration (Phase 1), chat assistant with DB tool-calling (Phase 2), and text generation popup (Phase 3).
2026-04-12 11:00:00 +02:00
Darko Gjorgjijoski
e861fc1fc1 feat(ai): Phase 2 — chat assistant with tool-calling
Second phase of the AI feature. Users can now open a slide-in chat drawer from the SiteHeader and ask natural-language questions about their company's invoices, customers, payments, and expenses. The LLM invokes pre-defined read-only tool functions (scoped to the current company at execute time) to fetch data and synthesize answers.

**Database** — new ai_conversations and ai_messages tables. Messages are stored in OpenAI's chat format so AiAssistantService serializes a conversation into an API request with zero translation. Columns: role, content, tool_call_id, tool_calls JSON, model, tokens_in, tokens_out. Conversations are scoped (company_id, user_id) — one user's chats are invisible to everyone else, even inside the same company. Foreign-key cascade deletes.

**Tool infrastructure** — AiTool abstract base + AiToolRegistry singleton (registered in a new AiServiceProvider). The base class enforces the security rule: every tool's execute() receives companyId and userId as injected parameters; tools' JSON schemas NEVER include a company_id field. An LLM physically cannot pass a company_id and escape tenancy. Modules can register their own tools by resolving the registry from their own ServiceProvider::boot().

**Nine built-in tools**: search_invoices, get_invoice, search_customers, get_customer, list_recent_payments, list_overdue_invoices, get_company_stats (aggregates for named periods), search_items, list_expense_categories. All read-only; no mutations. Each returns JSON-encodable data the LLM can parse.

**AiAssistantService orchestration loop** — the heart of Phase 2. Flow: persist user message → build payload from system prompt + recent history (40-message window) + new user message → call driver.chatCompletion with tools → if tool_calls, execute each one via the registry (with injected scope), persist tool result, loop → if plain text, persist and return. Hard cap at 5 iterations to prevent runaway LLMs. System prompt pins the assistant to this company's data and forbids mutation.

**Controllers + policy + rate limit** — POST /api/v1/ai/chat runs the orchestration loop. GET/PATCH/DELETE /api/v1/ai/conversations for CRUD. AiConversationPolicy enforces user_id+company_id match on every action. A new 'ai' RateLimiter in RouteServiceProvider throttles to 30 req/min per (user, company). New 'use ai' Gate defined in AppServiceProvider returns true for any authenticated user — the per-company kill-switch still goes through AiConfigurationService::resolveForCompany.

**Frontend** — new features/company/ai/ folder with a Pinia store (ai-chat.store.ts) holding drawer state, current conversation, messages, and loading flags. AiChatDrawer.vue is a slide-in panel teleported to <body>, mounted globally in CompanyLayout.vue when bootstrap reports ai.enabled && ai.chat_enabled. Sub-components: AiChatMessage (user bubbles vs assistant bubbles), AiChatMessageInput (Enter submits, Shift+Enter newline), AiChatConversationList (sidebar with 'new chat' button, rename, delete). A SparklesIcon button in SiteHeader toggles the drawer.

**Driver test double** — tests use a ScriptedAiDriver registered via AiDriverFactory::register('scripted', ...) that returns pre-queued AiChatResponse objects. Feature tests cover: happy path (new conversation + message persistence), tool-call loop (multi-round-trip with search_invoices), runaway-loop cap, driver-throws path, ai_enabled=NO rejection, chat role disabled rejection, per-user conversation visibility, cross-user policy enforcement, cascade delete.

388 tests pass (was 372, +16 new). Pint clean. npm run build clean. Phase 3 (WYSIWYG text generation popup) is the remaining follow-up.
2026-04-12 08:00:00 +02:00
Darko Gjorgjijoski
c7fab5d52f feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.

**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).

**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.

**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.

**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.

**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.

**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.

**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).

**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.

372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
2026-04-11 22:00:00 +02:00
Darko Gjorgjijoski
47907f9bf3 refactor(support): flatten Integrations umbrella and rename Formatters to Formatting
Two plural outliers were the only directories in Services/ and Support/ that didn't follow the singular naming convention we normalized in commit 947d00a9 (Documents -> Document).

**Integrations/ExchangeRate/ -> ExchangeRate/.** The Integrations/ umbrella was both plural AND introduced an extra layer of nesting no other Support subdir had (Pdf/, Hashids/, Module/, Update/ are all one level deep). Dropping the umbrella fixes both problems in one move and matches the existing shape. When AI providers eventually land, they follow the same pattern as Support/Pdf/: a sibling subdir at Support/Ai/, not buried under an umbrella.

**Formatters/ -> Formatting/.** Formatter (singular noun) would have been awkward; Formatting (gerund describing the capability) reads naturally as a namespace segment. The subdir holds DateFormatter, TimeFormatter, TimeZones — classes whose common thread is 'things that do formatting', which the gerund captures better than either the singular or plural noun form.

10 files renamed, 4 consumers updated (DriverRegistryProvider, ExchangeRateProviderService, FormatsController, ExchangeRateDriverFactoryTest). 350 tests pass, Pint clean.
2026-04-11 18:00:00 +02:00
Darko Gjorgjijoski
f657b53215 refactor(services): split driver infrastructure out of Services into Support
Services/Integrations/ExchangeRate/ and Services/Pdf/ were both mostly Support-shaped: interfaces, abstract classes, static factories, concrete adapter drivers, DTOs, and exceptions — infrastructure that doesn't carry business logic. They only each had one real DI-injected service mixed in.

This commit applies the same Services=DI-business-logic / Support=stateless-plumbing rule we've been using throughout the reorg:

**Moved to Support/Integrations/ExchangeRate/** (7 files): ExchangeRateDriver (abstract), ExchangeRateDriverFactory (static), ExchangeRateException, and the four concrete drivers (CurrencyConverter, CurrencyFreak, CurrencyLayer, OpenExchangeRate). These are HTTP adapters over third-party currency APIs — same shape as the Hashids library wrapper classes already in Support.

**Moved to Support/Pdf/** (6 files, merging with existing Pdf utilities): PdfDriver (interface), PdfDriverFactory (static), PdfService (static facade), GotenbergPdfDriver, GotenbergPdfResponse (DTO), ResponseStream (interface). The Support/Pdf/ dir now contains the full PDF rendering subsystem — drivers + sanitizer + template/image utilities.

**Promoted to Services/ root** (the real DI services): ExchangeRateProviderService (CRUD for ExchangeRateProvider model) and FontService (font package install/download orchestration). Both are proper DI services — instance methods, model writes, HTTP side effects.

Services/Integrations/ and Services/Pdf/ are now empty and deleted. Services/ holds only DI-injected classes; Support/ holds all the plumbing.

17 files renamed (git detects 90-99% similarity), 4 consumer files updated (DriverRegistryProvider, PdfServiceProvider, ExchangeRateProviderController, FontController, GeneratesPdfTrait, test). 350 tests pass, Pint clean.
2026-04-11 16:30:00 +02:00
Darko Gjorgjijoski
4c3d809f89 refactor(support): group Bouncer, Media, Setup files into subdirs
Finishes the Support/ consolidation pass: the three remaining root-level files get grouped into purpose-named subdirs, matching the shape the Pdf/, Hashids/, Update/, and Module/ subdirs took in the earlier sweep.

- BouncerDefaultScope → Support/Bouncer/ (Bouncer-specific authorization scope)

- CustomPathGenerator → Support/Media/ (Spatie MediaLibrary path generator — media config references it from config/media-library.php)

- InstallWizardAuth → Support/Setup/ (folded into the existing Setup/ subdir alongside EnvironmentManager, FilePermissionChecker, InstallUtils, RequirementsChecker — it's install-wizard-flow state)

4 consumer files updated (AppServiceProvider, LoginController, UseInstallWizardTokenAuth middleware, config/media-library.php). app/Support/ root is now completely empty of standalone PHP files except helpers.php.
2026-04-11 15:00:00 +02:00
Darko Gjorgjijoski
8cc4a6fa98 refactor(services): move ModuleInstaller to app/Support/Module
ModuleInstaller has the same shape as Updater (moved in 7cf72b9f): every method is public static, no constructor, no DI, no instance state. It orchestrates marketplace operations — fetch catalog, download zip, verify checksum, unzip, copy files, run module:migrate/module:enable, dispatch install/enable events — but the orchestration itself is stateless procedural plumbing.

Emitting events and writing to the Module eloquent model doesn't make it a service; plenty of static helpers touch models. The distinguishing factor is stateless-procedural vs DI-injected-workflow, and this is clearly the former.

4 consumers updated: ModulesController, ModuleInstallationController, InstallModuleCommand, and a doc comment in config/invoiceshelf.php. 350 tests still pass.

This leaves app/Services/ with no single-file driver-less subdirs except Mail/Module/Pdf/Storage which have multiple files each. The Module/ subdir in Services is now deleted entirely — the marketplace installer moved out and there were no other files in there.
2026-04-11 13:30:00 +02:00
Darko Gjorgjijoski
7cf72b9f1d refactor(services): move Updater to app/Support/Update
Updater is a pure static procedural class — all eight public methods (checkForUpdate, download, unzip, copyFiles, deleteFiles, cleanStaleFiles, migrateUpdate, finishUpdate) are static, there's no constructor, no DI, no instance state. It's stateless self-update plumbing, same character as the Setup/ helpers that moved to Support/ in commit 6d1816bd.

Moving it out of Services/ leaves Services/ exclusively for DI-injected classes with real business logic, which is the contract the reorg was aiming for. Update/ now lives next to Setup/ in Support/ where the other install/upgrade-time utilities live.

Only 3 consumers needed touching: UpdateController, UpdateCommand, and a reference in config/invoiceshelf.php's comment. 350 tests still pass.
2026-04-11 12:30:00 +02:00
Darko Gjorgjijoski
947d00a9f1 refactor(services): Documents→Document + ExchangeRate→Integrations/ExchangeRate
Two follow-ups to the Services reorg that landed in 6d1816bd.

**Documents → Document** (singular). Documents/ was the only plural subdir in app/Services/ — every other bucket (Company, Mail, Module, Pdf, Storage, Update, ExchangeRate) was singular. Renaming to Document/ normalizes the whole tree.

**ExchangeRate → Integrations/ExchangeRate**. Introduces Integrations/ as an umbrella for external-service adapter subsystems. Exchange rate providers move in first; AI providers, payment gateways, and any other driver-pattern integrations land as sibling subdirs (Integrations/Ai/, Integrations/Payment/) without another reorg. Integrations/ was chosen over Providers/ to avoid conceptual collision with Laravel's app/Providers/ — 'check the providers' shouldn't be ambiguous.

17 files moved, 21 consumer files rewritten to point at the new namespaces via literal-string replacement (same approach as the previous reorg). 350 tests pass, Pint clean.
2026-04-11 11:30:00 +02:00
Darko Gjorgjijoski
6d1816bd1b refactor: reorganize app/Services and app/Support by domain
The app/Services/ directory had grown into 22 flat files at the root plus 7 uneven subdirectories — finding anything required scrolling through an alphabetical mix of small CRUD services, infrastructure drivers, and install-time utilities. This commit groups services by domain, folds Backup into a new Storage namespace, and moves framework-infrastructure and install-time helpers out of Services and into Support where they belong.

New Services layout: Documents/ (Invoice, Estimate, RecurringInvoice, Payment, Expense, Transaction, DocumentItem, SerialNumber, Currency — matches the 'Documents' navigation group); Company/ (Company, Member, Invitation); Mail/ (MailConfiguration, CompanyMailConfig); Storage/ (FileDisk, plus Backup folded in). ExchangeRateProviderService moves next to its drivers in ExchangeRate/; FontService moves into Pdf/ where it belongs. CustomerService, ItemService, CustomFieldService stay at the Services root as standalone single-file domains.

Moves to Support/: Hashids/ (library wrapper — not business logic); Setup/ (one-shot install-time utilities — stateless helpers); Pdf/ (ImageUtils, PdfTemplateUtils, plus the existing PdfHtmlSanitizer consolidated into the same subdir). These are all framework infrastructure and stateless utilities — the 'service' label never really fit them.

Namespace declarations in 29 moved files updated to match new paths. 62 consumer files (controllers, other services, tests, database factories, seeders, routes, bootstrap/providers.php) have their use statements rewritten via a literal-string replacement script — no regex meant no risk of half-matching. Three Documents services needed an explicit 'use App\Services\Mail\CompanyMailConfigService' added because the same-namespace short reference they relied on no longer resolves after the split.

Verified: composer dump-autoload, 350 tests pass (850 assertions), vendor/bin/pint clean, npm run build succeeds.
2026-04-11 10:00:00 +02:00
Darko Gjorgjijoski
5c11147e95 feat(settings): allow Danger Zone for any owner regardless of company count
Removes three layered gates that kept the Danger Zone completely hidden unless the current user had more than one company:

1. SettingsLayoutView's showDangerZone computed no longer checks companies.length > 1 — just is_owner. 2. DangerZoneView drops the v-if that wrapped the delete button with the same check. 3. Admin\\CompaniesController::destroy() drops the companies_count <= 1 early-return that was enforcing the rule server-side (translation key You_cannot_delete_all_companies was inline in the controller, not in lang files or tests, so nothing else needs cleanup).

The reasoning behind the old gate was that a user with zero companies would be stranded. That's a misread of how the app degrades: /admin/no-company already exists as a graceful fallback view, and the user can create a fresh company from there to recover. Hiding the entire delete flow just to avoid that fallback UX was overkill — the name-confirmation modal already prevents accidental deletion.
2026-04-11 08:00:00 +02:00
Darko Gjorgjijoski
e44657bf7e feat(exchange-rate): make providers extendible via module Registry
Exchange rate providers are now pluggable via the module Registry. The four built-in drivers (currency_converter, currency_freak, currency_layer, open_exchange_rate) move from a static config array into App\\Providers\\DriverRegistryProvider, which calls Registry::registerExchangeRateDriver() for each during app boot with metadata the frontend needs: label (i18n key), website (help-text URL), and config_fields (schema for driver-specific driver_config JSON).

The Currency Converter's server-type selector and dedicated URL field — previously hardcoded in ExchangeRateProviderModal.vue — are now just another config_fields entry with a visible_when rule that shows the URL input only when type=DEDICATED. Any module that wants to ship a custom driver gets the same treatment for free: declare config_fields in the registration, and the host app's modal renders them automatically.

ExchangeRateDriverFactory::make() falls back to Registry::driverMeta() when a name isn't in the local built-in map, and availableDrivers() merges both sources. ConfigController handles the exchange_rate_drivers key specially by mapping Registry::allDrivers('exchange_rate') to enriched option objects, so the config-file route still works for every other key. The static exchange_rate_drivers + currency_converter_servers arrays in config/invoiceshelf.php are deleted.

Unit tests cover the new Registry::register/flushDrivers, the factory merging built-ins with Registry-contributed drivers, and the factory rejecting unknown names. A feature test exercises the end-to-end /api/v1/config?key=exchange_rate_drivers response shape.

NOTE: this commit depends on invoiceshelf/modules package commit e44d951 which adds the Registry driver API. The package needs to be released and pinned in composer.json before a fresh composer install on this commit will work.
2026-04-11 04:00:00 +02:00
Darko Gjorgjijoski
7885bf9d11 feat(menu): priority-sorted menu groups, user-menu items, sidebar appearance toggle
Every main_menu entry moves from numeric group (1/2/3) to string-based group + group_label + priority. Groups now carry their own i18n label and child entries are sorted by an explicit priority field instead of config-array order, so module-contributed menu items can slot into any existing group at any position.

BootstrapController merges module-registered menu items into main_menu (previously they lived in a separate module_menu response key) and introduces a user_menu response key for items modules want to place in the avatar dropdown. The global store follows suit: moduleMenu becomes userMenu, menuGroups is a computed that sorts by priority, and hasActiveModules drops out.

New admin Appearance setting page with a single toggle for whether sidebar group labels render — so instances that prefer a compact sidebar can hide the Documents/Administration/Modules headings without losing the grouping itself. CompanyLayout watches route meta and re-bootstraps when the admin-mode flag flips so the sidebar repaints with the right menu on navigation across the admin boundary.

Test suites updated: module menu merging is asserted against main_menu (name: 'module-{slug}') rather than the old module_menu response; HelloWorldIntegrationTest verifies the schema translation path; CompanyModulesIndexTest covers the display_name attachment.
2026-04-11 00:30:00 +02:00
Darko Gjorgjijoski
345bfde306 feat(modules): translated display names and inline settings modal
CompanyModulesController attaches a translated display_name to each module before returning the list. ModuleSettingsController gains a translateSchema() helper that resolves section titles and field labels against the host app's i18n store before sending the schema to the frontend, so module authors can keep their 'my_module::settings.field' keys and users still see localized strings.

Per-module settings now open in an inline ModuleSettingsModal rather than routing to a standalone page. The modal reuses BaseSchemaForm for rendering, so the whole interaction takes place in-context next to the module card the user clicked — no navigation, no loss of place.

CompanyModuleCard displays the translated display_name instead of the raw slug and emits open-settings with the module payload; the parent view hands that to the modal store.
2026-04-10 21:30:00 +02:00
Darko Gjorgjijoski
23d1476870 refactor(modules): marketplace install flow with checksum validation
Rewires module installation to use slug + version + checksum_sha256 instead of the opaque module identifier. ModuleInstaller splits marketplace token handling out of install() into helpers, adopts structured error responses, and validates the downloaded archive's SHA-256 against the marketplace manifest before unpacking.

ModuleResource is simplified to accept an already-loaded installed-module instance rather than fetching it from state, exposes access_tier and checksum fields, and drops the auto-disable-on-unpurchased side effect that was bleeding write logic into a read resource. UnzipUpdateRequest accepts a nullable module with a conditional module_name field so the same endpoint serves both app and module updates.

ModulesPolicy::manageModules now short-circuits for super-admins so administration flows (token validation, store state) are not blocked on a company-scoped ability. Two new feature tests cover both the authorization bypass and ModuleResource serialization.
2026-04-10 17:30:00 +02:00
Darko Gjorgjijoski
42ce99eeba Show common currencies first in dropdowns and default to USD in install wizard
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.
2026-04-10 15:55:46 +02:00
Darko Gjorgjijoski
9174254165 Refactor install wizard and mail configuration 2026-04-09 10:06:27 +02:00
Darko Gjorgjijoski
7743c2e126 feat(modules): dynamic sidebar group rendering active modules
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.
2026-04-09 00:29:56 +02:00
Darko Gjorgjijoski
e6eeacb6d4 feat(modules): company-context module surfaces and schema-driven settings
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.
2026-04-09 00:29:36 +02:00
Darko Gjorgjijoski
b2b7a07e0c refactor(modules): migrate asset registry from app/Services to invoiceshelf/modules package
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.
2026-04-09 00:27:44 +02:00
Darko Gjorgjijoski
cb37de6da4 Auto-generate company slug server-side in CompanyRequest
CompanyRequest::getCompanyPayload() accepted 'slug' from the client but never generated it, so the installation wizard (which PUTs /api/v1/company) left the slug empty when setting up the first company. Match the sibling CompaniesRequest (which already does Str::slug($this->name)) and generate the slug from the name server-side; drop the now-unused 'slug' validation rule.

Fixes the same bug that master's ed7af3fc tried to fix client-side with a lodash deburr + regex workaround in Step7CompanyInfo.vue. v3.0's installation wizard is a rewrite under resources/scripts/features/installation/CompanyView.vue and doesn't carry that workaround, so the cleaner fix is to make the backend authoritative like CompaniesRequest already is.
2026-04-07 17:30:34 +02:00
Darko Gjorgjijoski
119a1712b0 Port expense report grouped itemized view + i18n + return types from master
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:".
2026-04-07 17:28:34 +02:00
Darko Gjorgjijoski
78ed332d06 Add per-user language preference with company default fallback
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.
2026-04-07 04:41:00 +02:00
Darko Gjorgjijoski
ba5c6c39ba Add multilingual PDF font system with Noto Sans and on-demand CJK packages
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.
2026-04-06 23:32:00 +02:00
Darko Gjorgjijoski
20085cab5d Refactor FileDisk system with per-disk unique names and disk assignments UI
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.
2026-04-07 02:04:57 +02:00
Darko Gjorgjijoski
67268ac2b7 Secure expense receipts by wiring Media Library to FileDisk
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
2026-04-07 01:01:59 +02:00
Darko Gjorgjijoski
39c9179888 Support internationalized domain names (IDN) in email validation
Add IdnEmail validation rule that converts IDN domains to Punycode
via idn_to_ascii() before validating with FILTER_VALIDATE_EMAIL.
Applied to all email fields: customers, members, profiles, admin
users, customer portal profiles, and mail configuration.

Includes unit tests for standard emails, IDN emails, and invalid
inputs.

Fixes #388
2026-04-06 23:55:29 +02:00
Darko Gjorgjijoski
9638e02eb8 Fix customer portal not reflecting company default currency
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
2026-04-06 23:37:56 +02:00
Darko Gjorgjijoski
25b61b73a0 Fix case-sensitive email login
Email comparison on login now uses LOWER() for case-insensitive
matching. Applied to both admin and customer portal login controllers.

Fixes #424
2026-04-06 23:22:16 +02:00
Darko Gjorgjijoski
8508e7e1b8 Show user role in company switcher
CompanyResource now includes user_role — the authenticated user's
Bouncer role title scoped to that company (e.g. "Owner"). Displayed
as a subtitle under each company name in the switcher dropdown.
2026-04-06 23:03:29 +02:00
Darko Gjorgjijoski
9ca998e64a Add Convert to Estimate feature for invoices
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.
2026-04-06 22:57:03 +02:00
Darko Gjorgjijoski
e64529468c Replace deleted_files with manifest-based updater cleanup, add release workflow
- 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
2026-04-06 19:27:33 +02:00
Darko Gjorgjijoski
74b4b2df4e Finalize Typescript restructure 2026-04-06 17:59:15 +02:00
Darko Gjorgjijoski
eb0a588164 Refactor Administration entrypoint
We moved the administration item to the company switcher in the header
2026-04-04 01:36:28 +02:00
Darko Gjorgjijoski
fae59221d3 Generate admin menus for super admins without a company
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>
2026-04-04 00:24:00 +02:00
Darko Gjorgjijoski
afbc6c1db3 Handle no-company user in ScopeBouncer middleware and User model
Skip bouncer scoping when user has no companies instead of crashing
on null. Fall back to Y-m-d date format in getFormattedCreatedAtAttribute
when no company settings are available.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:12:00 +02:00
Darko Gjorgjijoski
c1994887ef Support invitations for unregistered users
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).
2026-04-03 23:26:58 +02:00
Darko Gjorgjijoski
8a6c085288 Rename company-scoped Users to Members throughout
Complete rename across backend and frontend:
- Controller: Company/Users/UsersController -> Company/Members/MembersController
- Service: UserService -> MemberService
- Requests: UserRequest -> MemberRequest, DeleteUserRequest -> DeleteMemberRequest
- API routes: /api/v1/users -> /api/v1/members (company-scoped only)
- Sidebar menu: "Users" -> "Members"
- Frontend: views/users -> views/members, stores/users -> stores/members
- Router: users.index -> members.index, /admin/users -> /admin/members
- i18n: new "members" section with invitation-related keys
- Tests: UserTest -> MemberTest

Admin/super-admin Users (system-wide user management) remains unchanged.
2026-04-03 23:12:30 +02:00
Darko Gjorgjijoski
92a1baced4 Add company invitation system (backend)
New feature allowing company owners/admins to invite users by email with
a specific company-scoped role.

Database:
- New company_invitations table (company_id, email, role_id, token,
  status, invited_by, expires_at)

Backend:
- CompanyInvitation model with pending/forUser scopes
- InvitationService: invite, accept, decline, getPendingForUser
- CompanyInvitationMail with markdown email template
- InvitationController (company-scoped): list, send, cancel invitations
- InvitationResponseController (user-scoped): pending, accept, decline
- BootstrapController returns pending_invitations in response
- CompanyMiddleware handles zero-company users gracefully

Tests: 9 feature tests covering invite, accept, decline, cancel, expire,
duplicate prevention, and bootstrap integration.
2026-04-03 22:58:55 +02:00
Darko Gjorgjijoski
00d5abae5f Eliminate Company\CompaniesController, introduce owner role
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.
2026-04-03 22:33:56 +02:00
Darko Gjorgjijoski
5912995164 Move CompaniesController from Company/Company/ to Company/ to eliminate namespace stutter 2026-04-03 22:20:04 +02:00
Darko Gjorgjijoski
6f095210d6 Consolidate Pdf controllers: 6 -> 1 DocumentPdfController
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).
2026-04-03 22:16:20 +02:00