# Command / Query / Event Contracts — Multi-Wallet

Date: 2026-03-30
Status: approved
Phase: 2 (Domain Contract and ADR Alignment)
Reference: docs/multi-wallet/deliverables/domain-state-machines.md
Repository pattern: apps/wallet_accounts/ (existing commands for reference pattern)

## 1. Naming and Versioning Conventions

Following existing repository patterns:
- Commands: `VerbNoun` PascalCase module (e.g., `CreateWalletProduct`)
- Events: `NounPastTense.v1` string (e.g., `"WalletProductCreated.v1"`)
- Queries: functions on store modules (e.g., `WalletProductStore.list_by_user/1`)
- All new modules live in `WalletAccounts` namespace (wallet_accounts app)
- Transfer-related commands live in `WalletTransfers` namespace (wallet_transfers app)

## 2. New App-Level Commands

### 2.1 WalletProduct Commands (app: wallet_accounts)

---

#### `WalletAccounts.Commands.CreateWalletProduct`

```elixir
@spec execute(user_id :: String.t(), product_type_id :: String.t(), opts :: keyword()) ::
  {:ok, WalletProduct.t(), [event()]} | {:error, atom()}

opts:
  :label          - String.t() required
  :primary_currency - String.t() required (ISO 4217)
  :actor_id       - String.t() required
  :correlation_id - String.t() optional
  :idempotency_key - String.t() optional

Side effects:
  1. Creates WalletProduct struct with status :active
  2. Creates underlying Account via OpenWalletAccount (existing command, DI injected)
  3. Creates default SubWallet (sub_type: "general", label: "General")
  4. Creates CurrencyConfig with classification: :primary for primary_currency
  5. Stores in WalletProductStore, SubWalletStore, CurrencyConfigStore
  6. Emits WalletProductCreated.v1 event
  7. Emits SubWalletCreated.v1 event (for default sub-wallet)
  8. Emits AuditEvent

Guards:
  - product_type_id must exist in WalletProductTypeCatalog
  - primary_currency must be valid (non-empty ISO code)
  - user's KYC tier must meet product type's min_kyc_tier (DI: kyc_tier_fn)
```

---

#### `WalletAccounts.Commands.FreezeWalletProduct`

```elixir
@spec execute(wallet_product_id :: String.t(), reason :: String.t(), actor_id :: String.t(), opts :: keyword()) ::
  {:ok, WalletProduct.t(), [event()]} | {:error, atom()}

opts:
  :correlation_id  - String.t() optional
  :idempotency_key - String.t() optional

Side effects:
  1. Transitions WalletProduct to :frozen
  2. Cascades: freezes all :active sub-wallets with frozen_reason: "parent_wallet_frozen"
  3. Updates WalletProductStore + SubWalletStore
  4. Emits WalletProductFrozen.v1
  5. Emits SubWalletFrozen.v1 for each cascaded sub-wallet
  6. Emits AuditEvent (sensitive action)

Guards: see domain-state-machines.md § 1.4
```

---

#### `WalletAccounts.Commands.UnfreezeWalletProduct`

```elixir
@spec execute(wallet_product_id :: String.t(), actor_id :: String.t(), opts :: keyword()) ::
  {:ok, WalletProduct.t(), [event()]} | {:error, atom()}

Side effects:
  1. Transitions WalletProduct to :active
  2. Cascades: sub-wallets with frozen_reason "parent_wallet_frozen" → :active
  3. Sub-wallets with other frozen_reason remain :frozen
  4. Emits WalletProductUnfrozen.v1
  5. Emits SubWalletUnfrozen.v1 for each cascaded sub-wallet
  6. Emits AuditEvent

Guards: see domain-state-machines.md § 1.4
```

---

#### `WalletAccounts.Commands.CloseWalletProduct`

```elixir
@spec execute(wallet_product_id :: String.t(), reason :: String.t(), actor_id :: String.t(), opts :: keyword()) ::
  {:ok, WalletProduct.t(), [event()]} | {:error, atom()}

opts:
  :balance_checker_fn - (wallet_product_id -> integer())  DI: checks aggregate balance
  :correlation_id  - String.t() optional

Side effects:
  1. Validates all sub-wallets == :closed and balance == 0 (via DI)
  2. Transitions WalletProduct to :closed
  3. Records closed_at, close_reason, closed_by
  4. Emits WalletProductClosed.v1
  5. Emits AuditEvent (sensitive action)

Guards: see domain-state-machines.md § 1.4
```

