# REST API Implementation Plan — Cards, Transactions, Rewards & Prepaid

**Document:** `docs/card-transaction-rewards-api-plan.md`  
**Date:** 2026-06-30  
**Status:** DRAFT — Awaiting Review  
**Author:** momentPay Engineering  

---

## Background

The wallet platform has a fully-built domain layer (commands, ETS stores, write-through DB
persistence) but **zero REST API routes** for cards, transactions, rewards, and prepaid top-ups.
All customer interactions today happen through Phoenix LiveViews (browser-only).

This plan delivers four phases of REST API controllers that expose the existing domain layer
without changing any business logic.

### What Already Exists (no changes needed)

| Layer | Status |
|---|---|
| Domain commands (`FreezeCard`, `TopUpCard`, `RedeemPoints`, …) | ✅ Complete |
| ETS stores (`CardStore`, `TopUpTransactionStore`, `OfferStore`, …) | ✅ Complete |
| DB write-through persistence | ✅ Complete |
| API auth pipeline (`:api_authenticated`, `VerifyAccessToken`) | ✅ Complete |
| `ErrorEnvelope` / `SuccessEnvelope` response contracts | ✅ Complete |
| Controller pattern (from `WalletProductsController`) | ✅ Established |

### What Does NOT Change

- No domain logic is modified.  
- No ETS stores or commands are modified.  
- No LiveViews are modified.  
- No database migrations are required.  

---

## Phase Overview

| Phase | Domain | Routes | Effort |
|---|---|---|---|
| **A1** | Cards — core lifecycle | 8 routes | ~1 day |
| **A2** | Card Transactions & Ledger | 5 routes | ~1 day |
| **A3** | Rewards & Offers | 7 routes | ~1 day |
| **A4** | Prepaid Top-Up | 5 routes | ~0.5 day |

**Total: ~3.5 working days, 25 routes**

---

## Phase A1 — Cards Core API

### Goal
Expose card listing, detail, balance, and lifecycle operations (freeze/unfreeze/block/reset-PIN)
as REST endpoints. Covers the most common mobile/partner integration need.

### New File
```
apps/wallet_web/lib/wallet_web/controllers/api/cards_controller.ex
```

### Routes (add to router.ex under `:api_authenticated` scope)

```elixir
scope "/api/v1", WalletWeb.Api, as: :api do
  pipe_through :api_authenticated

  # Phase A1 — Cards
  get    "/cards",                 CardsController, :index
  get    "/cards/:card_id",        CardsController, :show
  get    "/cards/:card_id/balance", CardsController, :balance
  post   "/cards/:card_id/freeze",  CardsController, :freeze
  post   "/cards/:card_id/unfreeze", CardsController, :unfreeze
  post   "/cards/:card_id/block",   CardsController, :block
  post   "/cards/:card_id/unblock", CardsController, :unblock
  post   "/cards/:card_id/reset-pin", CardsController, :reset_pin
end
```

### Endpoint Specifications

#### `GET /api/v1/cards`
- **Purpose:** List all cards belonging to the authenticated user.
- **Backend:** `CardStore.list_by_user(user_id)`
- **Auth:** Bearer token; `user_id` extracted from JWT claims.
- **Query Params:** `status` (active/frozen/blocked/expired), `type` (debit/credit)
- **Response 200:**
```json
{
  "data": [
    {
      "card_id": "card_abc123",
      "card_type": "debit",
      "status": "active",
      "last_four": "4321",
      "network": "Visa",
      "expiry": "12/28",
      "is_prepaid": false,
      "is_virtual": false,
      "top_up_amount": null,
      "daily_limit": 5000,
      "monthly_limit": 50000
    }
  ],
  "meta": { "count": 1 }
}
```

#### `GET /api/v1/cards/:card_id`
- **Purpose:** Full card detail for a specific card.
- **Backend:** `CardStore.get(card_id)` — validates `card.user_id == current_user_id`.
- **Response 200:** Single card object (same shape as list item, plus `last_top_up_at`).
- **Response 404:** `ErrorEnvelope` with `code: "card_not_found"`.
- **Response 403:** `ErrorEnvelope` with `code: "unauthorized"` if card belongs to another user.

