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>
26 KiB
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 toclient_appormitra_app— the public pricing-response shape is preserved (customer-facingidvalue 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:
- Tier id format: UUID PK (
gen_random_uuid()) +UNIQUE (mode, minutes), not the prefixedchat-60/call-60TEXT scheme the plan recommended in §1.1. Same shape forpricing_promotions— UUID PK +UNIQUE (eligibility). History tables usetier_id UUID/promotion_id UUIDcolumns referencing those. - Route prefix: all new routes live under
/internal/config/pricing-tiersand/internal/config/first-session-discount, not/internal/pricing-tiersas §3.1 shows — because they're registered underinternalConfigRouteswhich already mounts at/internal/config. - Rollout: Option B (direct cutover). Stage 2 dual-read/mirror-write skipped entirely. No regrets.
- Stage 5 included a hot-path fix not in the plan:
client.payment.routes.jshad a localreadDiscountConfigthat read first-session-discount fromapp_config, missed by Stage 3. Fixed by exportingreadFirstSessionDiscountConfigfrompricing.service.jsand rewiring the route. Without this, deleting the legacyapp_configrows would have silently degraded the payment hot path to hardcoded defaults. minutesandmodeare NOT patchable on existing tier rows (PATCH ignores those fields). Both columns form the natural key; allowing updates would let operators silently break theUNIQUE (mode, minutes)invariant. Operators must soft-delete and recreate to "change duration".original_price_idris in the schema but not exposed inGET /api/client/chat/pricing. CC can set it viaPOST/PATCHfor 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_configrows. - Rewrite backend/src/services/pricing.service.js read paths.
- Rewrite backend/src/services/config.service.js setter paths used for pricing (
setPricingTierGroup, first-session-discount setters). - Rewrite 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 to use the new endpoints.
- Tests in
backend/__tests__/covering migration, services, routes.
Out of scope (intentional)
- Customer-facing
client_appandmitra_app—getPricingForCustomerresponse 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_sessionstopricing_tiers(never). - Removing or rewriting
chat_sessions.price_idr/payment_sessions.amountsnapshot columns. Stays. - Renaming the existing
app_configtable.
Schema
pricing_tiers
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
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 inpricing.service.js(isCustomerEligibleForFirstSessionDiscount); theeligibilitycolumn 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.
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:
- Schema + backfill — DDL, backfill SQL, seed defaults. No live code reads new tables yet.
- Service-layer dual-read —
pricing.service.jsreads from new tables, writes still go toapp_configAND mirror to new tables. Live traffic switches over to new-table reads but legacy writes still work. Lets us verify equivalence in prod. - Routes + cutover — internal routes write only to new tables; mirror-write is removed. Optimistic locking introduced. Old endpoints either redirect or are deprecated.
- Control center UI —
SettingsPage.jsxpricing/discount sections rewritten against new endpoints. - Cleanup — drop old
app_configrows, 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)
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.
// --- 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 makeidthe 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 toclient_appkeeps the bareidfield (without the prefix) so the customer-facing shape is unchanged.
1.2 Hardcoded fallbacks in 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
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
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
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_atthe client last saw. - Service compares server-side; if different →
409 Conflictwith{ 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 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_athidden 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:
- Remove dual-write code in config.service.js (already done in Stage 3 if we skipped Stage 2 — verify).
- Remove DEFAULT_* in-memory fallbacks in pricing.service.js only if we add a "must-exist seed" assertion in migrate. Otherwise keep them.
- Delete
app_configpricing rows in a one-shot migration: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' ) - Remove deprecated route handlers (the old full-replace
PATCH /internal/pricing-tiers/:modeif it was kept as a redirect).
API contract — customer-facing (unchanged, verify in tests)
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.jsafter.
API contract — internal (new shape)
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
-
__tests__/db/pricing-migration.test.js- Empty DB → backfill seeds defaults.
- Pre-existing
app_configJSON → backfill copies those values. - Re-run migration → no duplicate rows, no clobbered edits.
-
__tests__/pricing.service.test.jsgetPricingForCustomerresponse shape byte-equal to pre-migration snapshot.isValidTier,findTiercontinue to work for all default tiers.- First-session discount eligibility predicate unchanged (phone-verified + no completed sessions).
-
__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.
-
__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 DESCshows 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:
- Revert backend deploy (old code reads
app_configJSON, which still exists — we don't delete it until Stage 5). - Manually replay any post-cutover edits from
pricing_tiers_historyintoapp_configJSON.
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
- Rollout option — A (dual-read) vs B (direct cutover)? Recommendation: B.
- Tier id format — confirm
chat-60/call-60prefix scheme. Alternative: composite PK(mode, id)keeps bare ids but adds slight complexity in routes (/internal/pricing-tiers/chat/60). original_price_idrexposure — column is added but not exposed inGET /api/client/pricingin this change. Confirm: ship as schema-only now, add to client-facing response in a separate change?- History retention —
pricing_tiers_historygrows unbounded. Acceptable for now (volume is tiny); revisit if it crosses 100k rows. - 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. is_active = falsetiers — confirm: hidden fromGET /api/client/pricing(read paths filteris_active = true). Soft-deleted tiers stay inpricing_tiersindefinitely 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_idnullable column onpricing_tiers, or apricing_overridestable — design when needed. - Coupon codes — new
pricing_couponstable that does FK topricing_tiers(config-to-config FK is fine). - Multi-currency — drop
_idrsuffix, addcurrencycolumn. Wait until international.