---

#### `WalletAccounts.Commands.UpdateWalletProduct`

```elixir
@spec execute(wallet_product_id :: String.t(), changes :: map(), actor_id :: String.t(), opts :: keyword()) ::
  {:ok, WalletProduct.t(), [event()]} | {:error, atom()}

changes allowed:
  :label    - String.t()
  :metadata - map()

Side effects:
  1. Applies changes to WalletProduct struct
  2. Updates in WalletProductStore
  3. Emits WalletProductUpdated.v1
```

---

### 2.2 SubWallet Commands (app: wallet_accounts)

---

#### `WalletAccounts.Commands.CreateSubWallet`

```elixir
@spec execute(wallet_product_id :: String.t(), sub_type :: String.t(), opts :: keyword()) ::
  {:ok, SubWallet.t(), [event()]} | {:error, atom()}

opts:
  :label          - String.t() optional (defaults to sub_type label)
  :actor_id       - String.t() required
  :correlation_id - String.t() optional

Side effects:
  1. Validates wallet product is :active
  2. Creates SubWallet with status :active, currency from wallet product primary_currency
  3. Sets originating_wallet_product_id = wallet_product_id (immutable)
  4. Sets owner_customer_id = wallet product's user_id
  5. Creates BalanceLedger row with amount 0 for each enabled currency in product's CurrencyConfig
  6. Stores in SubWalletStore
  7. Emits SubWalletCreated.v1
  8. Emits AuditEvent

Guards:
  - wallet_product.status must be :active
```

---

#### `WalletAccounts.Commands.FreezeSubWallet`

```elixir
@spec execute(sub_wallet_id :: String.t(), reason :: String.t(), actor_id :: String.t(), opts :: keyword()) ::
  {:ok, SubWallet.t(), [event()]} | {:error, atom()}

Side effects:
  1. Transitions SubWallet to :frozen with caller-supplied reason
  2. Emits SubWalletFrozen.v1
  3. Emits AuditEvent (sensitive action)

Guards: see domain-state-machines.md § 2.4
```

---

#### `WalletAccounts.Commands.UnfreezeSubWallet`

```elixir
@spec execute(sub_wallet_id :: String.t(), actor_id :: String.t(), opts :: keyword()) ::
  {:ok, SubWallet.t(), [event()]} | {:error, atom()}

Side effects:
  1. Validates frozen_reason != "parent_wallet_frozen"
  2. Validates parent wallet_product is :active
  3. Transitions SubWallet to :active
  4. Emits SubWalletUnfrozen.v1
  5. Emits AuditEvent

Guards: see domain-state-machines.md § 2.4
```

---

#### `WalletAccounts.Commands.CloseSubWallet`

```elixir
@spec execute(sub_wallet_id :: String.t(), reason :: String.t(), actor_id :: String.t(), opts :: keyword()) ::
  {:ok, SubWallet.t(), [event()]} | {:error, atom()}

opts:
  :balance_checker_fn - (sub_wallet_id, currency -> integer())  DI

Side effects:
  1. Validates not default sub-wallet
  2. Validates balance == 0 via DI
  3. Validates frozen_reason != "parent_wallet_frozen"
  4. Transitions to :closed
  5. Emits SubWalletClosed.v1
  6. Emits AuditEvent

Guards: see domain-state-machines.md § 2.4
```

---

### 2.3 CurrencyConfig Commands (app: wallet_accounts)

---

#### `WalletAccounts.Commands.AddCurrencyConfig`

```elixir
@spec execute(wallet_product_id :: String.t(), currency_code :: String.t(), classification :: :primary | :display_only, opts :: keyword()) ::
  {:ok, CurrencyConfig.t(), [event()]} | {:error, atom()}

opts:
  :currency_type  - :fiat | :crypto | :stablecoin (default: :fiat)
  :actor_id       - String.t() required
  :correlation_id - String.t() optional

Side effects:
  1. Validates no duplicate (wallet_product_id, currency_code)
  2. For :primary: validates no existing :primary config exists
  3. Creates CurrencyConfig
  4. Stores in CurrencyConfigStore
  5. Emits CurrencyConfigAdded.v1

Guards:
  - classification :primary rejected if primary already exists (one-primary invariant)
  - duplicate currency_code rejected
```

---