#### `GET /api/v1/cards/:card_id/balance`
- **Purpose:** Current balance for prepaid/debit cards.
- **Backend:** `CardStore.get(card_id)` — returns `top_up_amount` and `last_top_up_at`.
- **Response 200:**
```json
{
  "data": {
    "card_id": "card_abc123",
    "balance": "250.000",
    "currency": "AED",
    "last_top_up_at": "2026-06-30T10:22:00Z"
  }
}
```

#### `POST /api/v1/cards/:card_id/freeze`
- **Purpose:** Freeze an active card (no body required).
- **Backend:** `WalletCards.Commands.FreezeCard.execute(card_id, user_id)`
- **Response 200:** Updated card object with `"status": "frozen"`.
- **Response 422:** `ErrorEnvelope` if card is already frozen or blocked.

#### `POST /api/v1/cards/:card_id/unfreeze`
- **Purpose:** Unfreeze a frozen card.
- **Backend:** `WalletCards.Commands.UnfreezeCard.execute(card_id, user_id)`
- **Response 200:** Updated card object with `"status": "active"`.

#### `POST /api/v1/cards/:card_id/block`
- **Purpose:** Permanently block a card (report lost/stolen).
- **Backend:** `WalletCards.Commands.BlockCard.execute(card_id, user_id, reason)`
- **Request Body:**
```json
{ "reason": "lost" }
```
- **Response 200:** Updated card object with `"status": "blocked"`.

#### `POST /api/v1/cards/:card_id/unblock`
- **Purpose:** Unblock a card (admin/ops use).
- **Backend:** `WalletCards.Commands.UnblockCard.execute(card_id, user_id)`
- **Response 200:** Updated card object with `"status": "active"`.

#### `POST /api/v1/cards/:card_id/reset-pin`
- **Purpose:** Initiate PIN reset (triggers OTP to registered mobile).
- **Backend:** `WalletCards.Commands.ResetCardPin.execute(card_id, user_id)`
- **Response 200:**
```json
{ "data": { "message": "PIN reset OTP sent to registered mobile number." } }
```

### Error Handling (common to all A1 endpoints)
```json
{
  "error": {
    "code": "card_not_found",
    "message": "Card not found.",
    "retryable": false,
    "category": "not_found"
  },
  "meta": { "request_id": "req_abc", "timestamp": "2026-06-30T10:00:00Z" }
}
```

### Deliverables
- [ ] `apps/wallet_web/lib/wallet_web/controllers/api/cards_controller.ex`
- [ ] 8 routes added to `router.ex`
- [ ] `apps/wallet_web/test/wallet_web/controllers/api/cards_controller_test.exs` (min. 16 tests)

---

## Phase A2 — Card Transactions & Ledger API

### Goal
Expose per-card and per-user transaction history. For prepaid cards and card fund operations,
this reads from `TopUpTransactionStore`. A general ledger history endpoint is also included.

### New File
```
apps/wallet_web/lib/wallet_web/controllers/api/card_transactions_controller.ex
```

### Routes

```elixir
# Phase A2 — Transactions
get "/cards/:card_id/transactions",   CardTransactionsController, :index
get "/transactions/:transaction_id",  CardTransactionsController, :show
get "/transactions",                  CardTransactionsController, :user_index
get "/accounts/:account_id/ledger",   CardTransactionsController, :ledger
get "/cards/:card_id/statement",      CardTransactionsController, :statement
```

### Endpoint Specifications

#### `GET /api/v1/cards/:card_id/transactions`
- **Purpose:** List top-up/deposit transactions for a specific card (newest first).
- **Backend:** `WalletPrepaid.TopUpTransactionStore.list_by_card(card_id)`
- **Query Params:**
  - `status` — `success` | `failed`
  - `from` — ISO date (`2026-01-01`)
  - `to` — ISO date
  - `page` — integer (default 1)
  - `per_page` — integer (default 20, max 100)
