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, CustomerPaymentRefundTest.php, CustomerPaymentStripeRefundTest.php, CustomerPaymentPdfTest.php, CustomerPaymentEmailTest.php, TenantStripeIntegrationTest.php |
| Tenant Stripe (1.7.0) | TenantStripeConnection, TenantStripeConnectionService, TenantStripeCheckoutService, TenantStripeRefundService, TenantStripeApi, StripeMoney, TenantStripeIntegrationController, TenantStripeWebhookController |
Tenant Stripe + portal Checkout (1.6.0)
Workspace-owned Stripe credentials (encrypted secrets, mode sandbox|live) for Customer Portal invoice pay. Not Central payment_gateways / Cashier.
- Staff API:
/payments/integrations/stripe(payments.manage_integrations) - Portal:
POST /portal/invoices/{id}/checkout→ Checkout Session URL - Webhook:
POST /webhooks/tenant/{tenant}/stripe/customer-payments→ create+post gatewayCustomerPayment(idempotent ongateway_transaction_id);refund.created/charge.refundedbook a ledger refund once onstripe_refund_id - Staff refund of
gateway_provider=stripecalls Striperefunds.createon the PaymentIntent (Checkout Session ids are resolved first) before the ledger write. The secret is the mode stored ongateway_mode, else the(sandbox)/(live)capture note, else the connection’s current mode. A Stripe error leaves the payment posted. Manual methods never call Stripe. - Catalog payments 1.7.0. Companion catalog: customer-portal 1.4.0 (
online_payment_availableon portal invoice resource)
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 | send | refund | manage_integrationsRoutes use module:payments then can:payments.* / policies. Default admin/manager receive manage_integrations (1.6.0).
Catalog: slug payments, category billing, version 1.7.0, 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 | Amount, currency, method, paid-at, reference, notes, contact/company/assignee pickers, and an allocations editor (useFieldArray). Create loads GET /invoices?allocatable=1. 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. Fully paid invoices are omitted on create. Edit keeps invoices already allocated on that draft. |
| 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 posted or partially refunded; Refund via Stripe when gateway_provider=stripe, otherwise Record refund |
| 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.