### 2.4 Sub-wallet Transfer Commands (app: wallet_transfers)

---

#### `WalletTransfers.Commands.TransferBetweenSubWallets`

```elixir
@spec execute(source_sub_wallet_id :: String.t(), target_sub_wallet_id :: String.t(), amount :: integer(), currency :: String.t(), opts :: keyword()) ::
  {:ok, SubWalletTransfer.t(), [event()]} | {:error, atom()}

opts:
  :idempotency_key - String.t() required
  :actor_id       - String.t() required
  :correlation_id - String.t() optional
  :sub_wallet_resolver_fn - (sub_wallet_id -> {:ok, SubWallet.t()} | {:error, atom()})  DI
  :ledger_poster_fn       - (journal_params -> {:ok, journal} | {:error, atom()})  DI

Amount: integer (kobo/minor units, per ADR 0003 precision invariant)

Side effects:
  1. Check idempotency key (wallet_state)
  2. Resolve source + target sub-wallets via DI
  3. Validate statuses, wallet product statuses, currency match
  4. Validate source balance >= amount (via LedgerStore DI)
  5. Atomically post two ledger entries with shared reference_id:
     - Debit source sub-wallet
     - Credit target sub-wallet
  6. Record idempotency result (wallet_state)
  7. Emit SubWalletTransferCompleted.v1
  8. Emit AuditEvent

Guards:
  - source.status == :active
  - target.status == :active
  - source wallet_product.status == :active
  - target wallet_product.status == :active
  - source balance >= amount
  - idempotency key uniqueness
  - currency must match across source and target (P0; FX deferred to P1)
```

---

## 3. Queries

### 3.1 WalletProductStore Queries

```elixir
# New ETS GenServer in wallet_accounts
WalletProductStore.store(wallet_product :: WalletProduct.t()) :: :ok
WalletProductStore.get(wallet_product_id :: String.t()) :: {:ok, WalletProduct.t()} | {:error, :not_found}
WalletProductStore.update(wallet_product :: WalletProduct.t()) :: :ok | {:error, :not_found}
WalletProductStore.list_by_user(user_id :: String.t()) :: [WalletProduct.t()]
WalletProductStore.list_by_user_and_type(user_id :: String.t(), product_type_id :: String.t()) :: [WalletProduct.t()]
WalletProductStore.reset() :: :ok  # test-only
```

### 3.2 SubWalletStore Queries

```elixir
# New ETS GenServer in wallet_accounts
SubWalletStore.store(sub_wallet :: SubWallet.t()) :: :ok
SubWalletStore.get(sub_wallet_id :: String.t()) :: {:ok, SubWallet.t()} | {:error, :not_found}
SubWalletStore.update(sub_wallet :: SubWallet.t()) :: :ok | {:error, :not_found}
SubWalletStore.list_by_product(wallet_product_id :: String.t()) :: [SubWallet.t()]
SubWalletStore.list_by_user(user_id :: String.t()) :: [SubWallet.t()]  # via owner_customer_id
SubWalletStore.get_default(wallet_product_id :: String.t()) :: {:ok, SubWallet.t()} | {:error, :not_found}
SubWalletStore.reset() :: :ok  # test-only
```

### 3.3 CurrencyConfigStore Queries

```elixir
# New ETS GenServer in wallet_accounts
CurrencyConfigStore.store(config :: CurrencyConfig.t()) :: :ok
CurrencyConfigStore.get(config_id :: String.t()) :: {:ok, CurrencyConfig.t()} | {:error, :not_found}
CurrencyConfigStore.update(config :: CurrencyConfig.t()) :: :ok | {:error, :not_found}
CurrencyConfigStore.list_by_product(wallet_product_id :: String.t()) :: [CurrencyConfig.t()]
CurrencyConfigStore.get_primary(wallet_product_id :: String.t()) :: {:ok, CurrencyConfig.t()} | {:error, :not_found}
CurrencyConfigStore.reset() :: :ok  # test-only
```

### 3.4 LedgerStore Extensions (app: wallet_ledger)

```elixir
# Extensions to existing WalletLedger.LedgerStore
LedgerStore.get_sub_wallet_balance(sub_wallet_id :: String.t(), currency :: String.t()) :: integer()  # minor units
LedgerStore.get_entries_by_sub_wallet(sub_wallet_id :: String.t()) :: [Entry.t()]
LedgerStore.get_aggregate_product_balance(wallet_product_id :: String.t(), currency :: String.t()) :: integer()
  # sums balances across all sub-wallets for the product; sub_wallet_resolver_fn DI
```