- **Response 200:**
```json
{
  "data": [
    {
      "transaction_id": "topup_xyz",
      "card_id": "card_abc123",
      "amount": "100.00",
      "currency": "AED",
      "status": "success",
      "initiated_by": "user_001",
      "inserted_at": "2026-06-30T10:22:00Z"
    }
  ],
  "meta": { "page": 1, "per_page": 20, "total": 3 }
}
```

#### `GET /api/v1/transactions/:transaction_id`
- **Purpose:** Get a single top-up transaction by ID.
- **Backend:** `WalletPrepaid.TopUpTransactionStore.get(transaction_id)`
- **Ownership check:** `txn.user_id == current_user_id`
- **Response 200:** Single transaction object.
- **Response 404:** `ErrorEnvelope` with `code: "transaction_not_found"`.

#### `GET /api/v1/transactions`
- **Purpose:** All top-up transactions for the authenticated user (across all cards).
- **Backend:** `WalletPrepaid.TopUpTransactionStore.list_by_user(user_id)`
- **Query Params:** Same as per-card endpoint.
- **Response 200:** Paginated list of transaction objects.

#### `GET /api/v1/accounts/:account_id/ledger`
- **Purpose:** Double-entry ledger history (all journal entries for an account).
- **Backend:** `WalletLedger.Queries.GetLedgerHistory.execute(account_id, opts)`
- **Query Params:** `page`, `per_page`, `currency`
- **Response 200:**
```json
{
  "data": [
    {
      "journal_id": "jrnl_abc",
      "type": "transfer",
      "currency": "AED",
      "entries": [
        { "direction": "debit",  "amount": "100.00", "account_id": "acc_001" },
        { "direction": "credit", "amount": "100.00", "account_id": "acc_002" }
      ],
      "posted_at": "2026-06-30T10:22:00Z"
    }
  ],
  "meta": { "page": 1, "per_page": 20, "total": 45 }
}
```

#### `GET /api/v1/cards/:card_id/statement`
- **Purpose:** Statement summary for a date range (totals: debit, credit, net, count).
- **Backend:** `TopUpTransactionStore.list_by_card(card_id)` filtered by date range.
- **Query Params:** `from` (ISO date), `to` (ISO date) — defaults to current month.
- **Response 200:**
```json
{
  "data": {
    "card_id": "card_abc123",
    "period_from": "2026-06-01",
    "period_to": "2026-06-30",
    "total_credits": "350.00",
    "total_debits": "0.00",
    "net": "350.00",
    "currency": "AED",
    "transaction_count": 3
  }
}
```

### Deliverables
- [ ] `apps/wallet_web/lib/wallet_web/controllers/api/card_transactions_controller.ex`
- [ ] 5 routes added to `router.ex`
- [ ] `apps/wallet_web/test/wallet_web/controllers/api/card_transactions_controller_test.exs` (min. 15 tests)

---

## Phase A3 — Rewards & Offers API

### Goal
Expose the full rewards lifecycle — browse available offers, redeem offers, check points
balance, and view points history — as REST endpoints. Powers mobile apps and partner
integrations for loyalty features.

### New File
```
apps/wallet_web/lib/wallet_web/controllers/api/rewards_controller.ex
```

### Routes

```elixir
# Phase A3 — Rewards & Offers
get  "/rewards/offers",                   RewardsController, :list_offers
get  "/rewards/offers/:offer_id",         RewardsController, :get_offer
post "/rewards/offers/:offer_id/redeem",  RewardsController, :redeem_offer
get  "/rewards/points",                   RewardsController, :points_balance
get  "/rewards/points/history",           RewardsController, :points_history
get  "/rewards/transactions",             RewardsController, :reward_transactions
post "/rewards/points/adjust",            RewardsController, :adjust_points
```

### Endpoint Specifications

#### `GET /api/v1/rewards/offers`
- **Purpose:** List all currently active offers available to the user.
- **Backend:** `WalletRewards.OfferStore.list_active()`
- **Query Params:**
  - `category` — filter by category string
  - `merchant` — filter by merchant name (partial, case-insensitive)
  - `max_points` — only show offers within the user's points balance
