Credit Notes — Developer Guide
Mirror of the Payments developer guide (assignee scope, notes, domain timeline, hard dependency on Invoices), with a lines child table (subtotal/tax/total) instead of Payments' allocations.
Naming: the backend model is
CustomerCreditNote(tablecustomer_credit_notes) — distinct from Central's own platform-billingcredit_notesledger table (credits the platform issues to a tenant against its own module-subscription invoices). Frontend mirrors this withcustomerCreditNoteService/PERMISSIONS.customerCreditNotes/QUERY_KEYS.customerCreditNotes.
Backend layout
| Piece | Path |
|---|---|
| Models | app/Models/CustomerCreditNote.php, CustomerCreditNoteLine, CustomerCreditNoteNote, CustomerCreditNoteActivity |
| Enums | CustomerCreditNoteStatusEnum, CustomerCreditNoteActivityTypeEnum |
| Service | app/Services/Tenant/CustomerCreditNoteService.php (+ ScopesToAssignee) |
| Controller | app/Http/Controllers/Tenant/Api/V1/CustomerCreditNoteController.php |
| Requests | app/Http/Requests/Tenant/Api/V1/CustomerCreditNote/* |
| Resources | app/Http/Resources/Tenant/Api/V1/CustomerCreditNote/* |
| Policy | app/Policies/CustomerCreditNotePolicy.php |
| Events | app/Events/CustomerCreditNote*.php |
| Subscriber | app/Listeners/CustomerCreditNoteEventSubscriber.php (audit + assignment notification) |
| Notifications | app/Notifications/Tenant/CustomerCreditNote/CustomerCreditNoteAssignedNotification.php |
| Link rules | LinkableContact, LinkableCompany, EligibleCreditNoteAssignee — customer_invoice_id is a plain tenant-scoped Rule::exists() on CustomerInvoice |
| Factories | CustomerCreditNoteFactory, CustomerCreditNoteLineFactory, CustomerCreditNoteNoteFactory, CustomerCreditNoteActivityFactory |
| Tests | tests/Feature/Tenant/CustomerCreditNote/CustomerCreditNoteTest.php |
Domain notes
- Hard dependency: Credit Notes declares a required
module_dependenciesrow on Invoices (database/migrations/2026_07_31_222006_add_credit_notes_invoices_dependency.php) — Marketplace install is blocked until Invoices is entitled, same pattern as Payments → Invoices. - Status machine lives on
CustomerCreditNoteStatusEnum::allowedTransitions()/canTransitionTo():draft → issued|void,issued → applied|void,applied → refunded;refundedandvoidare terminal. CustomerCreditNoteService::transitionStatus()throwsValidationException(422,statusfield) for disallowed transitions; used byissue()andvoid().apply()andrefund()guard transitions themselves (like Paymentspost/void).issue()backfillsissue_dateto today if it wasn't already set.apply()locks the linked invoice withCustomerInvoice::query()->lockForUpdate()(deliberately notwithTrashed()— the target invoice must still be active) and validates before writing anything, throwing onstatus: invoice must exist, its status must beunpaid, and the credit note'stotalmust not exceed the invoice'sbalance_due(0.01 float tolerance). It then addstotaltoCustomerInvoice::amount_creditedand callsCustomerInvoice::recalculateBalanceFromAmounts()— this recomputesbalance_dueand can advance the invoicestatustopaid, the same as Payments posting. It also records acreditedactivity on the invoice itself. When Accounting is entitled, posts Dr Revenue / Cr AR (plus tax payable debit when tax > 0).refund()mirrorsCustomerPaymentService::void(): voids the linked apply journal viaCashMovementJournalService::voidLinked(), locks the invoice withCustomerInvoice::withTrashed()->lockForUpdate(), subtractstotalfromamount_credited(floored at 0), callsrecalculateBalanceFromAmounts(), transitions the credit note torefunded, and recordscredit_refundedon the invoice. DispatchesCustomerCreditNoteStatusChanged+CustomerCreditNoteRefunded.void()is a pure status transition — it does not reverse any invoice balance, because it's only reachable fromdraft/issued(beforeamount_creditedhas been touched).- Invoice cancel (
CustomerInvoiceService::void()) rejects when netamount_credited > 0and tells operators to refund applied credit notes first. - Content updates (
PUT) — including replacing the fulllinesarray — are draft-only viaCustomerCreditNoteService::isEditable()(status === draft, same guard used for delete). Assignment remains available after issuing viaPOST …/assign. - Lines are a first-class child table (
customer_credit_note_lines), not embedded JSON — each row is{ description, quantity, unit_price, tax_rate, sort_order }.subtotal/tax_total/totalare recomputed server-side from lines on create/update, same as Invoices. - Assignee scoping via
ScopesToAssigneewithcredit-notes.assign. credit-notes.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); when omitted, the frontend form pre-fills them from the selected invoice, but the backend does not auto-default them — a blank value is stored asnull.- Auto-numbering:
CustomerCreditNoteService::nextNumber()reads thecredit_notes_number_prefixtenant setting (defaultCN-), then zero-pads a running count to 5 digits — same pattern as Invoices/Payments. Prefix is editable under Settings → General → Document number prefixes.customer_credit_noteshas aunique(tenant_id, number)DB index;create()retries up to 3 times via the sharedRetriesOnDuplicateNumbertrait on a duplicate-key collision.
Permissions
credit-notes.view | create | update | delete | restore | force.delete | assign | issue | apply | void | refundRoutes use module:credit-notes then can:credit-notes.* / policies.
Catalog: slug credit-notes, category billing, is_default_included = false, is_billable = false, sort_order = 30, version 1.2.0. Registered via DefaultModuleRegistrar migration (migrate-only), with a follow-up migration inserting the module_dependencies row on invoices. Catalog MINOR bumps use DefaultModuleRegistrar::bumpVersion (e.g. 1.1.0 → 1.2.0 for applied refund).
API (tenant)
Base: /api/tenant/v1 — full reference tenant-v1-credit-notes.md.
Frontend
SPA mirrors Invoices/Payments (table + create/edit page, record page) under the existing AppLayout — do not invent a parallel shell.
| Piece | Path |
|---|---|
| Page | src/pages/credit-notes/ (credit-notes-page.tsx, credit-note-form-dialog.tsx, credit-note-detail-sheet.tsx) |
| Detail sheet | Overview, linked invoice, contact/company, line items, notes, timeline — actions: assign, add note, issue, apply, refund (applied), void (draft/issued), edit (draft only), delete |
| Form dialog | Invoice picker (drives default currency/contact/company), title, notes, issue date, contact/company/assignee pickers, and a line-items editor (useFieldArray) with live subtotal/tax/total preview |
| Service | customerCreditNoteService in src/api/services.ts |
| Types | CustomerCreditNote* in src/types/api.ts |
| Query keys | QUERY_KEYS.customerCreditNotes / customerCreditNote(id) / customerCreditNoteTimeline(id) / customerCreditNoteStats |
| Permissions | PERMISSIONS.customerCreditNotes.* (maps to credit-notes.* permission strings) |
| Nav | Billing sidebar group, after Payments — permission: PERMISSIONS.customerCreditNotes.view, module: 'credit-notes' |
| Route | tenantRoutes.creditNotes = '/credit-notes', lazy-loaded in App.tsx behind RequireAccess module="credit-notes" |
| Notifications | src/notifications/modules/credit-notes.ts — customer_credit_note.assigned → /credit-notes?credit-note={id} |
| Cross-link | Invoice record page shows a "Credit notes" link to /credit-notes?invoice={id} when the Credit Notes module + credit-notes.view are both present |
| Playwright | e2e/pages/credit-notes.page.ts, e2e/tests/credit-notes/, npm run test:e2e:credit-notes |
Tests
php artisan test --compact tests/Feature/Tenant/CustomerCreditNote/CustomerCreditNoteTest.php
npm run typecheck && npm run lint && npm run build
npm run test:e2e:credit-notesLogging
- Spatie
LogsActivityonCustomerCreditNote(log namecustomer_credit_notes) - Domain
customer_credit_note_activitiestimeline PlatformAuditServiceviaCustomerCreditNoteEventSubscriber
Ask EloSync
Ask EloSync Credit Note tools (get_credit_note, confirmed status/assign/note writes) are registered in AIToolRegistry and confirmed via PendingAiActionService. Status auth mirrors dedicated HTTP actions (Issued→issue, Applied→apply, Void→void, Refunded→refund; Draft target rejected) via CreditNoteAiSupport::authorizeStatusChange and CustomerCreditNoteService methods. Assign uses EligibleCreditNoteAssignee. See AI tools and AI Credit Note triage production readiness.