# 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.