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>
512 lines
26 KiB
Markdown
512 lines
26 KiB
Markdown
# 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.
|