Skip to content

Implement Stateful Accounting Domain with Balance Tracking #49

@bartul

Description

@bartul

Summary

Transform the Accounting bounded context from a stateless auto-approve skeleton into a realistic payment system with nation balance tracking, payment validation, transaction history, and idempotent charge processing.

Related decision: #76
Related follow-up: #131

Motivation

The current Accounting implementation auto-approves all charges immediately, which:

  • Doesn't test the RondelInvoicePaymentFailed path in Rondel
  • Provides no game realism (nations can make unlimited paid moves)
  • Misses the opportunity to validate cross-BC communication patterns
  • Does not protect against duplicate charge delivery in future at-least-once messaging

A stateful Accounting domain enables:

  • Realistic game economics with treasury constraints
  • Testing of payment failure flows end-to-end
  • Foundation for future Gameplay domain (nation starting funds)
  • BillingId-based idempotency for duplicate charge and void command delivery

Current State

// Accounting.fs - Current skeleton implementation
let internal chargeNationForRondelMovement
    (deps: AccountingDependencies)
    (cmd: ChargeNationForRondelMovementCommand)
    : Async<unit> =
    async {
        // Auto-approve: immediately publish paid event
        let event: RondelInvoicePaidEvent =
            { GameId = cmd.GameId
              BillingId = cmd.BillingId }
        do! deps.Publish(RondelInvoicePaid event)
    }

Proposed Implementation

1. Domain Types (Accounting.fsi)

Add state and dependency types following the Rondel pattern:

// ──────────────────────────────────────────────────────────────────────────
// Domain State
// ──────────────────────────────────────────────────────────────────────────

/// Tracks a nation's financial state within a game.
type NationBalance = {
    Nation: string
    /// Current available funds in the treasury.
    Balance: Amount
}

/// Persistent state for the Accounting bounded context.
type AccountingState = {
    GameId: Id
    /// Map of nation name to current balance.
    Balances: Map<string, Amount>
    /// Processed charge decisions keyed by BillingId for idempotency.
    ProcessedCharges: Map<Id, ProcessedCharge>
}

/// A terminal accounting decision for one BillingId.
and ProcessedCharge = {
    BillingId: Id
    Nation: string
    Amount: Amount
    Outcome: ProcessedChargeOutcome
}

and ProcessedChargeOutcome =
    | Paid
    | Failed
    | Voided

// ──────────────────────────────────────────────────────────────────────────
// Dependencies
// ──────────────────────────────────────────────────────────────────────────

/// Load accounting state for a game.
type LoadAccountingState = Id -> Async<AccountingState option>

/// Save accounting state after processing.
type SaveAccountingState = AccountingState -> Async<Result<unit, string>>

/// Unified dependencies for all Accounting handlers.
type AccountingDependencies = {
    Load: LoadAccountingState
    Save: SaveAccountingState
    Publish: PublishAccountingEvent
}

2. Contract Types (Contract.Accounting.fs)

Add serializable state for persistence:

/// Serializable accounting state for persistence.
type AccountingState = {
    GameId: Guid
    /// Nation name -> balance amount
    Balances: Map<string, int>
    /// BillingId -> processed charge decision
    ProcessedCharges: Map<Guid, ProcessedCharge>
}

and ProcessedCharge = {
    BillingId: Guid
    Nation: string
    Amount: int
    Outcome: string
}

/// Command to initialize nation balances for a new game.
type InitializeNationBalancesCommand = {
    GameId: Guid
    /// Nation name -> starting balance
    InitialBalances: Map<string, int>
}

3. New Command: Initialize Nation Balances

Add command for setting up initial treasury:

// Domain command
type InitializeNationBalancesCommand = {
    GameId: Id
    InitialBalances: Map<string, Amount>
}

// Add to AccountingCommand DU
type AccountingCommand =
    | InitializeNationBalances of InitializeNationBalancesCommand
    | ChargeNationForRondelMovement of ChargeNationForRondelMovementCommand
    | VoidRondelCharge of VoidRondelChargeCommand

4. Handler Implementation Notes

chargeNationForRondelMovement should be idempotent by BillingId.

If the billing ID already has a processed decision:

  • do not deduct balance again;
  • either publish the same outcome again or no-op, but choose and test one behavior explicitly;
  • never change the outcome for the same billing ID.

If the billing ID has not been processed:

  • validate game/nation/balance;
  • deduct balance and record Paid when sufficient funds exist;
  • record Failed when the charge is terminally rejected;
  • publish RondelInvoicePaid or RondelInvoicePaymentFailed based on the recorded outcome.

Failed-charge retry behavior must be explicit. If a billing ID represents one invoice attempt, then failed charges should be terminal and a later retry with the same billing ID should not succeed. If retries may later succeed after funds change, do not record failure as terminal and document the retry rule. The current Rondel model treats a BillingId as one invoice attempt, so terminal failure is the preferred starting point.

voidRondelCharge should also be idempotent by BillingId. Duplicate void commands must be safe. The exact business behavior depends on whether charges are deducted immediately or held pending, but duplicate void delivery must not double-refund or corrupt Accounting state.

Test Plan

Unit Tests (AccountingTests.fs)

Add tests for:

  • initializing balances;
  • idempotent initialization;
  • approving a charge when sufficient funds exist;
  • rejecting a charge when funds are insufficient;
  • deducting balance exactly once for a successful charge;
  • rejecting charges for unknown game/nation;
  • duplicate charge with the same BillingId does not debit twice;
  • duplicate charge with the same BillingId has deterministic event behavior;
  • duplicate void with the same BillingId is safe;
  • failed-charge retry behavior matches the selected business rule.

Integration Tests (Cross-BC Flow)

Add tests for:

  • paid move with sufficient funds completes successfully;
  • paid move with insufficient funds is rejected;
  • voided charge does not deduct or refund incorrectly;
  • duplicate Accounting charge delivery cannot produce duplicate balance changes.

Acceptance Criteria

  • AccountingState type with nation balances.
  • Accounting state tracks processed charges or equivalent business idempotency keyed by BillingId.
  • InitializeNationBalances command and handler.
  • chargeNationForRondelMovement validates balance before approving.
  • chargeNationForRondelMovement is idempotent for duplicate delivery of the same BillingId.
  • Duplicate successful charge delivery does not debit twice.
  • Publishes RondelInvoicePaymentFailed on insufficient funds.
  • Failed-charge retry behavior is explicitly specified and tested.
  • voidRondelCharge is safe under duplicate delivery of the same BillingId.
  • Amount module has zero, add, subtract helpers.
  • InMemoryAccountingStore for Terminal app.
  • AccountingHost uses store for persistence.
  • Unit tests for all Accounting handlers.
  • Unit tests for duplicate charge and duplicate void behavior.
  • Integration test for successful paid move flow.
  • Integration test for insufficient funds rejection.
  • Integration test proving duplicate Accounting charge delivery does not debit twice.

Dependencies

Files to Modify/Create

File Action
src/Imperium/Primitives.fs Add Amount helpers
src/Imperium/Contract.Accounting.fs Add state contracts
src/Imperium/Accounting.fsi Add state, dependencies, commands
src/Imperium/Accounting.fs Implement stateful/idempotent handlers
src/Imperium.Terminal/Accounting/Store.fs Create
src/Imperium.Terminal/Accounting/Host.fs Update to use store
src/Imperium.Terminal/Program.fs Wire up accounting store
tests/Imperium.UnitTests/AccountingTests.fs Add balance and idempotency tests
tests/Imperium.UnitTests/IntegrationTests.fs Create if needed for cross-BC flows

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions