# Pricing — Move from `app_config` JSON to Relational Tables

> **Status: SHIPPED (2026-05-15 / 2026-05-16).** This document is now historical reference. Live schema and code paths are the source of truth — read those, not this. Deviations from the original plan are noted in §"Shipped — deviations from plan" below.

> Affects: `backend`, `control_center`. **No changes to `client_app` or `mitra_app`** — the public pricing-response shape is preserved (customer-facing `id` value changed from `"60"` etc. to UUIDs, but the field type and overall envelope are unchanged; the backend looks up tiers by `(mode, duration_minutes)` so the id-value change is functionally inert).

## Shipped — deviations from plan

Decisions overriding what's written below:

1. **Tier id format:** UUID PK (`gen_random_uuid()`) + `UNIQUE (mode, minutes)`, **not** the prefixed `chat-60` / `call-60` TEXT scheme the plan recommended in §1.1. Same shape for `pricing_promotions` — UUID PK + `UNIQUE (eligibility)`. History tables use `tier_id UUID` / `promotion_id UUID` columns referencing those.
2. **Route prefix:** all new routes live under `/internal/config/pricing-tiers` and `/internal/config/first-session-discount`, **not** `/internal/pricing-tiers` as §3.1 shows — because they're registered under `internalConfigRoutes` which already mounts at `/internal/config`.
3. **Rollout:** Option B (direct cutover). Stage 2 dual-read/mirror-write skipped entirely. No regrets.
4. **Stage 5 included a hot-path fix not in the plan:** `client.payment.routes.js` had a **local** `readDiscountConfig` that read first-session-discount from `app_config`, missed by Stage 3. Fixed by exporting `readFirstSessionDiscountConfig` from `pricing.service.js` and rewiring the route. Without this, deleting the legacy `app_config` rows would have silently degraded the payment hot path to hardcoded defaults.
5. **`minutes` and `mode` are NOT patchable** on existing tier rows (PATCH ignores those fields). Both columns form the natural key; allowing updates would let operators silently break the `UNIQUE (mode, minutes)` invariant. Operators must soft-delete and recreate to "change duration".
6. **`original_price_idr` is in the schema but not exposed** in `GET /api/client/chat/pricing`. CC can set it via `POST` / `PATCH` for forward-compat (anchor / strikethrough price). Out of scope to surface to customer until UX signs off.

Stages 1, 3, 4, 5 all shipped. The seven legacy `app_config` keys are deleted. 82/84 backend tests passing (the two failures are pre-existing `session-timer.service.test.js` UUID/string-id issues — unrelated).



This document is the build sequence and contract for moving chat-tier, call-tier, and first-session-discount pricing out of `app_config` (key-value JSONB) into dedicated relational tables.

## Why (one paragraph)

The current pricing storage (one JSONB row per group in `app_config`, full-replace `PATCH`) is exposed to last-write-wins races between admins, has no audit trail or rollback, no DB-side uniqueness/range constraints, and is awkward to query. We want DB-enforced invariants, per-row edits, history, and a foundation that future pricing features (anchor prices, campaigns, coupons, per-mitra rates) can build on. The session-row snapshot pattern (`chat_sessions.price_idr`, `payment_sessions.amount`, `chat_sessions.is_first_session_discount`) continues to insulate historical/transactional data from config changes — **transactions will NOT FK to the new pricing tables**.

---

## Scope

**In scope**
- New tables: `pricing_tiers`, `pricing_promotions`, `pricing_tiers_history`, `pricing_promotions_history`.
- Backfill from current `app_config` rows.
- Rewrite [backend/src/services/pricing.service.js](../backend/src/services/pricing.service.js) read paths.
- Rewrite [backend/src/services/config.service.js](../backend/src/services/config.service.js) setter paths used for pricing (`setPricingTierGroup`, first-session-discount setters).
- Rewrite [backend/src/routes/internal/config.routes.js](../backend/src/routes/internal/config.routes.js) — pricing/discount endpoints become CRUD per-row with optimistic locking.
- Rewrite the pricing/discount section of [control_center/src/pages/settings/SettingsPage.jsx](../control_center/src/pages/settings/SettingsPage.jsx) to use the new endpoints.
- Tests in `backend/__tests__/` covering migration, services, routes.

