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

26 KiB
Raw Permalink Blame History

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

Out of scope (intentional)

  • Customer-facing client_app and mitra_appgetPricingForCustomer 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

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

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-readpricing.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 UISettingsPage.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)

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

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_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 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 (already done in Stage 3 if we skipped Stage 2 — verify).
  2. Remove DEFAULT_* in-memory fallbacks in 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:
    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)

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)

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