Invoices — Developer Guide
Mirror of the Quotations developer guide (assignee scope, notes, domain timeline, line items), kept module-standalone: no required FK / hard dependency, unlike Quotations/Contracts which require Opportunities.
Naming: the backend model is
CustomerInvoice(tablecustomer_invoices) — Central's own platform-billingInvoicemodel already exists for subscription invoices the platform sends to tenants. Frontend mirrors this withcustomerInvoiceService/PERMISSIONS.customerInvoices/QUERY_KEYS.customerInvoices, distinct from the pre-existinginvoiceService/PERMISSIONS.invoices.
Backend layout
| Piece | Path |
|---|---|
| Models | app/Models/CustomerInvoice.php, CustomerInvoiceLine, CustomerInvoiceNote, CustomerInvoiceActivity |
| Enums | CustomerInvoiceStatusEnum, CustomerInvoiceActivityTypeEnum, CustomerInvoiceRecurrenceFrequencyEnum, CustomerInvoiceRecurrenceStatusEnum, DocumentDiscountTypeEnum |
| Support | app/Support/Billing/DocumentTotalsCalculator.php, DocumentDiscountRules.php, DocumentHtmlSanitizer.php, BrandedDocumentPdfContext.php |
| Service | app/Services/Tenant/CustomerInvoiceService.php (+ ScopesToAssignee) |
app/Services/Tenant/CustomerInvoicePdfService.php, resources/views/invoices/pdf.blade.php | |
| Controller | app/Http/Controllers/Tenant/Api/V1/CustomerInvoiceController.php |
| Requests | app/Http/Requests/Tenant/Api/V1/CustomerInvoice/* |
| Resources | app/Http/Resources/Tenant/Api/V1/CustomerInvoice/* |
| Policy | app/Policies/CustomerInvoicePolicy.php |
| Events | app/Events/CustomerInvoice*.php |
| Subscriber | app/Listeners/CustomerInvoiceEventSubscriber.php (audit + assignment notification) |
| Notifications | app/Notifications/Tenant/CustomerInvoice/CustomerInvoiceAssignedNotification.php |
| Link rules | LinkableContact, LinkableCompany, LinkableReseller, EligibleInvoiceAssignee — quotation_id is a plain tenant-scoped Rule::exists(), not gated by a LinkableQuotation-style entitlement rule; reseller_id requires Resellers entitlement + assignee scope via LinkableReseller |
| Tests | tests/Feature/Tenant/CustomerInvoice/CustomerInvoiceTest.php, CustomerInvoiceRecurrenceTest.php, CustomerInvoiceSearchTest.php |
Domain notes
- No hard dependency: Invoices does not declare a
module_dependenciesrow on Opportunities (unlike Quotations/Contracts) — it installs standalone from Marketplace. - Status machine lives on
CustomerInvoiceStatusEnum::allowedTransitions()/canTransitionTo():draft → unpaid|cancelled,unpaid → cancelled|paid,paid/cancelledare terminal.paidis set programmatically by Payments posting/voiding or Credit Notes applying, viaCustomerInvoice::applyBalanceStatus()/recalculateBalanceFromAmounts()— partial settlement keepsunpaid. This API only exposes user-drivensend/void(cancel) /status. Unpaid → Cancelledrequires a zero ledger —CustomerInvoiceService::void()is the enforcement point (not just the enum): it throwsValidationException(422,status, naming the invoice number) ifamount_paid > 0(void the payments first) oramount_credited > 0(refund applied credit notes first).CustomerInvoice::isVoidable()mirrors this (Draft/Unpaid only, both amounts zero).changeStatus()(used byPOST …/status) routes acancelledtarget throughvoid()rather than callingtransitionStatus()directly, so both entry points share the same ledger guard.CustomerInvoiceService::transitionStatus()throwsValidationException(422,statusfield) for disallowed transitions.- Content updates (
PUT) and line sync are draft-only viaCustomerInvoice::isEditable()(status === draft). Assignment remains available after send viaPOST …/assign. POST …/status(changeStatus) maps target status to permissions in the controller:unpaid→invoices.send,cancelled→invoices.void, otherwiseinvoices.update. The form request itself only requiresinvoices.update; the controller'sGate::authorize()call adds the stricter check per target status.send/void/view/updatepolicies are assignee-scoped (same pattern) unless the actor hasinvoices.assignor is superadmin.send()backfillsissue_dateto today if unset, then transitions tosent. If the invoice is a recurring series root, this also setsrecurrence_status=active.recurrence_next_issue_onis the date the operator chose on the draft (required when recurring); the SPA auto-fills one frequency step from the issue date (or workspace today) via the same no-overflow rules asCustomerInvoiceRecurrenceFrequencyEnum::nextDate(), and the operator can override it. Send keeps it when it is after the issue date, otherwise it falls back to one frequency step from the issue date. Status-only — no outbound email delivery.- Customer email:
POST …/email(invoices.send, assignee-scoped,throttle:billing-document-email) delivers a branded message with optional PDF attachment viaBillingDocumentMailer+CustomerInvoiceEmailService. Requires a sent invoice (unpaid/paid; draft/cancelled → 422). Resolves defaulttofrom contact then company (ResolvesBillingDocumentRecipients). RecordsCustomerInvoiceActivityTypeEnum::Emailedand tenant email logs (customer_invoice.emailed). - Recurring series live on
customer_invoices(is_recurring,recurrence_frequency,recurrence_status,recurrence_next_issue_on,recurrence_ends_on,recurrence_due_days,recurring_source_invoice_id). The original invoice is the template.invoices:generate-recurring(daily) clones draft occurrences from the root’s current lines; children are not themselves recurring.POST …/recurrence/stopends the series (ended) and optionally voids the latest unpaid generated invoice (void_latest_unpaid). Voiding the root also ends an active series. - Generator:
chunkByIdover due series roots, per-seriestry/catch,withTrashed()uniqueness for(tenant, source, issue_date), catch-up cap (config('invoices.recurring_catchup_cap'), default 52) and per-tenant time budget. Command returnsFAILUREif any entitled tenant had a failed series. Remaining due periods run on the next daily tick. - PDF:
GET …/pdf(invoices.view, assignee-scoped,throttle:invoices-pdf) renders a Dompdf document fromresources/views/invoices/pdf.blade.phpviaCustomerInvoicePdfServiceon the fly (no storedpdf_path). Layout uses workspacebutton_color, embedded logo (base64 from branding disk), and invoice settings (company_*,tax_registration_id,invoice_bank_*,invoice_payment_terms,invoice_default_notes,invoice_default_terms_and_conditions). Whentax_registration_idis set under Settings → General, the seller header printsTax registration ID: …; when blank/null, that line is omitted. Empty memo notes / terms fall back to those defaults (shared with quotation and estimate PDFs). Includes discount rows whendiscount_total > 0, sanitized memo HTML, and a Payments received table for posted payment allocations. Shows a Partial chip when unpaid with partial payments. Totals / balance stay in a short right-aligned block; Notes / Terms are full-width blocks below so Dompdf paginates long HTML. Line-item body HTML is a block-level.line-bodyunder each short pricing row. Cached (base64) by id +updated_at+ settings fingerprint so database/Redis JSON stores stay valid UTF-8 and branding edits invalidate the cache.send()dispatchesWarmCustomerInvoicePdfJobon the default queue. - Line items are fully replaced on create/update (
CustomerInvoiceService::syncLines());CustomerInvoice::recalculateTotals()delegates toDocumentTotalsCalculatorforsubtotal/discount_total/tax_total/totalfrom persistedCustomerInvoiceLinerows plus documentline_discount_type. Tax is calculated after line discounts.balance_dueis then derived fromtotal - amount_paid - amount_creditedviarecalculateBalanceFromAmounts(), called by Payments on post/void and by Credit Notes on apply. - Shared line discounts use
DocumentDiscountTypeEnum(none,percent,fixed) on the parent asline_discount_type; lines storename, optionalbody, optionalproduct_id(LinkableProduct: Products entitled,products.viewor superadmin, active non-trashed), anddiscount_value. Validation inDocumentDiscountRules. Memonotesaccept sanitized HTML viaDocumentHtmlSanitizer. Recurring generation copiesproduct_idonto occurrence lines. - Partial is UI-only: the SPA shows a Partial badge when
status === unpaid,amount_paid > 0, andbalance_due > 0. The API has nopartialstatus — partial settlement keepsunpaiduntil the balance clears. - Assignee scoping via
ScopesToAssigneewithinvoices.assign. invoices.force.deleteis not granted to any default role — owner/superadmin only.estimate_idandcontract_idare set by convert actions (nullOnDelete);estimate_idis unique when not null (one-shot estimate convert);quotation_idis not unique so contracts can bill more than once.- Auto-numbering:
CustomerInvoiceService::nextNumber()reads theinvoices_number_prefixtenant setting (defaultINV-), then zero-pads a running count (CustomerInvoice::withTrashed()->count() + 1) to 5 digits.customer_invoiceshas aunique(tenant_id, number)DB index;create()wraps the insert with the sharedRetriesOnDuplicateNumbertrait (app/Services/Tenant/Concerns/RetriesOnDuplicateNumber.php), retrying up to 3 times with a freshly generated number if two concurrent requests race to the same count-derived sequence. The same trait/index pattern is used by Payments, Credit Notes, and Estimates. - Overdue definition (shared by list
overdue=truefilter andstats.overdue):due_date < today,status=unpaid,balance_due > 0. - List
searchmatches invoicetitle/number, related contactname/phone/ free-textcompany, and related companyname/phone.
Permissions
invoices.view | create | update | delete | restore | force.delete | assign | send | voidRoutes use module:invoices then can:invoices.* / policies.
Catalog: slug invoices, category billing, is_default_included = false, is_billable = false, sort_order = 10, version 1.9.3. Registered via DefaultModuleRegistrar migration (migrate-only); 1.5.0 added optional product line picker; 1.5.1 hardens linking + sanitizer; 1.6.0 adds contract_id for contract-created invoices; 1.6.1 adds unique nullable estimate_id for one-shot estimate convert; 1.7.0 dedicated record pages; 1.7.1 PDF long-notes pagination; 1.7.2 PDF long line-body pagination; 1.8.0 customer email delivery (POST …/email); 1.9.2 list search includes contact/company name and phone; 1.9.3 invoice PDF shows workspace tax_registration_id when set.
API (tenant)
Base: /api/tenant/v1 — full reference tenant-v1-invoices.md.
Frontend
SPA mirrors Quotations (table + create/edit page, record page) under the existing AppLayout — do not invent a parallel shell.
| Piece | Path |
|---|---|
| Page | src/pages/invoices/ (invoices-page.tsx, invoice-form-dialog.tsx, invoice-detail-sheet.tsx) |
| Detail sheet tabs | Overview, Lines, Notes, Timeline — actions: download PDF, email customer (after send), stop recurring (active series root), assign, add note, send, cancel (/void), edit (draft only), delete. No accept action (Invoices has no accepted status). List/detail show Partial badge (display-only) when unpaid with partial payments. |
| Shared billing UI | src/components/billing/document-lines-editor.tsx, document-totals-panel.tsx, src/components/common/rich-text-editor.tsx, src/lib/billing/document-totals.ts, src/lib/sanitize-html.ts |
| Service | customerInvoiceService in src/api/services.ts |
| Types | CustomerInvoice* in src/types/api.ts (kept distinct from the pre-existing Central Invoice* types) |
| Query keys | QUERY_KEYS.customerInvoices / customerInvoice(id) / customerInvoiceTimeline(id) / customerInvoiceStats |
| Permissions | PERMISSIONS.customerInvoices.* (maps to invoices.* permission strings) |
| Nav | New Billing sidebar group (after Sales) — permission: PERMISSIONS.customerInvoices.view, module: 'invoices'. Kept separate from the Central Billing nav. |
| Route | tenantRoutes.invoices = '/invoices', lazy-loaded in App.tsx behind RequireAccess module="invoices" |
| Notifications | src/notifications/modules/invoices.ts — customer_invoice.assigned → /invoices?invoice={id} |
| Playwright | One shared login, headed human workflow: validation, CRUD, Overview memo + activity notes/timeline, PDF, send/void, recurring generate + stop with optional void, shortcuts, trash. e2e/pages/invoices.page.ts, e2e/tests/invoices/, npm run test:e2e:invoices / test:e2e:invoices:headed |
Production readiness: Invoices 1.1.0.
Tests
php artisan test --compact tests/Feature/Tenant/CustomerInvoice/CustomerInvoiceTest.php tests/Feature/Tenant/CustomerInvoice/CustomerInvoiceRecurrenceTest.php
npm run typecheck && npm run lint && npm run build
npm run test:e2e:invoicesLogging
- Spatie
LogsActivityonCustomerInvoice(log namecustomer_invoices) - Domain
customer_invoice_activitiestimeline PlatformAuditServiceviaCustomerInvoiceEventSubscriber- Recurring generate:
invoices.generate-recurring.series_failed,tenant_failed,time_budget_reached - PDF:
invoices.pdf.rendered,invoices.pdf.warm_failed
Ask EloSync
Ask EloSync Invoice tools (get_invoice, confirmed status/assign/note writes, plus existing get_overdue_invoices / get_invoice_balance_summary) are registered in AIToolRegistry and confirmed via PendingAiActionService. Status auth mirrors HTTP POST …/status (Unpaid→send, Cancelled→void, else update) via InvoiceAiSupport::authorizeStatusChange and CustomerInvoiceService::changeStatus. Overdue rows include assignee id. See AI tools and AI Invoice triage production readiness.