**Out of scope (intentional)**
- Customer-facing `client_app` and `mitra_app` — `getPricingForCustomer` response shape stays identical. Apps are untouched.
- Adding new pricing features (anchor `original_price_idr`, scheduling, coupons). The new schema includes nullable columns to make these cheap *later*, but no UI exposes them in this change.
- FK from `chat_sessions` / `payment_sessions` to `pricing_tiers` (never).
- Removing or rewriting `chat_sessions.price_idr` / `payment_sessions.amount` snapshot columns. Stays.
- Renaming the existing `app_config` table.

---

## Schema

### `pricing_tiers`

```sql
CREATE TABLE pricing_tiers (
  id                  TEXT PRIMARY KEY,                  -- stable string id, e.g. '12','60'
  mode                TEXT NOT NULL CHECK (mode IN ('chat','call')),
  minutes             INTEGER NOT NULL CHECK (minutes > 0),
  price_idr           INTEGER NOT NULL CHECK (price_idr >= 0),
  original_price_idr  INTEGER CHECK (original_price_idr IS NULL OR original_price_idr >= price_idr),
  tag                 TEXT,                              -- e.g. 'hemat','paling pas'; NULL allowed
  sort_order          INTEGER NOT NULL DEFAULT 0,
  is_active           BOOLEAN NOT NULL DEFAULT true,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (mode, minutes)
);

CREATE INDEX idx_pricing_tiers_mode_active_sort
  ON pricing_tiers(mode, is_active, sort_order);
```

### `pricing_promotions`

```sql
CREATE TABLE pricing_promotions (
  id                  TEXT PRIMARY KEY,                  -- 'first_session' (single row for now)
  enabled             BOOLEAN NOT NULL DEFAULT true,
  eligibility         TEXT NOT NULL                      -- semantic predicate; only 'first_session' implemented in code
                      CHECK (eligibility IN ('first_session')),
  actual_price_idr    INTEGER NOT NULL CHECK (actual_price_idr >= 0),
  gimmick_price_idr   INTEGER CHECK (gimmick_price_idr IS NULL OR gimmick_price_idr >= actual_price_idr),
  duration_minutes    INTEGER NOT NULL CHECK (duration_minutes > 0),
  modes               TEXT[] NOT NULL CHECK (
                        array_length(modes, 1) >= 1
                        AND modes <@ ARRAY['chat','call']::TEXT[]
                      ),
  starts_at           TIMESTAMPTZ,                       -- reserved for future scheduling; ignored by predicate today
  ends_at             TIMESTAMPTZ,                       -- reserved for future scheduling; ignored by predicate today
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```

> Single row today, keyed `'first_session'`. Predicate logic remains hardcoded in `pricing.service.js` (`isCustomerEligibleForFirstSessionDiscount`); the `eligibility` column is a placeholder for when we generalize.

### History tables

Each write to the live tables inserts a row into the corresponding `_history` table. Cheapest to implement in the service layer (not a DB trigger) so we capture `changed_by` from `request.auth.userId` without a session variable dance.

```sql
CREATE TABLE pricing_tiers_history (
  history_id          BIGSERIAL PRIMARY KEY,
  id                  TEXT NOT NULL,
  mode                TEXT NOT NULL,
  minutes             INTEGER NOT NULL,
  price_idr           INTEGER NOT NULL,
  original_price_idr  INTEGER,
  tag                 TEXT,
  sort_order          INTEGER NOT NULL,
  is_active           BOOLEAN NOT NULL,
  changed_by          UUID,                              -- cc_users.id, nullable for system writes
  change_kind         TEXT NOT NULL CHECK (change_kind IN ('create','update','delete')),
  changed_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_pricing_tiers_history_id_time
  ON pricing_tiers_history(id, changed_at DESC);

-- same shape for pricing_promotions_history
```

### What this replaces in `app_config`

| `app_config` key | Becomes |
|---|---|
| `pricing_chat_tiers_json` | `pricing_tiers` rows where `mode='chat'` |
| `pricing_call_tiers_json` | `pricing_tiers` rows where `mode='call'` |
| `first_session_discount_enabled` | `pricing_promotions.enabled` |
| `first_session_discount_actual_price_idr` | `pricing_promotions.actual_price_idr` |
| `first_session_discount_gimmick_price_idr` | `pricing_promotions.gimmick_price_idr` |
| `first_session_discount_duration_minutes` | `pricing_promotions.duration_minutes` |
| `first_session_discount_modes` | `pricing_promotions.modes` |

The seven `app_config` rows above are **deleted** at the end of the rollout (Stage 5).

---

## Build Order (5 stages)

The dependency graph forces this order:

