# Domain State Machines — Multi-Wallet

Date: 2026-03-30
Status: approved
Phase: 2 (Domain Contract and ADR Alignment)
Reference: docs/multi-wallet/deliverables/multiwallet-requirements-spec.md

## 1. WalletProduct State Machine

### 1.1 States

| State | Meaning | Terminal? |
|---|---|---|
| `:active` | Operational. Deposits, withdrawals, transfers allowed. | No |
| `:frozen` | All money movement blocked. Balances preserved. | No |
| `:closed` | Terminal freeze. No further operations. Balances preserved forever. | Yes |

Note: Unlike the underlying `Account` model, `WalletProduct` does NOT have `:pending` or `:suspended` states. It is created in `:active` state (the underlying Account creation handles pending/active via its own lifecycle).

### 1.2 Transitions

```
         ┌──────────────────────────────────────────┐
         │                                          │
       active ──── FreezeWalletProduct ──────────→ frozen
         │  ←─── UnfreezeWalletProduct ────────────  │
         │
         └──── CloseWalletProduct ───────────────→ closed (terminal)
```

| From | To | Command | Guards | Cascade |
|---|---|---|---|---|
| `:active` | `:frozen` | `FreezeWalletProduct` | actor_id required; reason required | All `:active` sub-wallets → `:frozen` with `frozen_reason: "parent_wallet_frozen"` |
| `:frozen` | `:active` | `UnfreezeWalletProduct` | actor_id required | Sub-wallets with `frozen_reason: "parent_wallet_frozen"` → `:active`; independently frozen stay `:frozen` |
| `:active` | `:closed` | `CloseWalletProduct` | actor_id required; reason required; aggregate balance MUST be zero | All sub-wallets MUST already be `:closed` (guard rejects if any open) |

### 1.3 Forbidden Transitions

| From | To | Reason |
|---|---|---|
| `:closed` | any | Terminal state |
| `:frozen` | `:closed` | Must unfreeze first; closure requires active status |
| `:active` | `:active` | No-op guard |

### 1.4 Guard Logic (deterministic)

**FreezeWalletProduct guards:**
1. status == :active → allowed
2. status == :frozen → reject with :already_frozen
3. status == :closed → reject with :wallet_product_closed

**UnfreezeWalletProduct guards:**
1. status == :frozen → allowed
2. status == :active → reject with :not_frozen
3. status == :closed → reject with :wallet_product_closed

**CloseWalletProduct guards:**
1. status == :active → check sub-wallets
2. status == :frozen → reject with :wallet_product_frozen (must unfreeze before closing)
3. Sub-wallet check: all sub-wallets must have status :closed → if any are open, reject with :open_sub_wallets_remain
4. Balance check: aggregate balance (sum of all sub-wallet balances) must be zero → reject with :non_zero_balance

### 1.5 Metadata Tracked on Transitions

| Transition | Fields Set |
|---|---|
| → :frozen | `frozen_at`, `frozen_reason`, `frozen_by` |
| → :active (unfreeze) | `frozen_at: nil`, `frozen_reason: nil`, `frozen_by: nil`, `updated_at` |
| → :closed | `closed_at`, `close_reason`, `closed_by` |

---

## 2. SubWallet State Machine

### 2.1 States

| State | Meaning | Terminal? |
|---|---|---|
| `:active` | Operational. All money movement allowed. | No |
| `:frozen` | All money movement blocked. Triggered independently or by parent cascade. | No |
| `:closed` | Terminal. No operations. Balance must be zero. | Yes |

### 2.2 Transitions

```
       active ──── FreezeSubWallet ──────────→ frozen
         │  ←──── UnfreezeSubWallet ──────────  │
         │
         └──── CloseSubWallet ───────────────→ closed (terminal)
```

| From | To | Command | Guards | Notes |
|---|---|---|---|---|
| `:active` | `:frozen` | `FreezeSubWallet` | actor_id + reason required; parent product must not already be closed | `frozen_reason` is caller-supplied (e.g., "fraud_hold") |
| `:frozen` | `:active` | `UnfreezeSubWallet` | actor_id required; parent product must be `:active` | Rejects if `frozen_reason == "parent_wallet_frozen"` unless called by cascade unfreeze |
| `:active` | `:closed` | `CloseSubWallet` | balance MUST be zero; not the default sub-wallet | Terminal |
| `:frozen` | `:closed` | `CloseSubWallet` | balance MUST be zero; `frozen_reason` must NOT be "parent_wallet_frozen" | Cannot close a cascade-frozen sub-wallet directly |

### 2.3 Cascade Operations (initiated by WalletProduct commands, not direct sub-wallet commands)

| Trigger | Effect on SubWallet |
|---|---|
| `FreezeWalletProduct` | All `:active` sub-wallets → `:frozen`, `frozen_reason: "parent_wallet_frozen"` |
| `UnfreezeWalletProduct` | Sub-wallets with `frozen_reason: "parent_wallet_frozen"` → `:active`, reason cleared |
| `CloseWalletProduct` guard | Requires all sub-wallets == `:closed` (no auto-close cascade) |

### 2.4 Guard Logic (deterministic)

**FreezeSubWallet guards:**
1. status == :active → allowed
2. status == :frozen → reject with :already_frozen
3. status == :closed → reject with :sub_wallet_closed
4. wallet_product.status == :closed → reject with :parent_closed

**UnfreezeSubWallet guards:**
1. status == :frozen → check reason
2. frozen_reason == "parent_wallet_frozen" → reject with :frozen_by_parent (unfreeze via UnfreezeWalletProduct)
3. wallet_product.status == :frozen → reject with :parent_wallet_frozen
4. status == :active → reject with :not_frozen
5. status == :closed → reject with :sub_wallet_closed

