Skip to main content

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

DependencyWhy
DatabaseModuleKysely database connection for queries and transactions
PaymentMethodsModuleIdentifies payment methods that generate AR (e.g. credit terms)
CustomersModuleLoads customer data for credit limit validation
BusinessUsersModuleChecks whether the acting user is an Owner (for credit limit override)
AuditLogModuleRecords all create, update, void, and override events

Domain Concepts

Invoice

An invoice represents a credit obligation from a customer.

Key fields:

FieldDescription
entityTypeSource document type (e.g. sale)
entityIdUUID of the source document
customerIdThe customer who owes the balance
statusCurrent lifecycle status (see below)
totalAmountInvoice total in invoice currency
totalBaseAmountInvoice total in base business currency
balanceDueOutstanding balance in invoice currency
baseBalanceDueOutstanding balance in base currency
exchangeRateFX rate applied at invoice creation
documentNumberAuto-generated sequential document number
saleDateInvoice date
dueDatePayment due date (set when scheduling)
detailJSONB: 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 invoice
  • PaymentDetailItem — represents a payment method entry from a sale's paymentDetail

Status Lifecycle

DRAFT → SUBMITTED → APPROVED → SCHEDULED → PAID

VOID ←——————————————————————— (any non-terminal state)
TransitionDescription
DRAFT → SUBMITTEDInvoice submitted for approval; submittedBy / submittedAt recorded
SUBMITTED → APPROVEDFirst approval recorded; 2-level approval may be required (see below)
APPROVED → SCHEDULEDDue date is set
APPROVED/SCHEDULED → PAIDBalance has been fully collected via receipts (auto or manual)
Any → VOIDInvoice 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:

  1. First APPROVED call sets firstApprovedBy / firstApprovedAt
  2. A second APPROVED call from a different user is required to fully approve
  3. 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 allowed
  • dueDate — allowed when transitioning to SCHEDULED
  • voidedAt / 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

  1. Credit limit is validated for the customer
  2. A sequential document number is generated inside a transaction
  3. The invoice is persisted via the repository port
  4. An accountsReceivableInvoice.create event is emitted

2. Auto-Create Invoice from Sale

Triggered by OnCreateSaleEvent / OnUpdateSaleEvent when a sale transitions to submitted or reviewed:

  1. Each payment method in sale.paymentDetail is checked
  2. If paymentMethod.generatesAccountsReceivable = true, a new SUBMITTED invoice is created
  3. Credit limits are checked before creation (no override for auto-created invoices)

3. Apply Receipt Payment

Triggered by OnCreateAccountsReceivableReceiptEvent / OnUpdateAccountsReceivableReceiptEvent:

  1. Each receipt line item references an invoice by UUID and an amount
  2. balanceDue is reduced by the receipt amount
  3. If balanceDue reaches 0, the invoice is automatically set to PAID and audit-logged
  4. 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

MethodPathDescription
POST/accounts-receivable-invoicesCreate a new AR invoice
GET/accounts-receivable-invoicesList invoices (paginated, filterable)
GET/accounts-receivable-invoices/:idGet invoice by ID
PATCH/accounts-receivable-invoices/:idUpdate / transition invoice status
DELETE/accounts-receivable-invoices/:idDelete a draft invoice

Query Parameters (list endpoint)

ParamDescription
businessIdFilter by business UUID
customerIdFilter by customer UUID
searchSearch across documentNumber, terms, notes, entityType, customer taxId / taxName / taxAddress
pagePage number (1-based)
sizeResults per page
orderByField to sort by (e.g. entityType)
orderasc or desc

Error Responses

Error CodeHTTP StatusDescription
CREDIT_LIMIT_EXCEEDED400Customer's open AR + new invoice exceeds credit limit
INVALID_STATUS_TRANSITION400Requested status change is not allowed from the current status
INVOICE_LOCKED400Non-draft invoice cannot be edited (unless Owner)
SECOND_APPROVER_REQUIRED400Two-level approval requires a different user for second approval
Not found404Invoice 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

EventWhen emittedConsumed by
accountsReceivableInvoice.createAfter invoice created(external listeners)
accountsReceivableInvoice.updateAfter invoice updated(external listeners)
accountsReceivableReceipt.createEmitted by receipts moduleThis module — applies payment to invoice
accountsReceivableReceipt.updateEmitted by receipts moduleThis module — applies or reverses payment
sale.createEmitted by sales moduleThis module — auto-creates invoice for credit payment methods
sale.updateEmitted by sales moduleThis 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

ItemPriorityNotes
Move findApprovalRule to typed Kysely queryMediumRun pnpm run generate:types first
Move credit limit check inside the create transactionLowPrevents TOCTOU race on concurrent invoice creation
Enable RolesGuard on all endpointsHighCurrently commented out; re-enable once permissions are mapped