ADR-0003. Voucher idempotency via partial unique indexes
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-06 |
| Deciders | finance-team |
| Supersedes | — |
Context
- Kafka delivery is at-least-once; the finance consumer commits offsets only after a handler succeeds (
autocommit=false). A crash after posting but before commit causes redelivery. - Without dedup, redelivery would double-post vouchers and corrupt account balances.
- Different sources need different dedup keys: a sale order can legitimately receive multiple split-tender payments (dedup per payment event), while a purchase order should book exactly one PAYMENT voucher (dedup per source document).
- Voided documents must not block a later legitimate re-post of the same source.
Decision
We will enforce idempotency in the database via partial unique indexes on FinanceVoucher, backed by an application-level replay check:
| Key | Index condition | Used by |
|---|---|---|
(merchantId, type, sourceType, sourceId) | live, non-VOIDED, sourceType NOT IN (MANUAL, POS_SESSION, SALE_ORDER) | per-source dedup (e.g. PURCHASE_ORDER) |
(sourceType, sourceEventUid) | live, non-VOIDED, sourceEventUid IS NOT NULL | per-event dedup (SALE_ORDER attempt.uid, INVENTORY_ADJUSTMENT inventoryTrackingId) |
Before posting, FinanceVoucherService.tryIdempotentReplay looks up the existing voucher by the applicable key and, on hit, returns it (with its ledger lines) instead of posting again. The index is the hard backstop; the app check avoids relying on a unique-violation round-trip. All conditions are scoped deletedAt IS NULL AND status <> 'VOIDED'.
Consequences
| Pros | Cons |
|---|---|
| Redelivery is safe — no double posting | Producers must populate the right key (sourceEventUid for per-event sources; validated, throws if missing) |
| DB is the source of truth even under races between consumer replicas | Two dedup strategies to reason about (per-source vs per-event) |
| Voided/deleted rows never block a legitimate re-post | Index conditions are subtle — must stay in sync with FinanceVoucherSourceTypes.isDedupable* |
| Split tenders post one RECEIPT per attempt, correctly | — |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| App-only dedup (no DB index) | Simpler schema | Races between consumer replicas can double-post | Not safe under horizontal scaling |
| Single global dedup key for all sources | One rule | Breaks split tenders (one key per order) | Wrong for multi-payment orders |
| External dedup store (Redis set) | Decoupled | Extra infra; consistency window vs the DB write | DB partial index is atomic with the post |
References
packages/core/src/models/schemas/finance/finance-voucher/schema.ts(partial unique indexes)packages/core/src/models/schemas/finance/finance-voucher/constants.ts(FinanceVoucherSourceTypes.isDedupable,isDedupablePerSourceEvent)packages/core/src/services/finance/finance-voucher.service.ts(tryIdempotentReplay,_lookupExistingForReplay)- API Events — Idempotency & Ordering