- **Response 200:**
```json
{
  "data": [
    {
      "offer_id": "offer_001",
      "title": "20% off at Carrefour",
      "description": "Use 500 points for 20% discount on next purchase.",
      "category": "Groceries",
      "merchant": "Carrefour",
      "points_required": 500,
      "valid_until": "2026-07-31",
      "status": "active"
    }
  ],
  "meta": { "count": 12 }
}
```

#### `GET /api/v1/rewards/offers/:offer_id`
- **Purpose:** Detail for a single offer.
- **Backend:** `WalletRewards.OfferStore.get(offer_id)`
- **Response 200:** Single offer object.
- **Response 404:** `ErrorEnvelope` with `code: "offer_not_found"`.

#### `POST /api/v1/rewards/offers/:offer_id/redeem`
- **Purpose:** Redeem an offer by spending the required points.
- **Backend:** `WalletRewards.Commands.RedeemPoints.execute(%{user_id, offer_id, points})`
- **Request Body:** _(no body; offer_id in path, user_id from JWT)_
- **Response 200:**
```json
{
  "data": {
    "redemption_id": "txn_rew_xyz",
    "offer_id": "offer_001",
    "points_spent": 500,
    "points_remaining": 1250,
    "message": "Offer redeemed successfully. Voucher code: CARR-20OFF-XYZ"
  }
}
```
- **Response 422:** `ErrorEnvelope` with `code: "insufficient_points"` if balance < required.
- **Response 422:** `ErrorEnvelope` with `code: "offer_expired"` if offer is no longer active.

#### `GET /api/v1/rewards/points`
- **Purpose:** Current points balance for the authenticated user.
- **Backend:** `WalletRewards.PointsStore.get(user_id)`
- **Response 200:**
```json
{
  "data": {
    "user_id": "user_001",
    "balance": 1750,
    "lifetime_earned": 4200,
    "updated_at": "2026-06-30T09:15:00Z"
  }
}
```
- **Response 200 (no record):** Returns `{ "data": { "balance": 0, "lifetime_earned": 0 } }` — never 404.

#### `GET /api/v1/rewards/points/history`
- **Purpose:** Paginated list of points earn/redeem transactions.
- **Backend:** `WalletRewards.RewardStore.list_by_user(user_id)`
- **Query Params:** `type` (earn/redeem), `from`, `to`, `page`, `per_page`
- **Response 200:**
```json
{
  "data": [
    {
      "txn_id": "txn_rew_001",
      "type": "earn",
      "points": 150,
      "description": "Purchase at Noon.com",
      "inserted_at": "2026-06-28T14:00:00Z"
    }
  ],
  "meta": { "page": 1, "per_page": 20, "total": 8 }
}
```

#### `GET /api/v1/rewards/transactions`
- **Purpose:** All reward transactions for the user (earn + redeem combined), newest first.
- **Backend:** `WalletRewards.RewardStore.list_by_user(user_id)` (no type filter).
- **Response 200:** Same shape as points history.

#### `POST /api/v1/rewards/points/adjust` _(ops/admin only)_
- **Purpose:** Manually credit or debit a user's points (ops agents only).
- **Backend:** `WalletRewards.Commands.AdjustPoints.execute(%{user_id, delta, reason})`
- **Access:** Requires role `ops_supervisor` or `admin` in JWT claims.
- **Request Body:**
```json
{
  "user_id": "user_001",
  "delta": 200,
  "reason": "Goodwill credit for service disruption"
}
```
- **Response 200:**
```json
{
  "data": {
    "user_id": "user_001",
    "new_balance": 1950,
    "adjustment": 200
  }
}
```
- **Response 403:** `ErrorEnvelope` with `code: "insufficient_role"` if caller is not ops_supervisor+.

### Deliverables
- [ ] `apps/wallet_web/lib/wallet_web/controllers/api/rewards_controller.ex`
- [ ] 7 routes added to `router.ex`
- [ ] `apps/wallet_web/test/wallet_web/controllers/api/rewards_controller_test.exs` (min. 18 tests)

---

## Phase A4 — Prepaid Top-Up API

### Goal
Expose the individual card top-up operation and top-up transaction queries as REST endpoints.
This is the integration point for payment gateways, salary disbursement systems, and the mobile
app "Add Money" flow.