1. **Schema + backfill** — DDL, backfill SQL, seed defaults. No live code reads new tables yet.
2. **Service-layer dual-read** — `pricing.service.js` reads from new tables, writes still go to `app_config` AND mirror to new tables. Live traffic switches over to new-table reads but legacy writes still work. Lets us verify equivalence in prod.
3. **Routes + cutover** — internal routes write only to new tables; mirror-write is removed. Optimistic locking introduced. Old endpoints either redirect or are deprecated.
4. **Control center UI** — `SettingsPage.jsx` pricing/discount sections rewritten against new endpoints.
5. **Cleanup** — drop old `app_config` rows, remove dual-read shim, remove deprecated route handlers.

A safer alternative is to skip stage 2 (no dual-read) and just ship 1+3+4 in one go with a fast rollback. See **Rollout strategy** below for the call.

---

# Stage 1 — Schema + backfill

### 1.1 Migration ([backend/src/db/migrate.js](../backend/src/db/migrate.js))

Append a new section. Keep it idempotent — wrap all DDL in `IF NOT EXISTS` and gate the backfill behind a "table is empty" check so re-runs are safe.

```js
// --- Pricing tables (relational migration of app_config pricing rows) ---

await sql`CREATE TABLE IF NOT EXISTS pricing_tiers ( … )`
await sql`CREATE INDEX IF NOT EXISTS idx_pricing_tiers_mode_active_sort ON pricing_tiers(mode, is_active, sort_order)`
await sql`CREATE TABLE IF NOT EXISTS pricing_promotions ( … )`
await sql`CREATE TABLE IF NOT EXISTS pricing_tiers_history ( … )`
await sql`CREATE INDEX IF NOT EXISTS idx_pricing_tiers_history_id_time ON pricing_tiers_history(id, changed_at DESC)`
await sql`CREATE TABLE IF NOT EXISTS pricing_promotions_history ( … )`

// --- Backfill: only runs when pricing_tiers is empty ---

const [tierCheck] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
if (tierCheck.n === 0) {
  // Read JSON from app_config, fall back to hardcoded defaults if absent.
  const [chatRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
  const [callRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`

  const chatTiers = chatRow?.value?.tiers ?? [/* DEFAULT_CHAT_TIERS from pricing.service.js */]
  const callTiers = callRow?.value?.tiers ?? [/* DEFAULT_CALL_TIERS from pricing.service.js */]

  for (const [mode, tiers] of [['chat', chatTiers], ['call', callTiers]]) {
    let order = 0
    for (const t of tiers) {
      await sql`
        INSERT INTO pricing_tiers (id, mode, minutes, price_idr, tag, sort_order, is_active)
        VALUES (
          ${mode + '-' + t.id},     -- prefix mode to avoid 'chat-60' vs 'call-60' collision on shared PK
          ${mode},
          ${t.minutes},
          ${t.price_idr},
          ${t.tag},
          ${order++},
          true
        )
        ON CONFLICT (id) DO NOTHING
      `
    }
  }
}

// --- Backfill: pricing_promotions ---

const [promoCheck] = await sql`SELECT COUNT(*)::int AS n FROM pricing_promotions WHERE id = 'first_session'`
if (promoCheck.n === 0) {
  const keys = [
    'first_session_discount_enabled',
    'first_session_discount_actual_price_idr',
    'first_session_discount_gimmick_price_idr',
    'first_session_discount_duration_minutes',
    'first_session_discount_modes',
  ]
  const rows = await sql`SELECT key, value FROM app_config WHERE key IN ${sql(keys)}`
  const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))

  await sql`
    INSERT INTO pricing_promotions (id, enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes)
    VALUES (
      'first_session',
      ${byKey.first_session_discount_enabled ?? true},
      'first_session',
      ${byKey.first_session_discount_actual_price_idr ?? 2000},
      ${byKey.first_session_discount_gimmick_price_idr ?? 12000},
      ${byKey.first_session_discount_duration_minutes ?? 12},
      ${byKey.first_session_discount_modes ?? ['chat']}
    )
    ON CONFLICT (id) DO NOTHING
  `
}
```

> **Design note on tier `id`.** Old IDs are `'5','12','30','60','120'` (chat) and `'10','20','45','60'` (call). `'60'` collides across modes if we make `id` the PK. Two options: (a) make PK `(mode, id)` composite, or (b) prefix as `'chat-60'` / `'call-60'`. **Choose (b)** — keeps a flat string key, matches Stripe/Xendit conventions, simpler API URLs (`/internal/pricing-tiers/chat-60`). The public-facing tier object sent to `client_app` keeps the bare `id` field (without the prefix) so the customer-facing shape is unchanged.

### 1.2 Hardcoded fallbacks in [pricing.service.js](../backend/src/services/pricing.service.js)

Keep `DEFAULT_CHAT_TIERS` / `DEFAULT_CALL_TIERS` / `DEFAULT_DISCOUNT` as in-memory fallbacks for the case where the table is empty (e.g. fresh dev DB, or test fixtures). Read paths in Stage 2 will use them.

---

# Stage 2 — Service-layer dual-read (optional safety net)

> Skip this stage and go straight to Stage 3 if we accept a brief outage window and have a tested rollback. Decide before starting — see Rollout below.

### 2.1 Rewrite read paths in [pricing.service.js](../backend/src/services/pricing.service.js)

```js
const readChatTiers = async () => {
  const rows = await sql`
    SELECT id, minutes, price_idr, original_price_idr, tag
    FROM pricing_tiers
    WHERE mode = 'chat' AND is_active = true
    ORDER BY sort_order, minutes
  `
  if (rows.length === 0) return DEFAULT_CHAT_TIERS
  return rows.map((r) => ({
    id: r.id.replace(/^chat-/, ''),         // strip 'chat-' prefix for client-facing shape
    minutes: r.minutes,
    price_idr: r.price_idr,
    tag: r.tag,
    // NOTE: original_price_idr deliberately NOT exposed in the public response yet (out of scope).
  }))
}

