Skip to content

PRD: Event-driven finance posting (sale → GL seam)

ModuleFinance (CORE-12)PRD IDPRD-EVT-001
StatusShippedOwnerFinance squad
Date2026-06-15Versionv1.0
Packages@nx/finance · @nx/coreURDEVT · 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

MetricTarget / signal
Non-blockingTaking a payment never waits on a voucher write; the sale completes regardless of Finance latency
Posting coverageEvery money-relevant event that should book a voucher produces exactly one
IdempotencyA redelivered event never creates a second voucher - it replays the first
DurabilityNo money-relevant event is lost on a handler crash; it is redelivered and posted
TraceabilityEvery auto-posted voucher links back to its source document and originating event
Clean short-circuitEvents with nothing to book leave no voucher and no error

4. Personas & Use Cases

PersonaGoal in this feature
Owner / AccountantTrust that sales, purchases, and stock movements land in the books automatically and completely
CashierTake 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-callRely 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

#RequirementURD ref
FR-1Finance consumes operational events asynchronously off a message stream; the originating action is never blocked on a postingURD-EVT-001
FR-2Subscribe to: sale payment succeeded, purchase order received, inventory issued for sale, inventory adjusted, and merchant createdURD-EVT-002
FR-3A succeeded sale payment posts a receipt voucher; a change-return payment voucher posts when cash tendered exceeds the order totalURD-EVT-003 · URD-VCH-003
FR-4A received purchase order posts a vendor payment voucher, adding an inventory asset leg when the received value is positiveURD-EVT-004 · URD-VCH-004
FR-5Stock issued for a sale posts a cost-of-goods voucher (COGS against the inventory control account) when the cost basis is positiveURD-EVT-005 · URD-VCH-005
FR-6An operator-driven inventory value change posts a single-line inventory adjustment voucher against the inventory control accountURD-EVT-006 · URD-VCH-005
FR-7A new merchant seeds its default and internal control accounts from the merchant lifecycle eventURD-EVT-007 · URD-WAL-003
FR-8Every automatic posting is idempotent - a redelivered event replays the existing voucher, never a second oneURD-EVT-008 · URD-VCH-006
FR-9An event is acknowledged only after its posting commits; a failed handler leaves the event for redelivery (at-least-once)URD-EVT-009
FR-10Events carry only identifiers; the worker resolves the full source and party identity (vendor, PO code, accounts) before postingURD-EVT-010 · URD-VCH-007
FR-11Postings 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-12The consumer drains in-flight work and closes cleanly on a shutdown signalURD-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

AreaRequirement
DecouplingPosting is fully asynchronous off the event stream; no operational action waits on a Finance write
Delivery guaranteeAt-least-once - an event is committed only after its handler succeeds; a crash redelivers it
IdempotencyPostings are keyed off the originating event's unique identifier (or its source document); a redelivery replays the existing voucher
Data integrityA voucher and its ledger lines are written together and balance; no partial posting survives a failure
Tenancy & authzEvery posting is merchant-scoped; the worker resolves accounts within the event's merchant
ResilienceA missing control account or unresolved source raises an error so the event is redelivered, not silently dropped
PrecisionMonetary math uses float(value, 4); single currency per voucher, default VND
i18nVoucher 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

ConceptRole in the seam
Operational eventA published fact (payment succeeded, PO received, stock issued, inventory adjusted, merchant created) carrying identifiers, not full records
Finance consumerThe dedicated subscriber that reads each event, routes it to a handler, posts, then acknowledges
Source referenceThe link a posted voucher carries back to its source document (sale order, PO, stock movement)
Event idempotency keyThe originating event's unique identifier used to detect a redelivery and replay the existing voucher
Voucher / LedgerLineThe 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 / questionMitigation / status
A redelivered event could double-postPostings are idempotent - keyed off the event id; a redelivery replays the existing voucher
A handler crash could lose a money eventAt-least-once - the event is acknowledged only after the posting commits, so a crash redelivers it
Synchronous posting would block the saleRejected by design - posting is fully asynchronous off the event stream
A control account missing when a COGS/adjustment event arrivesThe handler raises rather than silently skipping, so the event is redelivered once accounts exist
Events with nothing to bookDeliberately short-circuited (no finance routing, zero cost, zero delta, no change) - logged, no voucher, no error
Poison event that always failsRedelivery is bounded by operations; persistent failures surface in the error log for review

12. Release Plan & Launch Criteria

AspectPlan
PhaseP1 (foundation) - EVT in the URD feature catalog, alongside VCH and WAL
RolloutAll merchants; no feature flag - the seam is always on
MigrationNone - the consumer subscribes to events already published by sale, payment, and inventory
Launch criteriaA 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
MonitoringPer-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

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.