---

## 4. Domain Events

All events implement `@behaviour WalletEvents.DomainEvent`.
All events follow the ADR 0002 structure: event_id, event_name, event_version, aggregate_id, correlation_id, occurred_at, payload.

### 4.1 WalletProduct Events (module: WalletAccounts.Events.*)

---

#### `WalletProductCreated.v1`

```elixir
event_name: "WalletProductCreated.v1"
aggregate_type: "WalletProduct"
aggregate_id: wallet_product_id
payload: %{
  wallet_product_id: String.t(),
  user_id:           String.t(),
  product_type_id:   String.t(),
  label:             String.t(),
  primary_currency:  String.t(),
  status:            :active,
  default_sub_wallet_id: String.t()
}
```

---

#### `WalletProductFrozen.v1`

```elixir
event_name: "WalletProductFrozen.v1"
aggregate_id: wallet_product_id
payload: %{
  wallet_product_id: String.t(),
  user_id:           String.t(),
  frozen_by:         String.t(),
  frozen_reason:     String.t(),
  frozen_at:         DateTime.t(),
  cascaded_sub_wallet_ids: [String.t()]
}
```

---

#### `WalletProductUnfrozen.v1`

```elixir
event_name: "WalletProductUnfrozen.v1"
aggregate_id: wallet_product_id
payload: %{
  wallet_product_id: String.t(),
  user_id:           String.t(),
  unfrozen_by:       String.t(),
  unfrozen_at:       DateTime.t(),
  cascaded_sub_wallet_ids: [String.t()]
}
```

---

#### `WalletProductUpdated.v1`

```elixir
event_name: "WalletProductUpdated.v1"
aggregate_id: wallet_product_id
payload: %{
  wallet_product_id: String.t(),
  user_id:           String.t(),
  changes:           map(),  # diff map
  updated_by:        String.t()
}
```

---

#### `WalletProductClosed.v1`

```elixir
event_name: "WalletProductClosed.v1"
aggregate_id: wallet_product_id
payload: %{
  wallet_product_id: String.t(),
  user_id:           String.t(),
  closed_by:         String.t(),
  close_reason:      String.t(),
  closed_at:         DateTime.t()
}
```

---

### 4.2 SubWallet Events (module: WalletAccounts.Events.*)

---

#### `SubWalletCreated.v1`

```elixir
event_name: "SubWalletCreated.v1"
aggregate_id: sub_wallet_id
payload: %{
  sub_wallet_id:              String.t(),
  wallet_product_id:          String.t(),
  owner_customer_id:          String.t(),
  originating_wallet_product_id: String.t(),
  sub_type:                   String.t(),
  label:                      String.t(),
  currency:                   String.t(),
  is_default:                 boolean()
}
```

---

#### `SubWalletFrozen.v1`

```elixir
event_name: "SubWalletFrozen.v1"
aggregate_id: sub_wallet_id
payload: %{
  sub_wallet_id:     String.t(),
  wallet_product_id: String.t(),
  frozen_by:         String.t(),
  frozen_reason:     String.t(),
  frozen_at:         DateTime.t(),
  cascade_source:    :parent_freeze | :direct  # source of freeze
}
```

---

#### `SubWalletUnfrozen.v1`

```elixir
event_name: "SubWalletUnfrozen.v1"
aggregate_id: sub_wallet_id
payload: %{
  sub_wallet_id:     String.t(),
  wallet_product_id: String.t(),
  unfrozen_by:       String.t(),
  unfrozen_at:       DateTime.t(),
  cascade_source:    :parent_unfreeze | :direct
}
```

---

#### `SubWalletClosed.v1`

```elixir
event_name: "SubWalletClosed.v1"
aggregate_id: sub_wallet_id
payload: %{
  sub_wallet_id:     String.t(),
  wallet_product_id: String.t(),
  closed_by:         String.t(),
  close_reason:      String.t(),
  closed_at:         DateTime.t()
}
```

---

### 4.3 Transfer Events (module: WalletTransfers.Events.*)

---

#### `SubWalletTransferCompleted.v1`

