Skip to content

ADR-0003. Voucher idempotency via partial unique indexes

FieldValue
StatusAccepted
Date2026-05-06
Decidersfinance-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:

KeyIndex conditionUsed 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 NULLper-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

ProsCons
Redelivery is safe — no double postingProducers 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 replicasTwo dedup strategies to reason about (per-source vs per-event)
Voided/deleted rows never block a legitimate re-postIndex conditions are subtle — must stay in sync with FinanceVoucherSourceTypes.isDedupable*
Split tenders post one RECEIPT per attempt, correctly

Alternatives Considered

OptionProsConsWhy rejected
App-only dedup (no DB index)Simpler schemaRaces between consumer replicas can double-postNot safe under horizontal scaling
Single global dedup key for all sourcesOne ruleBreaks split tenders (one key per order)Wrong for multi-payment orders
External dedup store (Redis set)DecoupledExtra infra; consistency window vs the DB writeDB 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

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