# Multi-Wallet Requirements Specification

Date: 2026-03-30
Status: approved
Owner: Architecture + Product
Canonical source: docs/multiwallet_suggested.md (SQL-level schema and business rules)

## Note
This document provides the Elixir/domain-level interpretation of the canonical spec
in `docs/multiwallet_suggested.md`. Key differences from the SQL-level spec:
- Uses TypedId instead of UUID (e.g., "wp_..." for wallet products)
- Uses Elixir atoms for enums instead of SQL strings
- Adds ETS store design not covered in the SQL spec
- Maps to existing OTP app boundaries (wallet_accounts, wallet_ledger, etc.)
- Includes backward compatibility layer for existing Account-based flows

## 1. Overview

This document defines the multi-wallet capabilities to be added to the wallet platform.
Three core features are delivered:

1. **Wallet Products** — users hold multiple named wallet product instances.
2. **Sub-wallets** — purpose-tagged balance buckets within a wallet product.
3. **Multi-currency configuration** — primary + display-only currencies per wallet product.

All features are backward-compatible: existing single-account flows continue to work through a default wallet product and default sub-wallet created transparently during migration.

## 2. Domain Model

### 2.1 WalletProduct

A WalletProduct is the top-level financial container owned by a user.

```
WalletProduct
  wallet_product_id : TypedId("wp")
  user_id           : String (FK → users)
  account_id        : String (FK → accounts, the underlying ledger account)
  product_type_id   : String (FK → wallet_product_types catalog)
  label             : String (user-facing name, e.g., "Holiday Fund")
  status            : :active | :frozen | :closed
  primary_currency  : String (ISO 4217, e.g., "NGN")
  frozen_at         : DateTime | nil
  frozen_reason     : String | nil
  created_at        : DateTime
  updated_at        : DateTime
  metadata          : Map
```

**Product type catalog** (seeded, not hardcoded):
```
WalletProductType
  id        : TypedId("wpt")
  code      : String (unique, e.g., "personal", "business", "crypto")
  label     : String
  min_kyc_tier : Integer (0 = unverified, 1 = basic, 2 = full)
  is_active : Boolean
```

**Constraints:**
- A user MAY have multiple wallet products of the SAME product_type.
- Each wallet product is linked to exactly ONE underlying Account (1:1 relationship).
- The Account.currency MUST equal WalletProduct.primary_currency.
- Wallet product lifecycle cascades to sub-wallets on freeze.
- **Closure = freeze**: on wallet closure, status becomes :frozen, balances are never auto-cleared.

**State machine:**
```
:active → :frozen   (FreezeWalletProduct — cascades to all sub-wallets)
:frozen → :active   (UnfreezeWalletProduct — cascades unfreeze to sub-wallets)
:active → :closed   (CloseWalletProduct — sets status to frozen, terminal)
```

### 2.2 SubWallet

A SubWallet is a purpose-tagged balance bucket within a wallet product.

```
SubWallet
  sub_wallet_id              : TypedId("sw")
  wallet_product_id          : String (FK → wallet_products, current parent)
  owner_customer_id          : String (FK → users, current owner — tracks ownership after transfers)
  originating_wallet_product_id : String (FK → wallet_products, original parent — never changes, for audit)
  sub_type                   : String (e.g., "savings", "spending", "rewards", "payroll", "expense", "tax_reserve", "hot_wallet", "staking", "vault")
  label                      : String (user-facing, e.g., "Rent", "Travel")
  status                     : :active | :frozen | :closed
  frozen_at                  : DateTime | nil
  frozen_reason              : String | nil (e.g., "parent_wallet_frozen", "fraud_hold")
  currency                   : String (inherits from wallet product primary_currency)
  created_at                 : DateTime
  updated_at                 : DateTime
  metadata                   : Map
```

**Constraints:**
- A sub-wallet MUST belong to exactly one wallet product (wallet_product_id).
- A sub-wallet CANNOT exist without a wallet product.
- Sub-wallet currency MUST match the wallet product's primary_currency.
- `owner_customer_id` tracks current ownership (changes on cross-customer transfer).
- `originating_wallet_product_id` is set at creation and NEVER changes — preserves audit trail.
- Sub-type values are governed per product type (e.g., "payroll" only for business products).