```elixir
event_name: "SubWalletTransferCompleted.v1"
aggregate_id: transfer_id  (a new TypedId "swt_...")
payload: %{
  transfer_id:            String.t(),
  source_sub_wallet_id:   String.t(),
  target_sub_wallet_id:   String.t(),
  amount:                 integer(),  # minor units
  currency:               String.t(),
  reference_id:           String.t(),  # shared across ledger legs
  idempotency_key:        String.t(),
  actor_id:               String.t(),
  completed_at:           DateTime.t()
}
```

---

### 4.4 CurrencyConfig Events

---

#### `CurrencyConfigAdded.v1`

```elixir
event_name: "CurrencyConfigAdded.v1"
aggregate_id: config_id
payload: %{
  config_id:         String.t(),
  wallet_product_id: String.t(),
  currency_code:     String.t(),
  currency_type:     :fiat | :crypto | :stablecoin,
  classification:    :primary | :display_only,
  added_by:          String.t()
}
```

---

## 5. Idempotency Contract Alignment

Per ADR 0004 and ADR 0005:

| Command | Idempotency Key Required? | Scope | Key in wallet_state? |
|---|---|---|---|
| CreateWalletProduct | Optional (recommended) | (user_id, route) | Yes when provided |
| FreezeWalletProduct | Optional | (actor_id, wallet_product_id) | Yes when provided |
| UnfreezeWalletProduct | Optional | (actor_id, wallet_product_id) | Yes when provided |
| CloseWalletProduct | Optional | (actor_id, wallet_product_id) | Yes when provided |
| CreateSubWallet | Optional | (user_id, wallet_product_id) | Yes when provided |
| FreezeSubWallet | Optional | (actor_id, sub_wallet_id) | Yes when provided |
| CloseSubWallet | Optional | (actor_id, sub_wallet_id) | Yes when provided |
| **TransferBetweenSubWallets** | **Required** | (actor_id, source, target, amount) | Yes always |
| AddCurrencyConfig | Optional | (actor_id, wallet_product_id, currency) | Yes when provided |

**Money movement (TransferBetweenSubWallets) requires idempotency key** per ADR 0004 §1 and the canonical spec rule #4.

---

## 6. Backward Compatibility Contract

Existing API endpoints that reference `account_id` or `user_id` directly are resolved through:

```elixir
# DI-injectable resolver — no coupling between wallet_web and wallet_accounts
WalletProductResolver.resolve_default_sub_wallet(account_id :: String.t()) ::
  {:ok, %{wallet_product: WalletProduct.t(), sub_wallet: SubWallet.t()}} | {:error, :not_found}

WalletProductResolver.resolve_default_sub_wallet_for_user(user_id :: String.t()) ::
  {:ok, %{wallet_product: WalletProduct.t(), sub_wallet: SubWallet.t()}} | {:error, :not_found}
```

Location: `apps/wallet_accounts/lib/wallet_accounts/wallet_product_resolver.ex`

These resolvers are used by wallet_web API controllers for backward-compatible handling of:
- `GET /api/v1/wallet/:userId/balance` → resolves default wallet product's default sub-wallet
- `POST /api/v1/wallet/:userId/credit` → credits default sub-wallet
- `POST /api/v1/wallet/:userId/transfer` → transfers from default sub-wallet

---

## 7. Event Catalog Delta (new events vs existing)

| Event Name | Version | App | New/Existing |
|---|---|---|---|
| WalletAccountOpened.v1 | 1 | wallet_accounts | Existing |
| WalletAccountFrozen.v1 | 1 | wallet_accounts | Existing |
| WalletAccountUnfrozen.v1 | 1 | wallet_accounts | Existing |
| WalletProductCreated.v1 | 1 | wallet_accounts | **New** |
| WalletProductFrozen.v1 | 1 | wallet_accounts | **New** |
| WalletProductUnfrozen.v1 | 1 | wallet_accounts | **New** |
| WalletProductUpdated.v1 | 1 | wallet_accounts | **New** |
| WalletProductClosed.v1 | 1 | wallet_accounts | **New** |
| SubWalletCreated.v1 | 1 | wallet_accounts | **New** |
| SubWalletFrozen.v1 | 1 | wallet_accounts | **New** |
| SubWalletUnfrozen.v1 | 1 | wallet_accounts | **New** |
| SubWalletClosed.v1 | 1 | wallet_accounts | **New** |
| SubWalletTransferCompleted.v1 | 1 | wallet_transfers | **New** |
| CurrencyConfigAdded.v1 | 1 | wallet_accounts | **New** |

Total new events: **13**
