Files
halobestie-clone/requirement/pricing-relational-migration-plan.md
ramadhan sjamsani 1c9d81d81d Pricing: migrate from app_config JSON to relational tables
Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*`
keys in app_config with dedicated `pricing_tiers` and `pricing_promotions`
tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes)
natural-key constraint, optimistic-lock via `updated_at` token returning 409
STALE_WRITE on conflicts. Every mutation writes a history row capturing the
operator (changed_by from request.auth.userId) and change_kind.

CC SettingsPage replaces the JSON-textarea editors with per-row tables —
add / edit / soft-delete / reactivate / reorder, plus a buffered first-session
discount form with the same optimistic-lock contract. `minutes` and `mode` are
read-only on edit since they form the natural key; operators soft-delete and
recreate to change duration.

Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local
`readDiscountConfig` that still read from app_config — would have silently
fallen to hardcoded defaults once the legacy rows were deleted. Now reads from
pricing_promotions via the shared service helper, so CC edits to the first-
session discount affect actual payment pricing on the next request.

Customer-facing GET /api/client/chat/pricing shape unchanged (id values are
now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so
no app changes needed). 27 new backend tests, all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 00:12:11 +08:00

512 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:** 910 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.