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
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 |
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:
RondelInvoicePaymentFailedpath in RondelA stateful Accounting domain enables:
BillingId-based idempotency for duplicate charge and void command deliveryCurrent State
Proposed Implementation
1. Domain Types (Accounting.fsi)
Add state and dependency types following the Rondel pattern:
2. Contract Types (Contract.Accounting.fs)
Add serializable state for persistence:
3. New Command: Initialize Nation Balances
Add command for setting up initial treasury:
4. Handler Implementation Notes
chargeNationForRondelMovementshould be idempotent byBillingId.If the billing ID already has a processed decision:
If the billing ID has not been processed:
Paidwhen sufficient funds exist;Failedwhen the charge is terminally rejected;RondelInvoicePaidorRondelInvoicePaymentFailedbased 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
BillingIdas one invoice attempt, so terminal failure is the preferred starting point.voidRondelChargeshould also be idempotent byBillingId. 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:
BillingIddoes not debit twice;BillingIdhas deterministic event behavior;BillingIdis safe;Integration Tests (Cross-BC Flow)
Add tests for:
Acceptance Criteria
AccountingStatetype with nation balances.BillingId.InitializeNationBalancescommand and handler.chargeNationForRondelMovementvalidates balance before approving.chargeNationForRondelMovementis idempotent for duplicate delivery of the sameBillingId.RondelInvoicePaymentFailedon insufficient funds.voidRondelChargeis safe under duplicate delivery of the sameBillingId.Amountmodule haszero,add,subtracthelpers.InMemoryAccountingStorefor Terminal app.AccountingHostuses store for persistence.Dependencies
Files to Modify/Create
src/Imperium/Primitives.fssrc/Imperium/Contract.Accounting.fssrc/Imperium/Accounting.fsisrc/Imperium/Accounting.fssrc/Imperium.Terminal/Accounting/Store.fssrc/Imperium.Terminal/Accounting/Host.fssrc/Imperium.Terminal/Program.fstests/Imperium.UnitTests/AccountingTests.fstests/Imperium.UnitTests/IntegrationTests.fs