PRD: Event-driven finance posting (sale → GL seam)
| Module | Finance (CORE-12) | PRD ID | PRD-EVT-001 |
| Status | Shipped | Owner | Finance squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/finance · @nx/core | URD | EVT · VCH · WAL |
TL;DR
Finance does not record routine money flows by hand and does not wedge itself into the sale's critical path. Instead it subscribes to the operational events that already happen - a sale payment succeeds, a purchase order is received, stock is issued for a sale, an inventory value changes, a merchant is created - and books each one as a balanced voucher off a message stream, asynchronously. The seam is at-least-once: an event is acknowledged only after its posting commits, so a crash replays it rather than losing it; and every posting is idempotent - a redelivered event replays the voucher it already created instead of double-booking. The originating sale, purchase, or stock action never waits for the books to catch up.
1. Context & Problem
The voucher engine (PRD-VCH-001) defines what a balanced voucher is and how money accounts are seeded. It does not define how the rest of the system tells Finance that something happened. If posting were synchronous - the sale calling Finance inline and waiting - a slow or failing ledger write would slow down or block taking the customer's money. That is unacceptable at the point of sale.
Coupling the books to the checkout request also makes both sides brittle: a Finance outage would stall sales, and a retried payment request would risk double-posting revenue. The merchant needs the books to be complete and correct eventually, not synchronously and fragilely.
This increment specifies the seam that closes that gap: operational services publish events, and a dedicated Finance consumer reads them out-of-band, resolves each event to its accounts and party, and posts the matching voucher - reliably (no event silently lost), idempotently (no event double-booked), and without ever blocking the action that produced it.
2. Goals & Non-Goals
Goals
- Post finance vouchers asynchronously from operational events, never on the originating action's critical path.
- Subscribe to the full set of money-relevant events: sale payment succeeded, purchase order received, inventory issued for sale, inventory adjusted, merchant created.
- Guarantee at-least-once processing: acknowledge an event only after its posting commits; a failed handler leaves the event for redelivery.
- Make every automatic posting idempotent - a redelivered event replays the existing voucher rather than creating a second one.
- Resolve each event - which carries only identifiers - to its accounts, category, party, and human-readable source reference before posting.
- Short-circuit cleanly when there is nothing to book (no finance routing, zero cost basis, zero value delta, no change due) - no empty or unbalanced voucher.
Non-Goals
- The voucher/ledger model itself - balancing, account types, numbering, void-by-reversal - specified in PRD-VCH-001.
- Synchronous, request-time posting - explicitly rejected; posting is event-driven.
- Stock quantity and costing math (Inventory) - Finance consumes the resulting cost basis, it does not compute it.
- Payment-gateway processing (Payment) - Finance reacts to a succeeded payment, it does not authorize one.
- Partner ledger, receivables/payables, and P&L (LDG) - a separate increment.
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Non-blocking | Taking a payment never waits on a voucher write; the sale completes regardless of Finance latency |
| Posting coverage | Every money-relevant event that should book a voucher produces exactly one |
| Idempotency | A redelivered event never creates a second voucher - it replays the first |
| Durability | No money-relevant event is lost on a handler crash; it is redelivered and posted |
| Traceability | Every auto-posted voucher links back to its source document and originating event |
| Clean short-circuit | Events with nothing to book leave no voucher and no error |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Accountant | Trust that sales, purchases, and stock movements land in the books automatically and completely |
| Cashier | Take payment instantly - the books catching up is invisible and never slows the sale |
| System (Finance consumer) | Read each operational event, resolve it, and post the right voucher exactly once |
| Ops / On-call | Rely on redelivery for transient failures; review the deliberate skip cases in the log |
Core scenario: a cashier takes payment → the sale publishes a payment-succeeded event and the order completes immediately → the Finance consumer reads the event off the stream, resolves the routed account and the Sale category, and posts a balanced receipt voucher linked to the order → if the consumer crashes mid-post, the event is redelivered and posts once → if the same event is delivered twice, the second delivery replays the existing voucher instead of double-booking.
5. User Stories
- As a cashier, I want payment to complete instantly, so a slow or down ledger never holds up the customer.
- As an accountant, I want a paid sale, a received PO, and issued stock to post their vouchers automatically, so the books stay complete without manual entry.
- As an owner, I want a momentary Finance outage to catch up afterward rather than lose entries, so my books are never missing a sale.
- As the system, I want a replayed event to replay its existing voucher, so a retry or redelivery never double-books revenue or cost.
- As on-call, I want events with nothing to book to be skipped quietly and logged, so I am not paged for non-events.
- As an accountant, I want each auto-posted voucher linked to its source order, PO, or movement, so I can trace any posting back to what caused it.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | Finance consumes operational events asynchronously off a message stream; the originating action is never blocked on a posting | URD-EVT-001 |
| FR-2 | Subscribe to: sale payment succeeded, purchase order received, inventory issued for sale, inventory adjusted, and merchant created | URD-EVT-002 |
| FR-3 | A succeeded sale payment posts a receipt voucher; a change-return payment voucher posts when cash tendered exceeds the order total | URD-EVT-003 · URD-VCH-003 |
| FR-4 | A received purchase order posts a vendor payment voucher, adding an inventory asset leg when the received value is positive | URD-EVT-004 · URD-VCH-004 |
| FR-5 | Stock issued for a sale posts a cost-of-goods voucher (COGS against the inventory control account) when the cost basis is positive | URD-EVT-005 · URD-VCH-005 |
| FR-6 | An operator-driven inventory value change posts a single-line inventory adjustment voucher against the inventory control account | URD-EVT-006 · URD-VCH-005 |
| FR-7 | A new merchant seeds its default and internal control accounts from the merchant lifecycle event | URD-EVT-007 · URD-WAL-003 |
| FR-8 | Every automatic posting is idempotent - a redelivered event replays the existing voucher, never a second one | URD-EVT-008 · URD-VCH-006 |
| FR-9 | An event is acknowledged only after its posting commits; a failed handler leaves the event for redelivery (at-least-once) | URD-EVT-009 |
| FR-10 | Events carry only identifiers; the worker resolves the full source and party identity (vendor, PO code, accounts) before posting | URD-EVT-010 · URD-VCH-007 |
| FR-11 | Postings short-circuit with no voucher when there is nothing to book (no finance routing, zero cost basis, zero value delta, no change due) | URD-EVT-011 |
| FR-12 | The consumer drains in-flight work and closes cleanly on a shutdown signal | URD-EVT-012 |
Full requirement text and acceptance criteria live in the Finance URD - EVT. This PRD references them rather than restating them. The voucher/ledger model itself is specified in PRD-VCH-001.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Decoupling | Posting is fully asynchronous off the event stream; no operational action waits on a Finance write |
| Delivery guarantee | At-least-once - an event is committed only after its handler succeeds; a crash redelivers it |
| Idempotency | Postings are keyed off the originating event's unique identifier (or its source document); a redelivery replays the existing voucher |
| Data integrity | A voucher and its ledger lines are written together and balance; no partial posting survives a failure |
| Tenancy & authz | Every posting is merchant-scoped; the worker resolves accounts within the event's merchant |
| Resilience | A missing control account or unresolved source raises an error so the event is redelivered, not silently dropped |
| Precision | Monetary math uses float(value, 4); single currency per voucher, default VND |
| i18n | Voucher reasons and party names are bilingual ({ en, vi }) |
8. UX & Flows
Posting is event-driven, not screen-driven. There is no Finance UI in this increment - the seam runs behind the merchant's ordinary actions.
Topic-to-posting map:
9. Data & Domain
| Concept | Role in the seam |
|---|---|
| Operational event | A published fact (payment succeeded, PO received, stock issued, inventory adjusted, merchant created) carrying identifiers, not full records |
| Finance consumer | The dedicated subscriber that reads each event, routes it to a handler, posts, then acknowledges |
| Source reference | The link a posted voucher carries back to its source document (sale order, PO, stock movement) |
| Event idempotency key | The originating event's unique identifier used to detect a redelivery and replay the existing voucher |
Voucher / LedgerLine | The balanced bookkeeping document and its rows the handler issues - specified in PRD-VCH-001 |
Conceptual only - the event contracts, topics, and consumer wiring live in the finance developer docs; the voucher schema lives in the finance domain model.
10. Dependencies & Assumptions
Depends on
- Voucher engine (PRD-VCH-001, VCH) - the seam issues the vouchers this engine balances and numbers.
- Accounts (WAL) - default and control accounts must be seeded before postings route; the merchant event seeds them.
- Payment (
@nx/core) - publishes the succeeded-payment event that drives the receipt voucher. - Inventory (
@nx/inventory) - publishes PO-received, stock-issued, and inventory-adjusted events. - Commerce / Merchant (
@nx/commerce) - publishes the merchant lifecycle event that triggers account seeding.
Assumptions
- Operational services reliably publish their events; the seam's job is to consume them durably.
- Each event carries enough identifiers (merchant, source document, accounts, amounts/cost basis) to resolve and post.
- The merchant's internal control accounts exist before cost-of-goods and inventory-adjustment postings are attempted.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| A redelivered event could double-post | Postings are idempotent - keyed off the event id; a redelivery replays the existing voucher |
| A handler crash could lose a money event | At-least-once - the event is acknowledged only after the posting commits, so a crash redelivers it |
| Synchronous posting would block the sale | Rejected by design - posting is fully asynchronous off the event stream |
| A control account missing when a COGS/adjustment event arrives | The handler raises rather than silently skipping, so the event is redelivered once accounts exist |
| Events with nothing to book | Deliberately short-circuited (no finance routing, zero cost, zero delta, no change) - logged, no voucher, no error |
| Poison event that always fails | Redelivery is bounded by operations; persistent failures surface in the error log for review |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P1 (foundation) - EVT in the URD feature catalog, alongside VCH and WAL |
| Rollout | All merchants; no feature flag - the seam is always on |
| Migration | None - the consumer subscribes to events already published by sale, payment, and inventory |
| Launch criteria | A paid sale, a received PO, and issued stock each post their voucher asynchronously; a redelivered event posts once; a handler crash redelivers and posts once; events with nothing to book leave no voucher |
| Monitoring | Per-topic posting volume and error rate, idempotent-replay counts, deliberate-skip counts, consumer lag |
13. FAQ
Does taking a payment wait for the books to update? No - the sale completes immediately and publishes an event; Finance posts the voucher afterward, off the stream. The books catching up is invisible to the cashier.
What happens if Finance is down when a sale is paid? The event waits on the stream. When Finance recovers it reads the event and posts the voucher - nothing is lost, because an event is acknowledged only after its posting commits.
Can the same event post twice? No - every posting is idempotent. A redelivered event is detected by its identifier and replays the voucher it already created instead of booking a second one.
Why is a sale posted twice in the log sometimes "skipped"? That is the idempotent replay - the second delivery found the existing voucher and replayed it rather than double-posting.
What events produce no voucher? Ones with nothing to book - a payment with no finance routing, a stock issuance with zero cost basis, an inventory change with zero value delta, or a sale with no change due. These are short-circuited and logged, not errors.
Where is the balancing and numbering logic? In the voucher engine (PRD-VCH-001). This PRD covers the seam that delivers events to that engine reliably and exactly once.
References
- URD: Finance - EVT · Vouchers & Posting · Accounts
- Builds on: Vouchers & posting · Categories
- Module: Finance - overview + traceability
- Upstream: Payment module · Inventory module
- Developer: @nx/finance · @nx/core · domain model