Saltar al contenido principal

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
SalesModuleCancels the linked uncertified sale when an invoice is voided (SALES_REPOSITORY only)

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. If a linked sale exists, it is cancelled in the same transaction unless a guard rejects the void (see below).

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"
}

Voiding is one-way and also cancels the linked commercial sale when it is safe:

  • POS credit invoices: entityType === "sale" → sale is entityId
  • Platform / subscription billing: sale is saleId

The whole void is rejected (invoice unchanged) when:

CodeReason
INVOICE_VOID_SALE_FEL_CERTIFIEDThe sale has a valid DTE, or FEL was requested and is not failed. Use FEL anulación (same month) or a credit note.
INVOICE_VOID_RECEIPTS_EXISTPosted receipts exist (balanceDue < totalAmount or detail.items with accountsReceivableInvoiceId). Commercial/platform billing lines in detail.items do not count. Void receipts first.
INVOICE_VOID_OTHER_INVOICES_EXISTAnother non-void AR invoice links to the same sale.

The sale row is loaded FOR UPDATE scoped to invoice.businessId. If there is no linked sale, or the sale is already cancelled, only the invoice is voided.

Voiding cancels the sale; it does not unlink it. entityId / saleId stay on the invoice, and saleDocumentNumber (join of the sale document number) remains available so the invoices list can still open the sale.

FEL anulación of a sale does not void the AR invoice.


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.updateSales module, or this module after voiding a linked uncertified saleThis module (draft → submitted auto-create); inventory restock; store-credit reverse

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