Skip to content

lawale/payment-state-machine

Repository files navigation

PaymentStateMachine.NET

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.

Why

Every fintech team building payment infrastructure ends up implementing some version of:

  • A payment status enum that grows organically until it's unmaintainable
  • if/else blocks 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.

Installation

# 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.EntityFramework

Planned: PaymentStateMachine.EventSourcing.EntityFramework — a full EF Core-backed IEventStore implementation for production event sourcing. Track progress in [issues].

Quick Start

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"
}

State Diagram

Created → Authorized → Captured → Settled
              │            │          │
         Void/Expire/  CaptureRev  Refund/PartialRefund/
         AuthReversed              Chargeback/NetworkReversal/
                                   SettlementAdjusted

Any non-terminal → Unknown → Resolve to valid state

Features

Money Value Type

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.75

Chargeback Workflow

Chargebacks 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)

Capture Reversal

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 AccountsReceivable

Event Sourcing

Every 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 stream

Ledger Entry Generation

Automatic double-entry bookkeeping for every financial event:

var ledger = new DefaultLedgerEntryGenerator();
var entries = ledger.GenerateEntries(captureEvent, payment);
// → Debit: Accounts Receivable, Credit: Revenue

Custom Transition Rules

// 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);

Domain Invariant Policy

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 amount
  • AllowOverReversal — network reversal exceeding settled amount
  • AllowDuplicateRefundReference — reuse of a refund reference
  • AllowOverDispute — chargeback exceeding remaining economic exposure

Investigation View

A read model for operations teams:

var view = new PaymentInvestigationView(payment);
// Timeline, financial position, open exceptions, chargebacks, refunds

EF Core Integration

Generic-typed snapshot persistence for any DbContext:

services.AddPaymentStateMachineEntityFramework<MyPaymentDbContext>();

Settlement Dates

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)

Edge Cases Handled

  • 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)

License

MIT

About

A configurable, event-sourced state machine library for modelling the complete payment lifecycle

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages