Accounts Receivable Invoices
Overview
The accounts-receivable-invoices module manages credit invoices issued to customers. An AR invoice represents credit extended to a customer — money owed to the business. Invoices can be created manually via the API or automatically when a sale is completed using a payment method configured to generate receivables.
Architecture
This module follows Hexagonal Architecture with four layers:
domain/ — Repository interface port + domain types
application/ — Business logic and event handlers (AccountsReceivableInvoicesService)
infrastructure/ — Kysely DB adapter (AccountsReceivableInvoicesRepository)
interfaces/ — HTTP controller, DTOs, query objects
Dependency Injection
The repository is injected via the ACCOUNTS_RECEIVABLE_INVOICES_REPOSITORY symbol token, allowing the service to depend only on the IAccountsReceivableInvoicesRepository interface (domain port). The concrete Kysely implementation is bound in the module definition.
Module Dependencies
| Dependency | Why |
|---|---|
DatabaseModule | Kysely database connection for queries and transactions |
PaymentMethodsModule | Identifies payment methods that generate AR (e.g. credit terms) |
CustomersModule | Loads customer data for credit limit validation |
BusinessUsersModule | Checks whether the acting user is an Owner (for credit limit override) |
AuditLogModule | Records all create, update, void, and override events |
Domain Concepts
Invoice
An invoice represents a credit obligation from a customer.
Key fields:
| Field | Description |
|---|---|
entityType | Source document type (e.g. sale) |
entityId | UUID of the source document |
customerId | The customer who owes the balance |
status | Current lifecycle status (see below) |
totalAmount | Invoice total in invoice currency |
totalBaseAmount | Invoice total in base business currency |
balanceDue | Outstanding balance in invoice currency |
baseBalanceDue | Outstanding balance in base currency |
exchangeRate | FX rate applied at invoice creation |
documentNumber | Auto-generated sequential document number |
saleDate | Invoice date |
dueDate | Payment due date (set when scheduling) |
detail | JSONB: list of receipt payments applied to this invoice |
Domain Types
Domain-specific interfaces are in domain/accounts-receivable-invoice.types.ts:
AccountsReceivableReceiptItem— represents a single receipt line item applied to an invoicePaymentDetailItem— represents a payment method entry from a sale'spaymentDetail
Status Lifecycle
DRAFT → SUBMITTED → APPROVED → SCHEDULED → PAID
↑
VOID ←——————————————————————— (any non-terminal state)
| Transition | Description |
|---|---|
| DRAFT → SUBMITTED | Invoice submitted for approval; submittedBy / submittedAt recorded |
| SUBMITTED → APPROVED | First approval recorded; 2-level approval may be required (see below) |
| APPROVED → SCHEDULED | Due date is set |
| APPROVED/SCHEDULED → PAID | Balance has been fully collected via receipts (auto or manual) |
| Any → VOID | Invoice voided; voidedAt / voidedBy recorded |
Status transitions are validated against a constant STATUS_TRANSITIONS map in the service layer.
Two-Level Approval
When the business has a business_approval_rule with approval_level = 2 and the invoice totalBaseAmount exceeds the configured threshold:
- First APPROVED call sets
firstApprovedBy/firstApprovedAt - A second APPROVED call from a different user is required to fully approve
- Attempting a second approval with the same user throws
SECOND_APPROVER_REQUIRED
The approval rule lookup is performed via the repository method findApprovalRule().
Credit Limit
Before creating an invoice, the system checks the customer's creditLimit:
- Uses
sumOpenArByCustomer()repository method to sum all open AR (non-PAID, non-VOID) for the customer - Adds the new invoice
baseAmount - If the total exceeds the limit, throws
CREDIT_LIMIT_EXCEEDED - If the acting user is an Owner, the limit is overridden and an audit log entry is written
Invoice Locking
Once an invoice leaves DRAFT status, it is locked for field edits. Only the following fields can be changed:
status— always allowed (subject to transition rules)updatedBy— always alloweddueDate— allowed when transitioning to SCHEDULEDvoidedAt/voidedBy— allowed when voiding
Owner override: An Owner can edit locked fields. The override is audit-logged.
Main Use Cases
1. Create Invoice (manual)
POST /accounts-receivable-invoices
- Credit limit is validated for the customer
- A sequential document number is generated inside a transaction
- The invoice is persisted via the repository port
- An
accountsReceivableInvoice.createevent is emitted
2. Auto-Create Invoice from Sale
Triggered by OnCreateSaleEvent / OnUpdateSaleEvent when a sale transitions to submitted or reviewed:
- Each payment method in
sale.paymentDetailis checked - If
paymentMethod.generatesAccountsReceivable = true, a new SUBMITTED invoice is created - Credit limits are checked before creation (no override for auto-created invoices)
3. Apply Receipt Payment
Triggered by OnCreateAccountsReceivableReceiptEvent / OnUpdateAccountsReceivableReceiptEvent:
- Each receipt line item references an invoice by UUID and an amount
balanceDueis reduced by the receipt amount- If
balanceDuereaches0, the invoice is automatically set toPAIDand audit-logged - Voiding a receipt reverses the balance change (removes only the specific receipt item by matching amount + invoice ID)
4. Submit Invoice
PATCH /accounts-receivable-invoices/:id with { status: "submitted" }
Sets submittedBy and submittedAt.
5. Approve Invoice
PATCH /accounts-receivable-invoices/:id with { status: "approved" }
Triggers two-level approval check if configured.
6. Schedule Invoice
PATCH /accounts-receivable-invoices/:id with { status: "scheduled", dueDate: "2026-04-15" }
Sets the payment due date.
7. Void Invoice
PATCH /accounts-receivable-invoices/:id with { status: "void" }
Sets voidedAt and voidedBy. Final state — cannot be reversed.
8. Delete Invoice
DELETE /accounts-receivable-invoices/:id
Only DRAFT invoices can be deleted. All other statuses must be VOID'd instead.
API Endpoints
| Method | Path | Description |
|---|---|---|
POST | /accounts-receivable-invoices | Create a new AR invoice |
GET | /accounts-receivable-invoices | List invoices (paginated, filterable) |
GET | /accounts-receivable-invoices/:id | Get invoice by ID |
PATCH | /accounts-receivable-invoices/:id | Update / transition invoice status |
DELETE | /accounts-receivable-invoices/:id | Delete a draft invoice |
Query Parameters (list endpoint)
| Param | Description |
|---|---|
businessId | Filter by business UUID |
customerId | Filter by customer UUID |
search | Search across documentNumber, terms, notes, entityType, customer taxId / taxName / taxAddress |
page | Page number (1-based) |
size | Results per page |
orderBy | Field to sort by (e.g. entityType) |
order | asc or desc |
Error Responses
| Error Code | HTTP Status | Description |
|---|---|---|
CREDIT_LIMIT_EXCEEDED | 400 | Customer's open AR + new invoice exceeds credit limit |
INVALID_STATUS_TRANSITION | 400 | Requested status change is not allowed from the current status |
INVOICE_LOCKED | 400 | Non-draft invoice cannot be edited (unless Owner) |
SECOND_APPROVER_REQUIRED | 400 | Two-level approval requires a different user for second approval |
| Not found | 404 | Invoice with the given ID does not exist |
Example Requests
Create invoice for a credit sale
POST /accounts-receivable-invoices
{
"entityType": "sale",
"entityId": "sale-uuid",
"saleDate": "2026-03-12",
"status": "submitted",
"customerId": "customer-uuid",
"currencyId": "currency-uuid",
"currencyCode": "GTQ",
"minorUnit": 2,
"exchangeRate": 1.00,
"currency": { "id": "currency-uuid", "name": "Quetzal", "symbol": "Q" },
"totalAmount": 112.00,
"totalBaseAmount": 112.00,
"balanceDue": 112.00,
"baseBalanceDue": 112.00,
"businessId": "business-uuid",
"createdBy": "employee-uuid"
}
Submit invoice
PATCH /accounts-receivable-invoices/:id
{
"status": "submitted",
"updatedBy": "employee-uuid"
}
Approve invoice
PATCH /accounts-receivable-invoices/:id
{
"status": "approved",
"updatedBy": "employee-uuid"
}
Schedule invoice with due date
PATCH /accounts-receivable-invoices/:id
{
"status": "scheduled",
"dueDate": "2026-04-15",
"updatedBy": "employee-uuid"
}
Void invoice
PATCH /accounts-receivable-invoices/:id
{
"status": "void",
"updatedBy": "employee-uuid"
}
Events
| Event | When emitted | Consumed by |
|---|---|---|
accountsReceivableInvoice.create | After invoice created | (external listeners) |
accountsReceivableInvoice.update | After invoice updated | (external listeners) |
accountsReceivableReceipt.create | Emitted by receipts module | This module — applies payment to invoice |
accountsReceivableReceipt.update | Emitted by receipts module | This module — applies or reverses payment |
sale.create | Emitted by sales module | This module — auto-creates invoice for credit payment methods |
sale.update | Emitted by sales module | This module — auto-creates invoice when sale transitions to submitted |
Design Decisions
Token-based repository injection
The repository is registered via ACCOUNTS_RECEIVABLE_INVOICES_REPOSITORY symbol token, decoupling the service from the concrete Kysely implementation. This makes the service testable with simple mock objects and respects the dependency inversion principle.
Why detail is a JSONB column
Receipt payment history is stored in detail.items directly on the invoice. This avoids a separate join table for the common read path (showing balance application history inline with the invoice).
Why credit limit check is outside the transaction
The credit limit check runs before the database transaction to keep validation fast and to surface errors before acquiring a DB connection. A known limitation is that concurrent invoice creation could theoretically race past the limit — this is acceptable given the low volume of simultaneous AR operations in the current use cases.
Why checkApprovalRule uses raw SQL
The business_approval_rule table was added after the initial Kysely type generation run. The raw SQL is encapsulated in the repository's findApprovalRule() method. This should be resolved by running pnpm run generate:types and migrating to the typed Kysely builder.
Known Follow-Ups
| Item | Priority | Notes |
|---|---|---|
Move findApprovalRule to typed Kysely query | Medium | Run pnpm run generate:types first |
| Move credit limit check inside the create transaction | Low | Prevents TOCTOU race on concurrent invoice creation |
Enable RolesGuard on all endpoints | High | Currently commented out; re-enable once permissions are mapped |