A configurable, event-sourced state machine library for modelling the complete payment lifecycle — from authorization through settlement, refunds, chargebacks, network reversals, and dispute arbitration. Enforces valid state transitions, emits domain events, maintains an append-only audit trail, and generates double-entry ledger entries.
Companion project to Payments Engineering blog series.
Every fintech team building payment infrastructure ends up implementing some version of:
- A payment status enum that grows organically until it's unmaintainable
if/elseblocks checking "can I transition from X to Y?"- An audit trail bolted on after the first compliance audit
- Exception handling code (chargebacks, reversals) in separate services with no relationship to the core payment model
This library provides a correct, opinionated, payments-specific state machine that treats exceptions as first-class state transitions — not error handlers.
# Core: Payment aggregate, events, transitions, money type
dotnet add package PaymentStateMachine
# Event sourcing: Event store integration
dotnet add package PaymentStateMachine.EventSourcing
# EF Core: Snapshot persistence
dotnet add package PaymentStateMachine.EntityFrameworkPlanned:
PaymentStateMachine.EventSourcing.EntityFramework— a full EF Core-backedIEventStoreimplementation for production event sourcing. Track progress in [issues].
using PaymentStateMachine.Core;
// Create a payment
var payment = new Payment(new Money(99.99m, Currency.USD), "order-123");
// Process lifecycle
payment.Authorize("AUTH-001", "provider-ref-001");
payment.Capture();
payment.Settle(new Money(99.99m, Currency.USD), "SET-001", DateTimeOffset.UtcNow);
// Invalid transitions throw with context
try { payment.Authorize("AUTH-002", "ref-002"); }
catch (InvalidStateTransitionException ex)
{
// "Cannot transition from Settled to Authorized.
// Valid transitions from Settled: Refunded, PartiallyRefunded,
// ChargebackInitiated, SettlementAdjusted, NetworkReversed, Unknown"
}Created → Authorized → Captured → Settled
│ │ │
Void/Expire/ CaptureRev Refund/PartialRefund/
AuthReversed Chargeback/NetworkReversal/
SettlementAdjusted
Any non-terminal → Unknown → Resolve to valid state
Currency-aware money type with ISO 4217 precision. JPY has 0 decimal places, KWD has 3 — the library handles this correctly.
var usd = new Money(10.50m, Currency.USD); // $10.50
var jpy = new Money(1000m, Currency.JPY); // ¥1,000
var kwd = new Money(1.500m, Currency.KWD); // 1.500 KWD
// Safe arithmetic (throws on currency mismatch)
var total = usd + new Money(5.25m, Currency.USD); // $15.75Chargebacks are first-class entities with their own lifecycle and exposure cap enforcement:
payment.InitiateChargeback("CB-001", "10.4", disputedAmount, responseDeadline);
// Enforces exposure cap: disputed amount cannot exceed remaining economic exposure
// (settled - refunded - prior chargebacks). Configurable via IPaymentInvariantPolicy.
payment.WinChargeback("CB-001", "Receipt evidence");
// or
payment.LoseChargeback("CB-001", chargebackFee);
// Split accounting: disputed amount vs. network fees
payment.ChargebackDisputedTotal // sum of disputed amounts (lost + accepted)
payment.ChargebackFeeTotal // sum of network fees (lost only)Full lifecycle support for capture reversals (acquirer-initiated reversal of a captured but unsettled payment):
payment.Capture();
payment.ReverseCapture("Fraud detected");
// State → CaptureReversed (terminal)
// Generates ledger entries: Debit CaptureReversalLosses, Credit AccountsReceivableEvery state change is recorded as an immutable event. Payments can be reconstituted from their event stream with optimistic concurrency control:
var eventStore = new PaymentEventStore(new InMemoryEventStore());
// Save (uses expectedVersion for optimistic concurrency)
await eventStore.SaveAsync(payment);
// Load and continue
var loaded = await eventStore.LoadAsync(paymentId);
loaded.Refund(amount, reason, refundRef);
await eventStore.SaveAsync(loaded);
// Throws ConcurrencyException if another writer modified the streamAutomatic double-entry bookkeeping for every financial event:
var ledger = new DefaultLedgerEntryGenerator();
var entries = ledger.GenerateEntries(captureEvent, payment);
// → Debit: Accounts Receivable, Credit: Revenue// Allow re-authorization for subscription payments
var validator = new TransitionValidatorBuilder()
.ExtendDefault()
.From(PaymentState.Expired).To(PaymentState.Authorized)
.Build();
var payment = new Payment(amount, "order-ref", validator);Non-negotiable invariants (positive amounts, currency match, non-empty strings) are always enforced. Variable business rules can be customized via IPaymentInvariantPolicy:
// Default: StrictPaymentInvariantPolicy rejects over-capture, duplicate refund refs,
// over-reversal, and over-dispute
var payment = new Payment(amount, "order-ref");
// Custom policy: allow over-capture or over-dispute for your business model
var payment = new Payment(amount, "order-ref", policy: myCustomPolicy);Policy methods:
AllowOverCapture— capture exceeding authorized amountAllowOverReversal— network reversal exceeding settled amountAllowDuplicateRefundReference— reuse of a refund referenceAllowOverDispute— chargeback exceeding remaining economic exposure
A read model for operations teams:
var view = new PaymentInvestigationView(payment);
// Timeline, financial position, open exceptions, chargebacks, refundsGeneric-typed snapshot persistence for any DbContext:
services.AddPaymentStateMachineEntityFramework<MyPaymentDbContext>();The library distinguishes between event time and business date for settlements:
payment.Settle(settledAmount, "SET-001", settlementBusinessDate);
payment.SettledAt // event timestamp (when the event was recorded)
payment.SettlementBusinessDate // acquirer/network business date (T+2 cycle date)- Network reversal after refund: Terminal state prevents double-reversal
- Void timeout treated as Unknown: Not silently treated as success
- Settlement amount differs from capture: Authorization != settlement
- Chargeback on partially refunded payment: Complex exception intersection
- Chargeback exposure cap: Prevents disputed amount from exceeding remaining economic exposure
- Capture reversal: Full lifecycle from Captured to CaptureReversed with ledger entries
- Currency precision: JPY (0 decimals), USD (2), KWD (3)
MIT