### New File
```
apps/wallet_web/lib/wallet_web/controllers/api/prepaid_controller.ex
```

### Routes

```elixir
# Phase A4 — Prepaid Top-Up
post "/prepaid/cards/:card_id/top-up",              PrepaidController, :top_up
get  "/prepaid/cards/:card_id/transactions",         PrepaidController, :card_transactions
get  "/prepaid/transactions/:transaction_id",        PrepaidController, :get_transaction
get  "/prepaid/programs/:program_id/cards",          PrepaidController, :program_cards
get  "/prepaid/programs/:program_id/transactions",   PrepaidController, :program_transactions
```

### Endpoint Specifications

#### `POST /api/v1/prepaid/cards/:card_id/top-up`
- **Purpose:** Credit a prepaid card with a specified amount. This is the API counterpart to
  the "Add Money" button in the LiveView.
- **Backend:** `WalletPrepaid.Commands.TopUpCard.execute(%{card_id, amount, program_id, initiated_by, currency})`
- **Request Body:**
```json
{
  "amount": "100.000",
  "currency": "AED",
  "reference": "SAL-2026-06-001"
}
```
- **Response 200:**
```json
{
  "data": {
    "transaction_id": "topup_xyz",
    "card_id": "card_abc123",
    "amount": "100.000",
    "currency": "AED",
    "new_balance": "350.000",
    "status": "success",
    "inserted_at": "2026-06-30T10:22:00Z"
  }
}
```
- **Response 422:** `ErrorEnvelope` with `code: "not_a_prepaid_card"`.
- **Response 422:** `ErrorEnvelope` with `code: "program_mismatch"`.
- **Response 422:** `ErrorEnvelope` with `code: "amount_must_be_positive"`.
- **Idempotency:** Accepts `Idempotency-Key` header; duplicate requests within 24 h return
  the original response without re-executing.

#### `GET /api/v1/prepaid/cards/:card_id/transactions`
- **Purpose:** List all top-up transactions for a specific prepaid card.
- **Backend:** `WalletPrepaid.TopUpTransactionStore.list_by_card(card_id)`
- **Ownership/program check:** card must belong to `current_user_id` or caller must be `ops_agent+`.
- **Query Params:** `status`, `from`, `to`, `page`, `per_page`
- **Response 200:** Paginated list of transaction objects.

#### `GET /api/v1/prepaid/transactions/:transaction_id`
- **Purpose:** Single top-up transaction detail by ID.
- **Backend:** `WalletPrepaid.TopUpTransactionStore.get(transaction_id)`
- **Response 200:** Single transaction object.
- **Response 404:** `ErrorEnvelope` with `code: "transaction_not_found"`.

#### `GET /api/v1/prepaid/programs/:program_id/cards`
- **Purpose:** List all cards belonging to a prepaid program. _(ops/admin only)_
- **Backend:** `WalletCards.CardStore.list_all()` filtered by `program_id`.
- **Access:** Requires `ops_agent+`.
- **Query Params:** `status`, `page`, `per_page`
- **Response 200:** Paginated card list.

#### `GET /api/v1/prepaid/programs/:program_id/transactions`
- **Purpose:** All top-up transactions across all cards in a program. _(ops/admin only)_
- **Backend:** Fetch all cards for program, then aggregate `TopUpTransactionStore.list_by_card(id)` for each.
- **Access:** Requires `ops_agent+`.
- **Query Params:** `from`, `to`, `page`, `per_page`
- **Response 200:** Paginated and combined transaction list.

### Deliverables
- [ ] `apps/wallet_web/lib/wallet_web/controllers/api/prepaid_controller.ex`
- [ ] 5 routes added to `router.ex`
- [ ] `apps/wallet_web/test/wallet_web/controllers/api/prepaid_controller_test.exs` (min. 12 tests)

---

## Common Patterns Across All Phases

### Controller Skeleton (follow `WalletProductsController` pattern)

