Payments — Developer Guide
Mirror of the Invoices developer guide (assignee scope, notes, domain timeline), with one structural difference: Payments declares a required module_dependencies row on Invoices — the first Phase 3 module to do so.
Naming: the backend model is
CustomerPayment(tablecustomer_payments) — Central's own platform-billing Payments ledger already exists for subscription payments tenants make to the platform. Frontend mirrors this withcustomerPaymentService/PERMISSIONS.customerPayments/QUERY_KEYS.customerPayments, distinct from the pre-existingpaymentService/PERMISSIONS.payments.
Backend layout
| Piece | Path |
|---|---|
| Models | app/Models/CustomerPayment.php, CustomerPaymentAllocation, CustomerPaymentNote, CustomerPaymentActivity |
| Enums | CustomerPaymentStatusEnum, CustomerPaymentMethodEnum, CustomerPaymentActivityTypeEnum |
| Service | app/Services/Tenant/CustomerPaymentService.php (+ ScopesToAssignee) |
| Controller | app/Http/Controllers/Tenant/Api/V1/CustomerPaymentController.php |
| Requests | app/Http/Requests/Tenant/Api/V1/CustomerPayment/* |
| Resources | app/Http/Resources/Tenant/Api/V1/CustomerPayment/* |
| Policy | app/Policies/CustomerPaymentPolicy.php |
| Events | app/Events/CustomerPayment*.php |
| Subscriber | app/Listeners/CustomerPaymentEventSubscriber.php (audit + assignment notification) |
| Notifications | app/Notifications/Tenant/CustomerPayment/CustomerPaymentAssignedNotification.php |
| Link rules | LinkableContact, LinkableCompany, EligiblePaymentAssignee — allocations.*.customer_invoice_id is a plain tenant-scoped Rule::exists() on CustomerInvoice |
| PDF / email | CustomerPaymentPdfService, CustomerPaymentEmailService, resources/views/payments/receipt.blade.php — posted-only; mirrors invoice billing document mailer |
| Tests | tests/Feature/Tenant/CustomerPayment/CustomerPaymentTest.php, CustomerPaymentPdfTest.php, CustomerPaymentEmailTest.php |
Domain notes
- Hard dependency: Payments declares a required
module_dependenciesrow on Invoices (database/migrations/2026_07_31_221006_add_payments_invoices_dependency.php) — Marketplace install is blocked until Invoices is entitled. Contrast with Invoices itself, which has no hard dependency. - Status machine lives on
CustomerPaymentStatusEnum::allowedTransitions()/canTransitionTo():draft → posted,posted → void;voidis terminal. There is nosent/partialequivalent — Payments only has these three states. CustomerPaymentService::post()/void()throwValidationException(422,statusfield) for disallowed transitions.post()locks every allocation's invoice withCustomerInvoice::query()->lockForUpdate()(deliberately notwithTrashed()— a soft-deleted invoice must not receive a payment) and validates all of them in a first pass before mutating anything, onallocations(naming the invoice number): invoice must exist, its status must besent/partial, the allocation amount must not exceed itsbalance_due(0.01 float tolerance), and if both the payment and invoice have acurrencyset they must match. Only after every allocation passes does a second pass add eachamountto itsCustomerInvoice::amount_paidand callCustomerInvoice::recalculateBalanceFromAmounts()(recomputesbalance_dueand advancessent → partial|paid).void()locks each invoice withwithTrashed()and does not re-check its status — this is intentional: a void is a ledger correction that must succeed even if the invoice was since fully paid by another payment or soft-deleted, otherwiseamount_paidwould permanently disagree with the payment record. It does the mirror-image subtraction and recalculation.- Content updates (
PUT) — including replacingallocations— are draft-only viaCustomerPaymentService::isEditable()(status === draft, same guard used for delete). Assignment remains available after posting viaPOST …/assign. - Allocations are a first-class child table (
customer_payment_allocations), not embedded JSON — each row is{ customer_payment_id, customer_invoice_id, amount }, loaded with itscustomerInvoiceref for display. - Assignee scoping via
ScopesToAssigneewithpayments.assign. payments.force.deleteis not granted to any default role — owner/superadmin only.contact_id/company_idare optional and validated for module entitlement + assignee scope (LinkableContact/LinkableCompany), same pattern as Invoices.- Auto-numbering:
CustomerPaymentService::nextNumber()reads thepayments_number_prefixtenant setting (defaultPAY-), then zero-pads a running count to 5 digits — same pattern as Invoices'invoices_number_prefix. Both prefixes are editable under Settings → General → Document number prefixes.customer_paymentshas aunique(tenant_id, number)DB index;create()retries up to 3 times via the sharedRetriesOnDuplicateNumbertrait on a duplicate-key collision.
Permissions
payments.view | create | update | delete | restore | force.delete | assign | post | void | sendRoutes use module:payments then can:payments.* / policies.
Catalog: slug payments, category billing, is_default_included = false, is_billable = false, sort_order = 20. Registered via DefaultModuleRegistrar migration (migrate-only), with a follow-up migration inserting the module_dependencies row on invoices.
API (tenant)
Base: /api/tenant/v1 — full reference tenant-v1-payments.md.
Frontend
SPA mirrors Invoices (table + create/edit page, record page) under the existing AppLayout — do not invent a parallel shell.
| Piece | Path |
|---|---|
| Page | src/pages/payments/ (payments-page.tsx, payment-form-dialog.tsx, payment-detail-sheet.tsx) |
| Detail sheet tabs | Overview, Allocations, Notes, Timeline — actions: assign, add note, post, void, edit (draft only), delete |
| Form dialog | Amount, currency, method, paid-at, reference, notes, contact/company/assignee pickers, and an allocations editor (useFieldArray). Allocation options show contact · company · invoice number — balance due; search matches name/company/phone/number; selecting an invoice copies contact, company, and assignee onto the payment when present on the invoice. |
| Service | customerPaymentService in src/api/services.ts |
| Types | CustomerPayment* in src/types/api.ts (kept distinct from the pre-existing Central Payment* types) |
| Query keys | QUERY_KEYS.customerPayments / customerPayment(id) / customerPaymentTimeline(id) / customerPaymentStats |
| Permissions | PERMISSIONS.customerPayments.* (maps to payments.* permission strings) |
| Receipt actions | payment-view-page.tsx — Download receipt + Email receipt (payments.send) when status === posted; shared BillingDocumentEmailDialog with kind: 'payment' |
| Nav | Billing sidebar group, after Invoices — permission: PERMISSIONS.customerPayments.view, module: 'payments'. Kept separate from the Central Billing nav. |
| Route | tenantRoutes.payments = '/payments', lazy-loaded in App.tsx behind RequireAccess module="payments" |
| Notifications | src/notifications/modules/payments.ts — customer_payment.assigned → /payments?payment={id} |
| Cross-link | Invoice record page shows a "Related payments" link to /payments when the Payments module + payments.view are both present |
| Playwright | e2e/pages/payments.page.ts, e2e/tests/payments/, npm run test:e2e:payments |
Tests
php artisan test --compact tests/Feature/Tenant/CustomerPayment/
npm run typecheck && npm run lint && npm run build
npm run test:e2e:paymentsLogging
- Spatie
LogsActivityonCustomerPayment(log namecustomer_payments) - Domain
customer_payment_activitiestimeline PlatformAuditServiceviaCustomerPaymentEventSubscriber
Ask EloSync
Ask EloSync Payment tools (get_payment, confirmed status/assign/note writes) are registered in AIToolRegistry and confirmed via PendingAiActionService. Status confirm calls CustomerPaymentService::post() / void() (Draft target rejected). Get payload includes assigned_to (user id) and assignee_name. See AI tools and AI Payment triage production readiness.