**CloseSubWallet guards:**
1. balance must be 0 → LedgerStore.get_sub_wallet_balance(sub_wallet_id, currency) == 0
2. sub_wallet must NOT be the default sub-wallet → reject with :cannot_close_default
3. frozen_reason == "parent_wallet_frozen" → reject with :frozen_by_parent

### 2.5 Sub-wallet Ownership Transfer (SW-09/SW-10)

A sub-wallet can be transferred to a different wallet product or a different customer. This is NOT a state machine transition — it is a structural mutation with transaction record:

```
SubWalletTransfer
  source_sub_wallet_id       : existing sub-wallet
  target_wallet_product_id   : destination wallet product
  target_customer_id         : destination customer (may differ)
  reference_id               : shared across transfer legs
  idempotency_key            : required
```

**Transfer validation checks:**
1. Source sub-wallet status == :active
2. Source wallet product status == :active
3. Target wallet product status == :active
4. Target customer status == :active
5. Target wallet product currency_config supports source sub-wallet currency (as :primary)
6. KYC tier check for cross-customer transfers

**Mutation:**
1. Update `sub_wallets.wallet_product_id` to target
2. Update `sub_wallets.owner_customer_id` to target customer
3. `originating_wallet_product_id` is NEVER changed
4. Write transaction records (SubWalletTransferOut + SubWalletTransferIn) with shared `reference_id`

---

## 3. CurrencyConfig State Machine

### 3.1 States

CurrencyConfig does not have a lifecycle state machine per se. It has a boolean `is_enabled` flag and a `classification` field that can be mutated (with constraints).

### 3.2 Classification Mutation Rules

| Mutation | Allowed? | Guards |
|---|---|---|
| Create with :primary | Yes | No other :primary must exist for this wallet_product |
| Create with :display_only | Yes | No duplicate currency_code for this wallet_product |
| Change :display_only → :primary | Blocked in Wave 1 (Phase 2 deferred) | Would require balance migration |
| Change :primary → :display_only | Blocked | Would violate one-primary invariant if only primary |
| Delete :primary config | Blocked | Enforced by store + DB constraint |
| Delete :display_only config | Blocked if balance exists | Balance check required |
| Disable :primary config | Blocked | One-primary invariant |
| Disable :display_only config | Allowed | Hides from display, does not affect balance |

### 3.3 Invariant

**One-primary invariant**: For every `wallet_product_id`, exactly ONE `currency_config` record must have `classification == :primary` at all times.

This is enforced at:
1. Application layer: `CurrencyConfigStore.store/1` guard
2. Database layer: partial unique index on `(wallet_product_id, classification='primary')`

---

## 4. BalanceLedger (Sub-wallet Balance)

The balance ledger is NOT a state machine entity. It is purely additive (append-only transactions).

### 4.1 Balance Rules

| Rule | Constraint |
|---|---|
| Non-negative balance | `balance >= 0` enforced by ledger posting pipeline |
| Per-currency tracking | One row per (sub_wallet_id, currency_code) |
| Update mechanism | Via `PostJournalEntry` with `sub_wallet_id` reference — never direct |
| Read mechanism | `LedgerStore.get_sub_wallet_balance(sub_wallet_id, currency)` |
| Freeze enforcement | Posting pipeline checks sub-wallet status before accepting debit |

### 4.2 Posting Types

| txn_type | Direction | Sub-wallet Effect |
|---|---|---|
| credit | Increases balance | sub_wallet balance += amount |
| debit | Decreases balance | sub_wallet balance -= amount (rejects if insufficient) |
| sub_wallet_transfer_out | Decreases source | source sub_wallet balance -= amount |
| sub_wallet_transfer_in | Increases target | target sub_wallet balance += amount |
| fx_conversion | Both legs | FX debit on source currency, credit on target currency |

### 4.3 Linked Transfer Legs

Sub-wallet transfers produce two immutable transaction records sharing one `reference_id`:
- Record 1: sub_wallet_id = source, txn_type = sub_wallet_transfer_out
- Record 2: sub_wallet_id = target, txn_type = sub_wallet_transfer_in
- Both must succeed atomically (failure → rollback both)

---

## 5. Error Code Matrix

| Domain | Condition | Error Atom | Category | Retryable |
|---|---|---|---|---|
| WalletProduct | Operation on closed wallet product | :wallet_product_closed | business | false |
| WalletProduct | Unfreeze on active product | :not_frozen | business | false |
| WalletProduct | Close with open sub-wallets | :open_sub_wallets_remain | business | false |
| WalletProduct | Close with non-zero balance | :non_zero_balance | business | false |
| WalletProduct | Already frozen | :already_frozen | business | false |
| SubWallet | Unfreeze blocked by parent | :frozen_by_parent | business | false |
| SubWallet | Close default sub-wallet | :cannot_close_default | business | false |
| SubWallet | Close with non-zero balance | :non_zero_balance | business | false |
| SubWallet | Operation after parent closed | :parent_closed | business | false |
| SubWallet | Insufficient balance for debit | :insufficient_balance | business | false |
| CurrencyConfig | Second primary attempted | :primary_already_exists | business | false |
| CurrencyConfig | Duplicate currency code | :duplicate_currency | business | false |
| Transfer | Incompatible target currency | :incompatible_currency | business | false |
| Transfer | Source frozen | :source_sub_wallet_frozen | business | false |
| Transfer | Target frozen | :target_sub_wallet_frozen | business | false |
| Idempotency | Key already exists with diff payload | :idempotency_conflict | business | false |
| Idempotency | Key in progress | :idempotency_in_progress | transient | true |