```elixir
defmodule WalletWeb.Api.CardsController do
  use WalletWeb, :controller

  alias WalletCards.{CardStore}
  alias WalletCards.Commands.{FreezeCard, UnfreezeCard, BlockCard, UnblockCard, ResetCardPin}
  alias WalletApiContracts.ErrorEnvelope

  # All actions extract user_id from conn.assigns.current_user_id (set by VerifyAccessToken)

  def index(conn, params) do
    user_id = conn.assigns.current_user_id
    cards   = CardStore.list_by_user(user_id)
    filtered = apply_filters(cards, params)
    json(conn, %{data: Enum.map(filtered, &serialize_card/1), meta: %{count: length(filtered)}})
  end

  def freeze(conn, %{"card_id" => card_id}) do
    user_id = conn.assigns.current_user_id
    with {:ok, card}    <- load_owned_card(card_id, user_id),
         :ok            <- validate_status(card, :active),
         {:ok, updated} <- FreezeCard.execute(card.card_id, user_id) do
      json(conn, %{data: serialize_card(updated.card)})
    else
      {:error, :not_found}    -> send_error(conn, 404, "card_not_found", "Card not found.")
      {:error, :unauthorized} -> send_error(conn, 403, "unauthorized", "Access denied.")
      {:error, reason}        -> send_error(conn, 422, to_string(reason), humanize(reason))
    end
  end

  # ... other actions
end
```

### Pagination Helper (shared across A2, A3, A4)

```elixir
defp paginate(list, params) do
  page     = String.to_integer(Map.get(params, "page",     "1"))
  per_page = String.to_integer(Map.get(params, "per_page", "20")) |> min(100)
  total    = length(list)
  offset   = (page - 1) * per_page
  items    = Enum.slice(list, offset, per_page)
  {items, %{page: page, per_page: per_page, total: total}}
end
```

### Date Filter Helper (shared across A2, A3, A4)

```elixir
defp apply_date_filter(list, params, date_field) do
  from = params["from"] && Date.from_iso8601!(params["from"])
  to   = params["to"]   && Date.from_iso8601!(params["to"])
  Enum.filter(list, fn item ->
    d = item |> Map.get(date_field) |> DateTime.to_date()
    (is_nil(from) or Date.compare(d, from) in [:gt, :eq]) and
    (is_nil(to)   or Date.compare(d, to)   in [:lt, :eq])
  end)
end
```

### Error Helper (shared across all phases)

```elixir
defp send_error(conn, status, code, message, opts \\ []) do
  retryable = Keyword.get(opts, :retryable, false)
  conn
  |> put_status(status)
  |> json(ErrorEnvelope.build(code, message, retryable: retryable))
end
```

---

## Router Changes Summary

All new routes go inside the existing `:api_authenticated` pipeline scope in `router.ex`.
No new pipelines are required.

```elixir
scope "/api/v1", WalletWeb.Api, as: :api do
  pipe_through [:api, :api_authenticated]

  # ── existing routes (wallet products, sub-wallets, currency configs) ──
  # ...

  # ── Phase A1 — Cards ──────────────────────────────────────────────────
  get    "/cards",                      CardsController, :index
  get    "/cards/:card_id",             CardsController, :show
  get    "/cards/:card_id/balance",     CardsController, :balance
  post   "/cards/:card_id/freeze",      CardsController, :freeze
  post   "/cards/:card_id/unfreeze",    CardsController, :unfreeze
  post   "/cards/:card_id/block",       CardsController, :block
  post   "/cards/:card_id/unblock",     CardsController, :unblock
  post   "/cards/:card_id/reset-pin",   CardsController, :reset_pin

  # ── Phase A2 — Transactions ───────────────────────────────────────────
  get    "/transactions",                         CardTransactionsController, :user_index
  get    "/transactions/:transaction_id",          CardTransactionsController, :show
  get    "/cards/:card_id/transactions",           CardTransactionsController, :index
  get    "/cards/:card_id/statement",              CardTransactionsController, :statement
  get    "/accounts/:account_id/ledger",           CardTransactionsController, :ledger

  # ── Phase A3 — Rewards & Offers ───────────────────────────────────────
  get    "/rewards/offers",                        RewardsController, :list_offers
  get    "/rewards/offers/:offer_id",              RewardsController, :get_offer
  post   "/rewards/offers/:offer_id/redeem",       RewardsController, :redeem_offer
  get    "/rewards/points",                        RewardsController, :points_balance
  get    "/rewards/points/history",                RewardsController, :points_history
  get    "/rewards/transactions",                  RewardsController, :reward_transactions
  post   "/rewards/points/adjust",                 RewardsController, :adjust_points

  # ── Phase A4 — Prepaid Top-Up ─────────────────────────────────────────
  post   "/prepaid/cards/:card_id/top-up",              PrepaidController, :top_up
  get    "/prepaid/cards/:card_id/transactions",         PrepaidController, :card_transactions
  get    "/prepaid/transactions/:transaction_id",        PrepaidController, :get_transaction
  get    "/prepaid/programs/:program_id/cards",          PrepaidController, :program_cards
  get    "/prepaid/programs/:program_id/transactions",   PrepaidController, :program_transactions
end
```