// readCallTiers — symmetric.

const readDiscountConfig = async () => {
  const [row] = await sql`
    SELECT enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes
    FROM pricing_promotions
    WHERE id = 'first_session'
  `
  if (!row) return DEFAULT_DISCOUNT
  return {
    enabled: row.enabled,
    actual_price_idr: row.actual_price_idr,
    gimmick_price_idr: row.gimmick_price_idr,
    duration_minutes: row.duration_minutes,
    modes: row.modes,
  }
}
```

**Customer-facing API response is unchanged.** Verify by snapshotting `GET /api/client/pricing` before and after.

### 2.2 Mirror writes in [config.service.js](../backend/src/services/config.service.js)

`setPricingTierGroup` and the first-session-discount setters keep writing to `app_config` **and** also write to the new tables. This lets us point reads at the new tables in Stage 2 while still being able to revert reads to `app_config` without losing data.

Mirror-write is throwaway code — it gets removed in Stage 5.

---

# Stage 3 — Routes + optimistic locking

### 3.1 New CRUD endpoints in [config.routes.js](../backend/src/routes/internal/config.routes.js)

Replace the single full-replace `PATCH /internal/pricing-tiers/:mode` with per-row CRUD. Keep the GET unchanged in shape.

| Method | Path | Body | Purpose |
|---|---|---|---|
| GET | `/internal/pricing-tiers` | — | Returns `{ chat: [...], call: [...] }` (unchanged shape). Each tier now carries `updated_at`. |
| POST | `/internal/pricing-tiers` | `{ mode, minutes, price_idr, tag?, sort_order? }` | Create. Server generates `id = ${mode}-${minutes}`. |
| PATCH | `/internal/pricing-tiers/:id` | `{ updated_at, price_idr?, tag?, sort_order?, is_active? }` | Per-row update. `updated_at` is the **optimistic-lock token** — required, must match current row, else 409. |
| DELETE | `/internal/pricing-tiers/:id` | — | Soft delete (`is_active = false`). Hard delete reserved for admin maintenance, not exposed via API. |
| GET | `/internal/first-session-discount` | — | Unchanged response shape, plus `updated_at`. |
| PATCH | `/internal/first-session-discount` | `{ updated_at, enabled?, actual_price_idr?, gimmick_price_idr?, duration_minutes?, modes? }` | Same optimistic-lock contract. |

#### Optimistic-lock semantics

- GET returns the row's `updated_at` (ISO-8601) alongside the data.
- PATCH/DELETE must include the `updated_at` the client last saw.
- Service compares server-side; if different → `409 Conflict` with `{ error: { code: 'STALE_WRITE', message: 'Pricing tier was updated by someone else. Reload and try again.', server_updated_at } }`.
- CC handles 409 by re-fetching and re-prompting the operator.

#### History writes

Wrap each create/update/delete in a single SQL transaction with the corresponding `INSERT INTO pricing_tiers_history` (or `_promotions_history`). `changed_by = request.auth.userId`. `change_kind` ∈ `'create'|'update'|'delete'`.

### 3.2 Drop mirror-writes

`app_config` setter mirror writes from Stage 2 are removed. Reads have already been on new tables; writes now too. Old `app_config` rows stop receiving updates but still exist for safety. They are removed in Stage 5.

### 3.3 `publishConfigInvalidate` channels

Keep using existing Valkey channels (`pricing_chat_tiers_json`, `pricing_call_tiers_json`, `first_session_discount`) so subscribers (`pricing.service.js` cache, if any) keep working without changes. The channel names are an internal contract — no need to rename.

---

# Stage 4 — Control center

### 4.1 [SettingsPage.jsx](../control_center/src/pages/settings/SettingsPage.jsx) rewrite

Two sections change:

**Pricing tiers editor (around line 633)**
- Replace the JSON-textarea per mode with a per-row table:
  - Columns: minutes, price, tag, sort order, active, actions (edit/delete).
  - "Add tier" button → modal/inline form.
  - "Edit" → inline editable row or modal.
  - Save → individual POST/PATCH/DELETE per change. Better UX than "save the entire blob".
- On 409 STALE_WRITE: toast + auto-refetch.
- React Query `queryKey: ['config-pricing-tiers']` stays; just the mutation functions and UI change.

**First-session discount editor (around line 559)**
- Stays as a single form (it's one row).
- Add an `updated_at` hidden state, populated from GET, sent in PATCH.

### 4.2 No other CC pages to touch

The dashboard and other pages don't read pricing directly. The new endpoints stay namespaced under `/internal/`.

---

# Stage 5 — Cleanup

After 1 week of stable operation in production:

1. Remove dual-write code in [config.service.js](../backend/src/services/config.service.js) (already done in Stage 3 if we skipped Stage 2 — verify).
2. Remove DEFAULT_* in-memory fallbacks in [pricing.service.js](../backend/src/services/pricing.service.js) **only if** we add a "must-exist seed" assertion in migrate. Otherwise keep them.
3. Delete `app_config` pricing rows in a one-shot migration:
   ```sql
   DELETE FROM app_config WHERE key IN (
     'pricing_chat_tiers_json', 'pricing_call_tiers_json',
     'first_session_discount_enabled', 'first_session_discount_actual_price_idr',
     'first_session_discount_gimmick_price_idr', 'first_session_discount_duration_minutes',
     'first_session_discount_modes'
   )
   ```
4. Remove deprecated route handlers (the old full-replace `PATCH /internal/pricing-tiers/:mode` if it was kept as a redirect).

---

## API contract — customer-facing (unchanged, verify in tests)

```jsonc
GET /api/client/pricing  →  200 OK
{
  "chat": {
    "tiers": [
      { "id": "5",   "minutes": 5,   "price_idr": 5000,  "tag": null },
      { "id": "12",  "minutes": 12,  "price_idr": 12000, "tag": "paling pas" },
      …
    ]
  },
  "call": { "tiers": [ … ] },
  "first_session_discount": {
    "eligible": false,
    "actual_price_idr": 2000,
    "gimmick_price_idr": 12000,
    "duration_minutes": 12,
    "modes": ["chat"]
  }
}
```

> Snapshot this response from prod before Stage 2 ships. Assert byte-exact match in `__tests__/pricing.service.test.js` after.

## API contract — internal (new shape)

```jsonc
GET /internal/pricing-tiers  →  200 OK
{
  "success": true,
  "data": {
    "chat": [
      { "id": "5", "mode": "chat", "minutes": 5, "price_idr": 5000, "tag": null,
        "sort_order": 0, "is_active": true, "updated_at": "2026-05-15T10:23:01Z" },
      …
    ],
    "call": [ … ]
  }
}