**State machine:**
```
:active → :frozen   (FreezeSubWallet)
:frozen → :active   (UnfreezeSubWallet — rejects if parent wallet product is frozen)
:active | :frozen → :closed  (CloseSubWallet — requires zero balance)
```

**Cascade rules:**
- When wallet product is frozen → all sub-wallets cascade to :frozen with `frozen_reason = "parent_wallet_frozen"`.
- When wallet product is unfrozen → only sub-wallets with `frozen_reason = "parent_wallet_frozen"` are unfrozen. Independently frozen sub-wallets (e.g., "fraud_hold") stay frozen.
- Closure = freeze: closed sub-wallets are frozen, balances preserved.

### 2.3 CurrencyConfig

Currency configuration for a wallet product.

```
CurrencyConfig
  config_id         : TypedId("cc")
  wallet_product_id : String (FK → wallet_products)
  currency_code     : String (ISO 4217 for fiat, ticker for crypto)
  currency_type     : :fiat | :crypto | :stablecoin
  classification    : :primary | :display_only
  is_enabled        : Boolean (default: true)
  display_order     : Integer (default: 0)
  created_at        : DateTime
  updated_at        : DateTime
```

**Constraints:**
- Exactly ONE currency config per wallet product MUST have classification :primary.
- Unique constraint on (wallet_product_id, currency_code).
- Primary classification CANNOT be removed if it would leave zero primaries.
- Changing primary to display_only triggers a balance sweep to remaining primary.
- Display-only currencies show converted balances but do NOT hold actual funds.
- Disabling a currency config hides it from display but does not affect fund balances.

**Classification behavior:**

| Classification | Deposits | Withdrawals | Balance Display | FX Conversion |
|---|---|---|---|---|
| primary | Yes | Yes | Native amount | Source or target |
| display_only | No | No | Converted to primary | Auto-converted on receipt |

### 2.4 Relationship Diagram

```
Customer
└── WalletProduct (many per customer, same type allowed)
    │── WalletProductType (FK to catalog)
    │── Account (1:1 underlying ledger account)
    ├── CurrencyConfig (per product — defines active currencies and classification)
    └── SubWallet (child of WalletProduct, never standalone)
        └── BalanceLedger (one row per currency per sub-wallet — source of truth)
            └── Transaction (immutable record of every money movement)
```

## 3. Functional Requirements

### 3.1 Wallet Product Lifecycle

| ID | Requirement | Priority |
|---|---|---|
| WP-01 | Create a wallet product with product_type, label, primary_currency | P0 |
| WP-02 | List all wallet products for a user | P0 |
| WP-03 | Get wallet product details (with sub-wallets and balances) | P0 |
| WP-04 | Update wallet product metadata (label, metadata map) | P0 |
| WP-05 | Freeze wallet product (cascades to sub-wallets) | P0 |
| WP-06 | Unfreeze wallet product (cascades unfreeze) | P0 |
| WP-07 | Suspend wallet product | P1 |
| WP-08 | Resume wallet product | P1 |
| WP-09 | Close wallet product (requires zero balance, all sub-wallets closed) | P0 |
| WP-10 | Support multiple products of same type per user | P0 |

### 3.2 Sub-wallet Lifecycle

| ID | Requirement | Priority |
|---|---|---|
| SW-01 | Create sub-wallet under a wallet product (default created automatically) | P0 |
| SW-02 | List sub-wallets for a wallet product | P0 |
| SW-03 | Get sub-wallet details with balance | P0 |
| SW-04 | Update sub-wallet metadata (label, purpose) | P1 |
| SW-05 | Freeze sub-wallet independently | P0 |
| SW-06 | Unfreeze sub-wallet | P0 |
| SW-07 | Close sub-wallet (requires zero balance, not the default) | P0 |
| SW-08 | Transfer between sub-wallets (same wallet product) | P0 |
| SW-09 | Transfer between sub-wallets (different wallet products, same user) | P1 |
| SW-10 | Transfer between sub-wallets (cross-user) | P2 |

### 3.3 Multi-Currency Configuration

| ID | Requirement | Priority |
|---|---|---|
| MC-01 | Set primary currency on wallet product creation | P0 |
| MC-02 | Add display-only currency with conversion rate | P1 |
| MC-03 | Update display rate for a currency config | P1 |
| MC-04 | Disable/enable a display currency | P2 |
| MC-05 | Enforce one-primary invariant on all mutations | P0 |

### 3.4 Ledger and Transaction Integration

