Saltar al contenido principal

Accounts Receivable Receipts

Overview

An AR Receipt records a payment received from a customer against one or more AR Invoices. It tracks how much was paid, through which payment methods, on which date, and in which currency.

Receipts are the financial event that reduces the outstanding balance (balanceDue) on one or more AR Invoices.


Domain Concepts

ConceptDescription
ReceiptA payment event that settles all or part of one or more AR invoices
detailJSON array of { accountsReceivableInvoiceId, amount, baseAmount } — which invoices are being paid
paymentDetailJSON array of { paymentMethodId, amount, baseAmount, ... } — how the payment was made
documentNumberAuto-generated sequential identifier (e.g. ARR-000042)
statusposted (active) or void (cancelled)
exchangeRateFX rate when the receipt currency differs from the invoice currency
notesOptional internal memo attached to the receipt

Receipt Lifecycle

Created (posted) ──► Voided (void)
  • Receipts are created as posted by default.
  • The only valid status transition is posted → void.
  • Voiding sets voidedAt and voidedBy automatically.
  • Deletion is only allowed for receipts with neither posted nor void status (edge cases / legacy data). For posted receipts, use void instead.

Validation Rules

All rules are enforced on create and on any update that modifies detail or paymentDetail:

RuleError CodeDescription
At least one invoice item requiredRECEIPT_ITEMS_REQUIREDdetail.items must not be empty
Invoice must existINVOICE_NOT_FOUNDEach referenced invoice must exist in the DB
Invoice status must be payableINVOICE_STATUS_NOT_APPROVEDInvoice status must be submitted, approved, or scheduled
Total must equal sum of itemsTOTAL_AMOUNT_MISMATCHtotalAmount must equal the sum of all detail.items[].amount (rounded to minorUnit)
No overpayment per invoiceOVERPAYMENTApplied amount per invoice must not exceed invoice.balanceDue
FX rate required for cross-currencyFX_REQUIRED_FOR_CROSS_CURRENCYWhen invoice currency ≠ receipt currency, exchangeRate must be > 0
Payment method must be activePAYMENT_METHOD_INACTIVEAll paymentDetail.items[].paymentMethodId must be active

Module Structure

accounts-receivable-receipts/
├── accounts-receivable-receipts.module.ts
├── application/
│ ├── accounts-receivable-receipts.service.ts # All use cases + validation
│ ├── accounts-receivable-receipts.service.spec.ts
│ └── events/
│ ├── on-create-accounts-receivable-receipt.event.ts
│ └── on-update-accounts-receivable-receipt.event.ts
├── domain/
│ └── accounts-receivable-receipts-repository.domain.ts # Port interface
├── infrastructure/
│ └── accounts-receivable-receipts.repository.ts # Kysely adapter
└── interfaces/
├── accounts-receivable-receipts.controller.ts
├── dtos/
│ ├── create-accounts-receivable-receipt.dto.ts
│ └── update-accounts-receivable-receipt.dto.ts
└── query/
└── paginate-accounts-receivable-receipts.query.ts

Authorization

All endpoints require a valid Firebase bearer token. Access is controlled via RolesGuard with resource AccountsReceivableReceipt and per-endpoint CASL actions (Create, Read, Update, Delete).


API Endpoints

MethodPathActionDescription
POST/accounts-receivable-receiptsCreateCreate a new receipt
GET/accounts-receivable-receiptsReadList receipts (paginated, filterable by businessId, customerId)
GET/accounts-receivable-receipts/with-invoice-itemsReadList receipts expanded with per-invoice payment details (via DB view)
GET/accounts-receivable-receipts/:idReadGet single receipt (404 if not found)
GET/accounts-receivable-receipts/:id/pdfReadDownload receipt as PDF (404 if not found)
GET/accounts-receivable-receipts/:id/printReadPreview receipt as HTML (404 if not found)
PATCH/accounts-receivable-receipts/:idUpdateUpdate / void a receipt (404 if not found)
DELETE/accounts-receivable-receipts/:idDeleteDelete receipt (only non-posted, non-void; 404 if not found)

Supported orderBy values

paymentDate · totalAmount · documentNumber · status · createdAt


Create Receipt — Example Payload

{
"customerId": "<uuid>",
"status": "posted",
"currencyId": "<uuid>",
"currencyCode": "GTQ",
"minorUnit": 2,
"exchangeRate": 1.00,
"currency": { "id": "<uuid>", "name": "Quetzal", "symbol": "Q" },
"totalAmount": 112.00,
"totalBaseAmount": 112.00,
"paymentDate": "2026-03-12",
"businessId": "<uuid>",
"createdBy": "<uuid>",
"detail": {
"items": [
{
"accountsReceivableInvoiceId": "<uuid>",
"amount": 112.00,
"baseAmount": 112.00
}
]
},
"paymentDetail": {
"items": [
{
"paymentMethodId": "<uuid>",
"paymentMethodName": "Cash",
"amount": 112.00,
"baseAmount": 112.00,
"currencyId": "<uuid>",
"currencyCode": "GTQ",
"minorUnit": 2,
"exchangeRate": 1.00
}
]
},
"notes": "Optional memo"
}

Void Receipt — Example Payload

{
"status": "void",
"updatedBy": "<uuid>"
}

Events

EventTriggerPayload
accountsReceivableReceipt.createReceipt createdFull receipt record
accountsReceivableReceipt.updateReceipt updated or voidedOriginal + updated records

Database

Table: accounts_receivable_receipt View: vw_accounts_receivable_receipt_details — denormalizes receipt + invoice payment mapping (one row per invoice line)

Key columns

ColumnTypeNotes
idUUIDAuto-generated
document_numbertextSequential (e.g. ARR-000001), generated in transaction
statusenumposted | void
detailjsonbInvoice payment items
payment_detailjsonbPayment method items
voided_at / voided_bytimestamp/uuidSet automatically on void

Design Decisions

  1. detail and paymentDetail are JSON columns — Both the invoice line allocation and the payment method breakdown are stored as JSON, consistent with other financial document modules in this codebase. This avoids additional join tables for simple cases and simplifies the document structure.

  2. GET /with-invoice-items uses a DB viewvwAccountsReceivableReceiptDetails is a PostgreSQL view that unnests the detail JSON into relational rows, enabling efficient filtering by accountsReceivableInvoiceId at the DB level.

  3. GET / does not support accountsReceivableInvoiceId filtering — The invoice ID is stored inside the detail JSON column, not as a relational column. Only GET /with-invoice-items (which queries the view) supports this filter.

  4. Transactions used for mutations only — Create and update operations wrap DB writes inside Kysely transactions to ensure atomicity. Read operations (findAll, findById) execute directly without transactions.

  5. Voiding never deletes — Voids preserve the full audit trail. Only use DELETE for receipts that were never properly committed (no POSTED or VOID status).