POST /internal/pricing-tiers
  body: { "mode": "chat", "minutes": 90, "price_idr": 60000, "tag": null }
  → 201 Created { "success": true, "data": { …row… } }

PATCH /internal/pricing-tiers/chat-60
  body: { "updated_at": "2026-05-15T10:23:01Z", "price_idr": 50000 }
  → 200 OK   { "success": true, "data": { …new row… } }
  → 409 Conflict on stale updated_at
  → 422 on validation
  → 404 on unknown id

DELETE /internal/pricing-tiers/chat-60
  → 200 OK   { "success": true, "data": { …row with is_active=false… } }
```

---

## Tests

### Backend

1. `__tests__/db/pricing-migration.test.js`
   - Empty DB → backfill seeds defaults.
   - Pre-existing `app_config` JSON → backfill copies those values.
   - Re-run migration → no duplicate rows, no clobbered edits.

2. `__tests__/pricing.service.test.js`
   - `getPricingForCustomer` response shape byte-equal to pre-migration snapshot.
   - `isValidTier`, `findTier` continue to work for all default tiers.
   - First-session discount eligibility predicate unchanged (phone-verified + no completed sessions).

3. `__tests__/routes/internal/pricing-tiers.test.js`
   - CRUD happy path: create, update, soft-delete a tier.
   - 409 on stale `updated_at`.
   - 422 on bad payload (negative price, duplicate `(mode,minutes)`, etc.).
   - 404 on unknown id.
   - History row inserted on every change with correct `changed_by`.

4. `__tests__/routes/internal/first-session-discount.test.js`
   - Same optimistic-lock + history coverage.

### Manual verification

- CC: edit chat tier price, verify customer app sees new price after Valkey invalidation (≤1s).
- CC: two operators editing the same tier → one gets a 409 banner.
- DB: `SELECT * FROM pricing_tiers_history WHERE id='chat-12' ORDER BY changed_at DESC` shows full edit lineage.

---

## Rollout strategy — pick one

**Option A — Dual-read, then cut over (safest)**
Stage 1 → Stage 2 (dual-read, mirror-write) → bake 24h → Stage 3 (cut writes) → Stage 4 → bake 1 week → Stage 5 (cleanup).
**Total:** 9–10 days clock time, ~1.5 days work. Zero downtime, easy rollback.

**Option B — Direct cutover (cheaper)**
Stage 1 → 3 → 4 in one deploy, skip Stage 2. Rollback = revert deploy + manual restore of any pricing edits that happened post-cutover from `pricing_tiers_history`.
**Total:** 1 day work. Brief risk window during deploy.

**Recommendation:** Option B. Pricing edits are infrequent (operators rarely change tiers), so the "lose edits made during the cutover window" risk is small. The history table gives us audit-level rollback. Save the dual-read complexity for systems with high write QPS.

If we hit unexpected issues, the rollback path for Option B is:
1. Revert backend deploy (old code reads `app_config` JSON, which still exists — we don't delete it until Stage 5).
2. Manually replay any post-cutover edits from `pricing_tiers_history` into `app_config` JSON.

---

## Effort estimate

| Stage | Effort |
|---|---|
| 1 (schema + backfill) | 1h |
| 2 (dual-read — skip if Option B) | 1.5h |
| 3 (routes + optimistic lock + history) | 2h |
| 4 (CC settings page) | 2h |
| Tests | 1.5h |
| Manual verification + bake | 0.5h |
| **Total — Option A** | **8.5h** |
| **Total — Option B** | **7h** |

---

## Open questions for review

1. **Rollout option** — A (dual-read) vs B (direct cutover)? Recommendation: B.
2. **Tier id format** — confirm `chat-60` / `call-60` prefix scheme. Alternative: composite PK `(mode, id)` keeps bare ids but adds slight complexity in routes (`/internal/pricing-tiers/chat/60`).
3. **`original_price_idr` exposure** — column is added but **not** exposed in `GET /api/client/pricing` in this change. Confirm: ship as schema-only now, add to client-facing response in a separate change?
4. **History retention** — `pricing_tiers_history` grows unbounded. Acceptable for now (volume is tiny); revisit if it crosses 100k rows.
5. **Old route handler** — keep `PATCH /internal/pricing-tiers/:mode` (full-replace) as a thin wrapper that translates to per-row CRUD for one release, or hard-delete in Stage 3? CC is the only caller; hard-deleting is fine if we ship CC + backend together.
6. **`is_active = false` tiers** — confirm: hidden from `GET /api/client/pricing` (read paths filter `is_active = true`). Soft-deleted tiers stay in `pricing_tiers` indefinitely for FK preservation, even though sessions don't FK to them (yet).

---

## Out-of-scope follow-ups (for later)

- Expose `original_price_idr` (anchor price for strikethrough) in customer pricing response — separate small change once UX is signed off.
- Scheduling fields (`starts_at` / `ends_at`) on promotions — wire into eligibility predicate when marketing asks.
- Per-mitra pricing — add `mitra_id` nullable column on `pricing_tiers`, or a `pricing_overrides` table — design when needed.
- Coupon codes — new `pricing_coupons` table that **does** FK to `pricing_tiers` (config-to-config FK is fine).
- Multi-currency — drop `_idr` suffix, add `currency` column. Wait until international.