| ID | Requirement | Priority |
|---|---|---|
| LT-01 | Credit to a sub-wallet (posts to underlying account ledger with sub_wallet_id reference) | P0 |
| LT-02 | Debit from a sub-wallet (with balance check per sub-wallet) | P0 |
| LT-03 | Sub-wallet transfer (two ledger legs: debit source + credit target, same reference_id) | P0 |
| LT-04 | Freeze-state enforcement in posting pipeline | P0 |
| LT-05 | Balance query by sub-wallet | P0 |
| LT-06 | Aggregate balance query by wallet product (sum of sub-wallet balances) | P0 |
| LT-07 | FX conversion transaction records for cross-currency display | P2 |
| LT-08 | Idempotency key required for all mutating ledger operations | P0 |

### 3.5 API Surface

| ID | Requirement | Priority |
|---|---|---|
| API-01 | REST endpoints for wallet product CRUD + freeze/unfreeze | P0 |
| API-02 | REST endpoints for sub-wallet CRUD + freeze/unfreeze | P0 |
| API-03 | REST endpoint for sub-wallet transfer | P0 |
| API-04 | REST endpoints for currency config management | P1 |
| API-05 | Customer LiveView for wallet product switching and sub-wallet management | P1 |
| API-06 | Admin LiveView for wallet product inquiry and operational controls | P1 |
| API-07 | Backward-compatible existing account endpoints (default wallet product/sub-wallet) | P0 |

### 3.6 Eventing and Compliance

| ID | Requirement | Priority |
|---|---|---|
| EC-01 | Domain events for wallet product lifecycle transitions | P0 |
| EC-02 | Domain events for sub-wallet lifecycle transitions | P0 |
| EC-03 | Domain events for sub-wallet transfers | P0 |
| EC-04 | Audit events for freeze/unfreeze/close actions | P0 |
| EC-05 | Telemetry spans for wallet product and sub-wallet operations | P1 |
| EC-06 | Compliance control checks for regulated actions | P1 |

## 4. Non-Functional Requirements

| ID | Requirement | Priority |
|---|---|---|
| NF-01 | ETS store performance: sub-wallet lookup by wallet_product_id < 1ms | P0 |
| NF-02 | Write-through persistence must be non-fatal (ETS is source of truth) | P0 |
| NF-03 | Database migration must be online-compatible (no table locks > 1s) | P0 |
| NF-04 | Backward compatibility: existing API clients MUST NOT break | P0 |
| NF-05 | Data backfill: existing accounts get default wallet product + default sub-wallet | P0 |
| NF-06 | Rollback: each migration step must be independently reversible | P0 |

## 5. Data Migration Strategy

### 5.1 Backfill Plan

For each existing Account:
1. Create a WalletProduct with:
   - product_type: :spending (default)
   - label: "Default Wallet"
   - primary_currency: Account.currency
   - status: mirrors Account.status
   - account_id: Account.account_id
2. Create a default SubWallet with:
   - label: "General"
   - purpose: :general
   - currency: Account.currency
   - status: mirrors Account.status
3. Create a CurrencyConfig with:
   - classification: :primary
   - currency_code: Account.currency
4. Backfill ledger entries/balances with sub_wallet_id reference.

### 5.2 Compatibility Layer

During transition period:
- Existing endpoints that reference `account_id` resolve through `WalletProduct.account_id` to find the default wallet product and its default sub-wallet.
- New endpoints use `wallet_product_id` and `sub_wallet_id` directly.
- The compatibility layer is a DI-injectable resolver function.

## 6. Invariants and Constraints Summary

1. **One-primary currency**: exactly one :primary CurrencyConfig per wallet product.
2. **Parent linkage**: every sub-wallet MUST reference a valid wallet_product_id.
3. **Currency match**: sub-wallet.currency == wallet_product.primary_currency.
4. **Default sub-wallet**: every wallet product has exactly one :general sub-wallet, cannot be independently closed.
5. **Freeze cascade**: freezing a wallet product freezes all its sub-wallets.
6. **Close guard**: wallet product close requires all sub-wallets closed with zero balance.
7. **Non-negative balance**: sub-wallet balance MUST NOT go below zero (enforced in ledger).
8. **Idempotency**: all mutating operations require and honor idempotency keys.
9. **Audit trail**: all lifecycle transitions emit domain + audit events.
