Tenant API v1 — Credit Notes
Base path: /api/tenant/v1
Middleware: auth:tenant-api, tenant.user, not.suspended, verified, module:credit-notes, plus permission middleware / policies.
Naming: the model is
CustomerCreditNote— a credit note a tenant issues against its own customer'sCustomerInvoice. Central's own platform-billingcredit_notesledger table (credits the platform issues to a tenant against its own subscription invoices) is unrelated.
Requires the Invoices module (hard module_dependencies row) — a credit note always references a CustomerInvoice and cannot be created without it.
Assignee scoping: without credit-notes.assign (and not superadmin), list/stats/view/update/issue/apply/void/refund only include credit notes where assigned_to is the current user.
Stats
GET /credit-notes/stats
Same filters as list (minus pagination/sort). Response:
{
"total_credit_notes": 0,
"my_credit_notes": 0,
"draft": 0,
"issued": 0,
"applied": 0,
"refunded": 0,
"void": 0,
"applied_total": 0,
"scope": "org | mine"
}applied_total sums total across credit notes with status = applied.
Credit Notes CRUD
GET /credit-notes
Query: search (matches title or number), status (draft|issued|applied|refunded|void), customer_invoice_id, contact_id, company_id, assigned_to (unassigned or user id), my_credit_notes, trashed (true|only), sort, direction, page, per_page.
List items include status, currency, subtotal/tax_total/total, issue_date, customer_invoice ref (number/total/balance_due/status), contact/company refs, assignee/creator refs, and latest_note.
POST /credit-notes
Body: customer_invoice_id (required, tenant-scoped existence check), title (required), notes, currency (3-letter, optional — defaults to the linked invoice's currency when omitted), issue_date (optional date), contact_id, company_id (optional, module-entitlement + assignee-scope validated via LinkableContact/LinkableCompany — default to the invoice's contact_id/company_id when omitted), assigned_to, lines (array of { description, quantity, unit_price, tax_rate, sort_order }).
subtotal, tax_total, and total are computed server-side from lines — do not send them. Status always starts at draft; number is auto-generated (CN-00001, configurable via the credit_notes_number_prefix tenant setting). number is unique per tenant at the database level; on the rare concurrent-create collision, the service retries with a freshly generated number (up to 3 attempts).
GET /credit-notes/{id}
Includes contact, company, the linked customer invoice, assignee, creator, lines, notes, and timeline activities. Embedded notes and timeline/domain activities are newest-first (created_at DESC, then id DESC).
PUT /credit-notes/{id}
Partial update of draft credit notes only — including replacing the full lines set (recalculates totals). Non-draft credit notes return 422 on status (Only draft credit notes can be edited.). Assignment after issuing uses POST /credit-notes/{id}/assign.
DELETE /credit-notes/{id}
Soft delete. Permission: credit-notes.delete.
POST /credit-notes/{id}/restore
Permission: credit-notes.restore.
DELETE /credit-notes/{id}/force
Permanently delete a soft-deleted credit note. Permission: credit-notes.force.delete (owner/superadmin only by default).
Actions
POST /credit-notes/{id}/assign
{ "assigned_to": number|null }
Permission: credit-notes.assign.
POST /credit-notes/{id}/issue
Transitions draft → issued. Backfills issue_date to today if unset. Permission: credit-notes.issue. Rejects with 422 on status if the credit note isn't currently draft.
POST /credit-notes/{id}/apply
Transitions issued → applied. The linked invoice is locked (SELECT ... FOR UPDATE) — deliberately not withTrashed(), so a soft-deleted invoice can never receive a credit — and validated before anything is written: rejected with a 422 on status if the invoice can't be found, its status is not unpaid, or the credit note's total exceeds the invoice's current balance_due (0.01 tolerance). Once valid, adds the credit note's total to the invoice's amount_credited and calls CustomerInvoice::recalculateBalanceFromAmounts(), which recomputes balance_due and can advance the invoice status to paid (same as a Payment post — this is not status-neutral). Records a credited activity on the invoice. When Accounting is entitled, posts the revenue-reverse journal. Permission: credit-notes.apply. Rejects with 422 on status if the credit note isn't currently issued.
POST /credit-notes/{id}/refund
Transitions applied → refunded. Mirrors payment void for the accounting reverse: voids the linked apply journal (if any), locks the invoice with withTrashed(), subtracts the credit note's total from amount_credited (floored at 0), recalculates balance/status, and records credit_refunded on the invoice. Permission: credit-notes.refund. Rejects with 422 on status if the credit note isn't currently applied. Terminal — cannot re-apply or void a refunded credit note.
POST /credit-notes/{id}/void
Transitions draft|issued → void. Does not reverse any invoice balance — void is only valid before a credit note has been applied. Permission: credit-notes.void. Rejects with 422 on status if the credit note is already applied, refunded, or void.
GET /credit-notes/{id}/pdf
Download branded credit note PDF. Permission: credit-notes.view (assignee-scoped). Available after Issue (issued / applied).
POST /credit-notes/{id}/email
Email the credit note to the customer with optional PDF attachment. Body matches billing document email (to, cc, subject, message, attach_pdf). Permission: credit-notes.issue (policy send). Throttled (billing-document-email). Issued / applied only (not refunded or void).
POST /credit-notes/{id}/notes
{ "body": string }
Permission: credit-notes.update.
GET /credit-notes/{id}/timeline
Domain timeline entries (created, updated, assigned, status_changed, issued, applied, refunded, voided, note_added, emailed, deleted, restored).