---

## Test Coverage Plan

Each controller test file follows the existing pattern: `use WalletWeb.ConnCase, async: false`.
All stores are reset in `setup` before each test.

| Phase | Test File | Min Tests | Key Scenarios |
|---|---|---|---|
| A1 | `cards_controller_test.exs` | 16 | list empty/populated, get owned/unowned, freeze/unfreeze cycle, block with reason, reset-pin, 404/403 |
| A2 | `card_transactions_controller_test.exs` | 15 | list by card, list by user, single get, date filter, pagination, 404 on bad txn_id |
| A3 | `rewards_controller_test.exs` | 18 | list/get offers, redeem success/insufficient-points/expired, points balance zero/nonzero, history pagination, adjust-points with role check |
| A4 | `prepaid_controller_test.exs` | 12 | top-up success, non-prepaid 422, program-mismatch 422, list by card, program cards, idempotency header |

---

## Sequence Diagram — Card Top-Up via API (A4 reference)

```
Client          CardsController / PrepaidController      TopUpCard Command      CardStore / TopUpTransactionStore      DB (MySQL)
  │                        │                                    │                             │                            │
  │ POST /prepaid/top-up   │                                    │                             │                            │
  │ ──────────────────────>│                                    │                             │                            │
  │                        │── TopUpCard.execute ──────────────>│                             │                            │
  │                        │                                    │── CardStore.get ───────────>│                            │
  │                        │                                    │<─ {:ok, card} ──────────────│                            │
  │                        │                                    │── CardStore.update ─────────>│                           │
  │                        │                                    │                             │── CardPersistence.persist ─>│
  │                        │                                    │── TopUpTransactionStore.store>│                           │
  │                        │                                    │                             │── TxnPersistence.persist ──>│
  │                        │<── {:ok, updated_card} ────────────│                             │                            │
  │ 200 {data: {…}} ───────│                                    │                             │                            │
```

---

## Open Questions (resolve before implementation)

1. **Pagination strategy** — offset-based (current) or cursor-based (better for large sets)?
   Recommend starting with offset for simplicity, plan cursor migration in a later phase.

2. **Rewards `adjust_points` role gate** — Use JWT `role` claim directly in controller, or
   route through `WalletWeb.Authorization.Policy.evaluate/3`? Recommend Policy for consistency.

3. **Idempotency (A4 top-up)** — Should we wire `WalletState.IdempotencyKey` store for the
   `Idempotency-Key` header on the top-up endpoint, or defer to Phase A5?

4. **Card serialization** — Sensitive fields (`card_number`, `cvv`) must never appear in API
   responses. Confirm: API only returns `last_four`, never full PAN.

5. **A2 Ledger endpoint** — `GetLedgerHistory` requires an `account_id`. Customers may not
   know their `account_id`. Should this be exposed as `/api/v1/me/ledger` (resolves from JWT)
   instead of `/accounts/:account_id/ledger`?

---

## Implementation Order Recommendation

```
A1 (Cards Core)  →  A4 (Prepaid Top-Up)  →  A2 (Transactions)  →  A3 (Rewards)
```

Rationale: A1 is the most-needed integration point for mobile apps. A4 is the core revenue
flow (card funding). A2 depends on A1 existing. A3 is standalone and can run in parallel with A2.

---

*Document status: DRAFT — pending review. Implementation begins after approval.*
