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>
This commit is contained in:
28
backend/package-lock.json
generated
28
backend/package-lock.json
generated
@@ -90,6 +90,29 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
@@ -1141,7 +1164,6 @@
|
||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.5",
|
||||
@@ -3601,7 +3623,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -3699,7 +3720,6 @@
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4537,7 +4557,6 @@
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -4616,7 +4635,6 @@
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
|
||||
@@ -679,6 +679,242 @@ const migrate = async () => {
|
||||
ADD COLUMN IF NOT EXISTS account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL
|
||||
`
|
||||
|
||||
// --- Pricing relational migration — Stage 1 (schema + backfill only) ---
|
||||
//
|
||||
// Moves the pricing_chat_tiers_json / pricing_call_tiers_json /
|
||||
// first_session_discount_* rows from app_config into dedicated relational
|
||||
// tables. Stage 1 is schema-only: the live read paths in
|
||||
// pricing.service.js continue to read app_config until Stage 3 cuts them
|
||||
// over. The seven legacy app_config rows are NOT deleted here — that's
|
||||
// Stage 5.
|
||||
//
|
||||
// Design notes:
|
||||
// - PK is UUID (gen_random_uuid()) instead of the doc's TEXT prefix
|
||||
// scheme ("chat-60"). Backend lookups go through (mode, minutes),
|
||||
// not the id, so the id is purely internal — there is no benefit to
|
||||
// a human-readable surrogate key, and UUIDs match the convention
|
||||
// used by every other table in this schema.
|
||||
// - UNIQUE (mode, minutes) on pricing_tiers and UNIQUE (eligibility)
|
||||
// on pricing_promotions enforce the natural keys at the DB level.
|
||||
// - History tables reference the live row via tier_id / promotion_id
|
||||
// (UUID) — column rename from the doc's `id` to avoid shadowing the
|
||||
// history row's own pk.
|
||||
// - original_price_idr is shipped as schema-only — not exposed in
|
||||
// GET /api/client/pricing in this Stage. Surfacing it to clients is
|
||||
// a separate out-of-scope follow-up.
|
||||
// - sort_order: operator-controlled ordering distinct from minutes.
|
||||
// Backfill seeds it as the array index from the existing JSON so any
|
||||
// curated order is preserved.
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_tiers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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,
|
||||
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)
|
||||
)
|
||||
`
|
||||
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
eligibility TEXT NOT NULL UNIQUE
|
||||
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,
|
||||
ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_tiers_history (
|
||||
history_id BIGSERIAL PRIMARY KEY,
|
||||
tier_id UUID 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,
|
||||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_tiers_history_tier_time
|
||||
ON pricing_tiers_history (tier_id, changed_at DESC)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS pricing_promotions_history (
|
||||
history_id BIGSERIAL PRIMARY KEY,
|
||||
promotion_id UUID NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
eligibility TEXT NOT NULL,
|
||||
actual_price_idr INTEGER NOT NULL,
|
||||
gimmick_price_idr INTEGER,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
modes TEXT[] NOT NULL,
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
changed_by UUID,
|
||||
change_kind TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
|
||||
await sql`
|
||||
CREATE INDEX IF NOT EXISTS idx_pricing_promotions_history_promo_time
|
||||
ON pricing_promotions_history (promotion_id, changed_at DESC)
|
||||
`
|
||||
|
||||
// --- Backfill: pricing_tiers ---
|
||||
//
|
||||
// Only seeds when the table is empty so re-runs don't clobber operator
|
||||
// edits or insert duplicates. The unique(mode, minutes) constraint is a
|
||||
// belt-and-suspenders backstop in case a half-finished run is retried.
|
||||
//
|
||||
// Source preference order per mode:
|
||||
// 1. app_config.pricing_{chat,call}_tiers_json (if the row exists)
|
||||
// 2. Hardcoded defaults that match DEFAULT_{CHAT,CALL}_TIERS in
|
||||
// pricing.service.js. We duplicate them here rather than importing
|
||||
// because migrate.js is a standalone script.
|
||||
|
||||
const DEFAULT_CHAT_TIERS_BACKFILL = [
|
||||
{ minutes: 5, price_idr: 5000, tag: null },
|
||||
{ minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ minutes: 60, price_idr: 45000, tag: null },
|
||||
{ minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]
|
||||
const DEFAULT_CALL_TIERS_BACKFILL = [
|
||||
{ minutes: 10, price_idr: 9000, tag: null },
|
||||
{ minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ minutes: 45, price_idr: 35000, tag: null },
|
||||
{ minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]
|
||||
|
||||
const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
|
||||
if (tierCount === 0) {
|
||||
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 = Array.isArray(chatRow?.value?.tiers) ? chatRow.value.tiers : DEFAULT_CHAT_TIERS_BACKFILL
|
||||
const callTiers = Array.isArray(callRow?.value?.tiers) ? callRow.value.tiers : DEFAULT_CALL_TIERS_BACKFILL
|
||||
|
||||
for (const [mode, tiers] of [['chat', chatTiers], ['call', callTiers]]) {
|
||||
let order = 0
|
||||
for (const t of tiers) {
|
||||
await sql`
|
||||
INSERT INTO pricing_tiers (mode, minutes, price_idr, tag, sort_order, is_active)
|
||||
VALUES (
|
||||
${mode},
|
||||
${t.minutes},
|
||||
${t.price_idr},
|
||||
${t.tag ?? null},
|
||||
${order++},
|
||||
true
|
||||
)
|
||||
ON CONFLICT (mode, minutes) DO NOTHING
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Backfill: pricing_promotions (single 'first_session' row) ---
|
||||
//
|
||||
// Defaults below match DEFAULT_DISCOUNT in pricing.service.js. The five
|
||||
// legacy app_config keys override these if present.
|
||||
|
||||
const [{ n: promoCount }] = await sql`
|
||||
SELECT COUNT(*)::int AS n FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
if (promoCount === 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]))
|
||||
|
||||
const enabled = byKey.first_session_discount_enabled ?? true
|
||||
const actual = byKey.first_session_discount_actual_price_idr ?? 2000
|
||||
const gimmick = byKey.first_session_discount_gimmick_price_idr ?? 12000
|
||||
const duration = byKey.first_session_discount_duration_minutes ?? 12
|
||||
const modes = Array.isArray(byKey.first_session_discount_modes)
|
||||
? byKey.first_session_discount_modes
|
||||
: ['chat']
|
||||
|
||||
await sql`
|
||||
INSERT INTO pricing_promotions (
|
||||
enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
)
|
||||
VALUES (
|
||||
${enabled},
|
||||
'first_session',
|
||||
${actual},
|
||||
${gimmick},
|
||||
${duration},
|
||||
${modes}
|
||||
)
|
||||
ON CONFLICT (eligibility) DO NOTHING
|
||||
`
|
||||
}
|
||||
|
||||
// --- Pricing relational migration — Stage 5 cleanup ---
|
||||
//
|
||||
// The seven legacy `app_config` rows below were the JSON-on-app_config
|
||||
// source of truth for pricing tiers and the first-session discount, copied
|
||||
// into `pricing_tiers` / `pricing_promotions` by the Stage 1 backfill above.
|
||||
// Stage 3 cut every read path over to the relational tables; this delete
|
||||
// removes the now-orphaned rows so operator edits in CC can't get out of
|
||||
// sync with the live source.
|
||||
//
|
||||
// Idempotent: a fresh dev DB just deletes zero rows. A previously-migrated
|
||||
// DB on this revision is a no-op. The seed of these keys in the Phase 4
|
||||
// app_config INSERT block above (~line 627) uses ON CONFLICT (key) DO
|
||||
// NOTHING — so even if the seed runs *after* this delete during a future
|
||||
// refactor, we don't accidentally resurrect them on the next pass.
|
||||
await sql`
|
||||
DELETE FROM app_config
|
||||
WHERE key IN (
|
||||
'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',
|
||||
'pricing_chat_tiers_json',
|
||||
'pricing_call_tiers_json'
|
||||
)
|
||||
`
|
||||
|
||||
console.log('Migration complete.')
|
||||
await sql.end()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { authenticate, requirePermission } from '../../plugins/auth.js'
|
||||
import { getCcUserById } from '../../services/cc-user.service.js'
|
||||
import { UserType, ExtensionTimeoutAction } from '../../constants.js'
|
||||
import { UserType, ExtensionTimeoutAction, SessionMode } from '../../constants.js'
|
||||
import { publish } from '../../plugins/valkey.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import {
|
||||
getAnonymityConfig, setAnonymityConfig,
|
||||
getMaxCustomersPerMitra, setMaxCustomersPerMitra,
|
||||
@@ -14,11 +15,44 @@ import {
|
||||
getReturningChatConfirmationTimeoutSeconds, setReturningChatConfirmationTimeoutSeconds,
|
||||
getExtensionDefaultActionOnTimeout, setExtensionDefaultActionOnTimeout,
|
||||
getPairingBlastTimeoutSeconds, setPairingBlastTimeoutSeconds,
|
||||
getFirstSessionDiscountConfig, setFirstSessionDiscountConfig,
|
||||
getSupportHandles, setSupportHandles,
|
||||
getPricingTierGroups, setPricingTierGroup,
|
||||
} from '../../services/config.service.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// RFC 4122 UUID format (any variant). Loose check is fine — Postgres will reject
|
||||
// malformed UUIDs at query time, but rejecting early gives a clean 422 instead.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
const validation = (message, field) => ({
|
||||
success: false,
|
||||
error: { code: 'VALIDATION', message, ...(field ? { field } : {}) },
|
||||
})
|
||||
|
||||
const staleWrite = (serverUpdatedAt) => ({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'STALE_WRITE',
|
||||
message: 'Pricing tier was updated by someone else. Reload and try again.',
|
||||
server_updated_at: serverUpdatedAt,
|
||||
},
|
||||
})
|
||||
|
||||
const notFound = (message = 'Not found') => ({
|
||||
success: false,
|
||||
error: { code: 'NOT_FOUND', message },
|
||||
})
|
||||
|
||||
// `updated_at` from a GET response may arrive as an ISO-8601 string or a JS Date.
|
||||
// Compare on the underlying millisecond value to dodge millisecond-precision drift
|
||||
// from string round-tripping through PG's timestamptz <-> postgres.js Date.
|
||||
const updatedAtMatches = (a, b) => {
|
||||
if (!a || !b) return false
|
||||
const aMs = a instanceof Date ? a.getTime() : new Date(a).getTime()
|
||||
const bMs = b instanceof Date ? b.getTime() : new Date(b).getTime()
|
||||
return Number.isFinite(aMs) && Number.isFinite(bMs) && aMs === bMs
|
||||
}
|
||||
|
||||
const attachCcUser = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CC_USER) {
|
||||
return reply.code(403).send({ success: false, error: { code: 'FORBIDDEN', message: 'Not a control center user' } })
|
||||
@@ -288,21 +322,320 @@ export const internalConfigRoutes = async (app) => {
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 4: First-session discount ---
|
||||
// --- Phase 4 / Stage 3 (relational pricing): per-row CRUD with optimistic-lock ---
|
||||
//
|
||||
// Pricing tiers and the first-session discount promotion now live in dedicated
|
||||
// relational tables (pricing_tiers / pricing_promotions). The old full-replace
|
||||
// PATCH /:mode handler is gone — every write here goes through per-row CRUD with
|
||||
// an updated_at optimistic-lock token. History rows are written in the same
|
||||
// transaction as the live-row mutation so audit can't drift from reality.
|
||||
//
|
||||
// Channel names on publishConfigInvalidate are preserved (pricing_chat_tiers_json /
|
||||
// pricing_call_tiers_json / first_session_discount) so any in-process cache
|
||||
// subscribers keep working without rewires.
|
||||
|
||||
/**
|
||||
* Snapshot a tier row into pricing_tiers_history. Called inside the same
|
||||
* transaction as the live-row write so audit can't drift from reality.
|
||||
*/
|
||||
const writeTierHistory = async (tx, row, { changedBy, changeKind }) => {
|
||||
await tx`
|
||||
INSERT INTO pricing_tiers_history (
|
||||
tier_id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, changed_by, change_kind
|
||||
) VALUES (
|
||||
${row.id}, ${row.mode}, ${row.minutes}, ${row.price_idr},
|
||||
${row.original_price_idr ?? null}, ${row.tag ?? null},
|
||||
${row.sort_order}, ${row.is_active},
|
||||
${changedBy ?? null}, ${changeKind}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
const writePromotionHistory = async (tx, row, { changedBy, changeKind }) => {
|
||||
await tx`
|
||||
INSERT INTO pricing_promotions_history (
|
||||
promotion_id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, changed_by, change_kind
|
||||
) VALUES (
|
||||
${row.id}, ${row.enabled}, ${row.eligibility}, ${row.actual_price_idr},
|
||||
${row.gimmick_price_idr ?? null}, ${row.duration_minutes}, ${row.modes},
|
||||
${row.starts_at ?? null}, ${row.ends_at ?? null},
|
||||
${changedBy ?? null}, ${changeKind}
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
// --- Pricing tiers ---
|
||||
|
||||
app.get('/pricing-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
const rows = await sql`
|
||||
SELECT id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
FROM pricing_tiers
|
||||
ORDER BY mode ASC, sort_order ASC, minutes ASC
|
||||
`
|
||||
const data = { chat: [], call: [] }
|
||||
for (const r of rows) {
|
||||
if (r.mode === SessionMode.CHAT) data.chat.push(r)
|
||||
else if (r.mode === SessionMode.CALL) data.call.push(r)
|
||||
}
|
||||
return reply.send({ success: true, data })
|
||||
})
|
||||
|
||||
app.post('/pricing-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const body = request.body ?? {}
|
||||
const { mode, minutes, price_idr, original_price_idr, tag, sort_order } = body
|
||||
|
||||
if (mode !== SessionMode.CHAT && mode !== SessionMode.CALL) {
|
||||
return reply.code(422).send(validation('mode must be "chat" or "call"', 'mode'))
|
||||
}
|
||||
if (typeof minutes !== 'number' || !Number.isInteger(minutes) || minutes <= 0) {
|
||||
return reply.code(422).send(validation('minutes must be a positive integer', 'minutes'))
|
||||
}
|
||||
if (typeof price_idr !== 'number' || !Number.isInteger(price_idr) || price_idr < 0) {
|
||||
return reply.code(422).send(validation('price_idr must be a non-negative integer', 'price_idr'))
|
||||
}
|
||||
if (original_price_idr !== undefined && original_price_idr !== null) {
|
||||
if (typeof original_price_idr !== 'number' || !Number.isInteger(original_price_idr) || original_price_idr < price_idr) {
|
||||
return reply.code(422).send(validation('original_price_idr must be an integer >= price_idr', 'original_price_idr'))
|
||||
}
|
||||
}
|
||||
if (tag !== undefined && tag !== null && typeof tag !== 'string') {
|
||||
return reply.code(422).send(validation('tag must be a string or null', 'tag'))
|
||||
}
|
||||
if (sort_order !== undefined && (typeof sort_order !== 'number' || !Number.isInteger(sort_order))) {
|
||||
return reply.code(422).send(validation('sort_order must be an integer', 'sort_order'))
|
||||
}
|
||||
|
||||
// Pre-check uniqueness so we can return a friendly 422 instead of a 23505 leak.
|
||||
const [dup] = await sql`
|
||||
SELECT id FROM pricing_tiers WHERE mode = ${mode} AND minutes = ${minutes}
|
||||
`
|
||||
if (dup) {
|
||||
return reply.code(422).send(validation(`A tier already exists for ${mode}/${minutes}min`, 'minutes'))
|
||||
}
|
||||
|
||||
let inserted
|
||||
try {
|
||||
inserted = await sql.begin(async (tx) => {
|
||||
const [row] = await tx`
|
||||
INSERT INTO pricing_tiers (mode, minutes, price_idr, original_price_idr, tag, sort_order)
|
||||
VALUES (
|
||||
${mode}, ${minutes}, ${price_idr},
|
||||
${original_price_idr ?? null},
|
||||
${tag ?? null},
|
||||
${sort_order ?? 0}
|
||||
)
|
||||
RETURNING id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
`
|
||||
await writeTierHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'create' })
|
||||
return row
|
||||
})
|
||||
} catch (err) {
|
||||
// 23505 = unique_violation (the (mode, minutes) constraint). Race with another writer.
|
||||
if (err.code === '23505') {
|
||||
return reply.code(422).send(validation(`A tier already exists for ${mode}/${minutes}min`, 'minutes'))
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
await publishConfigInvalidate(`pricing_${mode}_tiers_json`)
|
||||
return reply.code(201).send({ success: true, data: inserted })
|
||||
})
|
||||
|
||||
app.patch('/pricing-tiers/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
if (!UUID_RE.test(id)) {
|
||||
return reply.code(422).send(validation('id must be a UUID', 'id'))
|
||||
}
|
||||
|
||||
const body = request.body ?? {}
|
||||
const { updated_at: clientUpdatedAt, price_idr, original_price_idr, tag, sort_order, is_active } = body
|
||||
|
||||
if (!clientUpdatedAt) {
|
||||
return reply.code(422).send(validation('updated_at is required (optimistic-lock token)', 'updated_at'))
|
||||
}
|
||||
|
||||
// Build patch object — only include fields the caller actually sent.
|
||||
const patch = {}
|
||||
if (price_idr !== undefined) {
|
||||
if (typeof price_idr !== 'number' || !Number.isInteger(price_idr) || price_idr < 0) {
|
||||
return reply.code(422).send(validation('price_idr must be a non-negative integer', 'price_idr'))
|
||||
}
|
||||
patch.price_idr = price_idr
|
||||
}
|
||||
if (original_price_idr !== undefined) {
|
||||
if (original_price_idr !== null && (typeof original_price_idr !== 'number' || !Number.isInteger(original_price_idr) || original_price_idr < 0)) {
|
||||
return reply.code(422).send(validation('original_price_idr must be a non-negative integer or null', 'original_price_idr'))
|
||||
}
|
||||
patch.original_price_idr = original_price_idr
|
||||
}
|
||||
if (tag !== undefined) {
|
||||
if (tag !== null && typeof tag !== 'string') {
|
||||
return reply.code(422).send(validation('tag must be a string or null', 'tag'))
|
||||
}
|
||||
patch.tag = tag
|
||||
}
|
||||
if (sort_order !== undefined) {
|
||||
if (typeof sort_order !== 'number' || !Number.isInteger(sort_order)) {
|
||||
return reply.code(422).send(validation('sort_order must be an integer', 'sort_order'))
|
||||
}
|
||||
patch.sort_order = sort_order
|
||||
}
|
||||
if (is_active !== undefined) {
|
||||
if (typeof is_active !== 'boolean') {
|
||||
return reply.code(422).send(validation('is_active must be a boolean', 'is_active'))
|
||||
}
|
||||
patch.is_active = is_active
|
||||
}
|
||||
|
||||
const [existing] = await sql`
|
||||
SELECT id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, updated_at
|
||||
FROM pricing_tiers WHERE id = ${id}
|
||||
`
|
||||
if (!existing) {
|
||||
return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
}
|
||||
if (!updatedAtMatches(clientUpdatedAt, existing.updated_at)) {
|
||||
return reply.code(409).send(staleWrite(existing.updated_at))
|
||||
}
|
||||
|
||||
// Cross-field check: post-patch original_price_idr >= post-patch price_idr.
|
||||
const nextPrice = patch.price_idr ?? existing.price_idr
|
||||
const nextOrig = patch.original_price_idr === undefined ? existing.original_price_idr : patch.original_price_idr
|
||||
if (nextOrig !== null && nextOrig < nextPrice) {
|
||||
return reply.code(422).send(validation('original_price_idr must be >= price_idr', 'original_price_idr'))
|
||||
}
|
||||
|
||||
const updated = await sql.begin(async (tx) => {
|
||||
// Re-check inside the transaction with FOR UPDATE so a concurrent writer's
|
||||
// commit between our pre-check and our UPDATE bumps the timestamp and we
|
||||
// catch it. Without this, two stale-with-same-token PATCHes could both win.
|
||||
const [locked] = await tx`
|
||||
SELECT id, updated_at FROM pricing_tiers WHERE id = ${id} FOR UPDATE
|
||||
`
|
||||
if (!locked) return { _notFound: true }
|
||||
if (!updatedAtMatches(clientUpdatedAt, locked.updated_at)) {
|
||||
return { _stale: locked.updated_at }
|
||||
}
|
||||
|
||||
const [row] = await tx`
|
||||
UPDATE pricing_tiers
|
||||
SET price_idr = ${patch.price_idr ?? existing.price_idr},
|
||||
original_price_idr = ${patch.original_price_idr === undefined ? existing.original_price_idr : patch.original_price_idr},
|
||||
tag = ${patch.tag === undefined ? existing.tag : patch.tag},
|
||||
sort_order = ${patch.sort_order ?? existing.sort_order},
|
||||
is_active = ${patch.is_active ?? existing.is_active},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
`
|
||||
await writeTierHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'update' })
|
||||
return row
|
||||
})
|
||||
|
||||
if (updated._notFound) return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
if (updated._stale) return reply.code(409).send(staleWrite(updated._stale))
|
||||
|
||||
await publishConfigInvalidate(`pricing_${updated.mode}_tiers_json`)
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
app.delete('/pricing-tiers/:id', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params
|
||||
if (!UUID_RE.test(id)) {
|
||||
return reply.code(422).send(validation('id must be a UUID', 'id'))
|
||||
}
|
||||
|
||||
// updated_at is the optimistic-lock token even for DELETE — same contract as PATCH.
|
||||
const clientUpdatedAt = (request.body ?? {}).updated_at
|
||||
if (!clientUpdatedAt) {
|
||||
return reply.code(422).send(validation('updated_at is required (optimistic-lock token)', 'updated_at'))
|
||||
}
|
||||
|
||||
const [existing] = await sql`
|
||||
SELECT id, mode, updated_at FROM pricing_tiers WHERE id = ${id}
|
||||
`
|
||||
if (!existing) return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
if (!updatedAtMatches(clientUpdatedAt, existing.updated_at)) {
|
||||
return reply.code(409).send(staleWrite(existing.updated_at))
|
||||
}
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [locked] = await tx`
|
||||
SELECT id, updated_at FROM pricing_tiers WHERE id = ${id} FOR UPDATE
|
||||
`
|
||||
if (!locked) return { _notFound: true }
|
||||
if (!updatedAtMatches(clientUpdatedAt, locked.updated_at)) {
|
||||
return { _stale: locked.updated_at }
|
||||
}
|
||||
|
||||
const [row] = await tx`
|
||||
UPDATE pricing_tiers
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
sort_order, is_active, created_at, updated_at
|
||||
`
|
||||
await writeTierHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'delete' })
|
||||
return row
|
||||
})
|
||||
|
||||
if (result._notFound) return reply.code(404).send(notFound('Pricing tier not found'))
|
||||
if (result._stale) return reply.code(409).send(staleWrite(result._stale))
|
||||
|
||||
await publishConfigInvalidate(`pricing_${result.mode}_tiers_json`)
|
||||
return reply.send({ success: true, data: result })
|
||||
})
|
||||
|
||||
// --- First-session discount (single promotion row, eligibility='first_session') ---
|
||||
|
||||
app.get('/first-session-discount', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getFirstSessionDiscountConfig() })
|
||||
const [row] = await sql`
|
||||
SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, created_at, updated_at
|
||||
FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
if (!row) return reply.code(404).send(notFound('First-session discount not configured'))
|
||||
return reply.send({ success: true, data: row })
|
||||
})
|
||||
|
||||
app.patch('/first-session-discount', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const { enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes } = request.body ?? {}
|
||||
const body = request.body ?? {}
|
||||
const {
|
||||
updated_at: clientUpdatedAt,
|
||||
enabled,
|
||||
actual_price_idr,
|
||||
gimmick_price_idr,
|
||||
duration_minutes,
|
||||
modes,
|
||||
} = body
|
||||
|
||||
if (!clientUpdatedAt) {
|
||||
return reply.code(422).send(validation('updated_at is required (optimistic-lock token)', 'updated_at'))
|
||||
}
|
||||
|
||||
const patch = {}
|
||||
if (enabled !== undefined) {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'enabled must be a boolean' } })
|
||||
return reply.code(422).send(validation('enabled must be a boolean', 'enabled'))
|
||||
}
|
||||
patch.enabled = enabled
|
||||
}
|
||||
@@ -312,53 +645,77 @@ export const internalConfigRoutes = async (app) => {
|
||||
['duration_minutes', duration_minutes],
|
||||
]) {
|
||||
if (value !== undefined) {
|
||||
if (typeof value !== 'number' || value < 0 || !Number.isFinite(value)) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: `${field} must be a non-negative number` } })
|
||||
if (value === null && field === 'gimmick_price_idr') {
|
||||
patch[field] = null
|
||||
continue
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
return reply.code(422).send(validation(`${field} must be a non-negative number`, field))
|
||||
}
|
||||
if (field === 'duration_minutes' && value <= 0) {
|
||||
return reply.code(422).send(validation('duration_minutes must be > 0', field))
|
||||
}
|
||||
patch[field] = Math.round(value)
|
||||
}
|
||||
}
|
||||
if (modes !== undefined) {
|
||||
if (!Array.isArray(modes) || modes.some((m) => m !== 'chat' && m !== 'call')) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'modes must be an array of "chat" | "call"' } })
|
||||
if (
|
||||
!Array.isArray(modes)
|
||||
|| modes.length === 0
|
||||
|| modes.some((m) => m !== SessionMode.CHAT && m !== SessionMode.CALL)
|
||||
) {
|
||||
return reply.code(422).send(validation('modes must be a non-empty array of "chat" | "call"', 'modes'))
|
||||
}
|
||||
patch.modes = modes
|
||||
}
|
||||
const config = await setFirstSessionDiscountConfig(patch)
|
||||
|
||||
const [existing] = await sql`
|
||||
SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, updated_at
|
||||
FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
if (!existing) return reply.code(404).send(notFound('First-session discount not configured'))
|
||||
if (!updatedAtMatches(clientUpdatedAt, existing.updated_at)) {
|
||||
return reply.code(409).send(staleWrite(existing.updated_at))
|
||||
}
|
||||
|
||||
// Cross-field: gimmick_price_idr (if set) must be >= actual_price_idr.
|
||||
const nextActual = patch.actual_price_idr ?? existing.actual_price_idr
|
||||
const nextGimmick = patch.gimmick_price_idr === undefined ? existing.gimmick_price_idr : patch.gimmick_price_idr
|
||||
if (nextGimmick !== null && nextGimmick < nextActual) {
|
||||
return reply.code(422).send(validation('gimmick_price_idr must be >= actual_price_idr', 'gimmick_price_idr'))
|
||||
}
|
||||
|
||||
const updated = await sql.begin(async (tx) => {
|
||||
const [locked] = await tx`
|
||||
SELECT id, updated_at FROM pricing_promotions WHERE id = ${existing.id} FOR UPDATE
|
||||
`
|
||||
if (!locked) return { _notFound: true }
|
||||
if (!updatedAtMatches(clientUpdatedAt, locked.updated_at)) {
|
||||
return { _stale: locked.updated_at }
|
||||
}
|
||||
|
||||
const [row] = await tx`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = ${patch.enabled ?? existing.enabled},
|
||||
actual_price_idr = ${patch.actual_price_idr ?? existing.actual_price_idr},
|
||||
gimmick_price_idr = ${patch.gimmick_price_idr === undefined ? existing.gimmick_price_idr : patch.gimmick_price_idr},
|
||||
duration_minutes = ${patch.duration_minutes ?? existing.duration_minutes},
|
||||
modes = ${patch.modes ?? existing.modes},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${existing.id}
|
||||
RETURNING id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes, starts_at, ends_at, created_at, updated_at
|
||||
`
|
||||
await writePromotionHistory(tx, row, { changedBy: request.auth.userId, changeKind: 'update' })
|
||||
return row
|
||||
})
|
||||
|
||||
if (updated._notFound) return reply.code(404).send(notFound('First-session discount not configured'))
|
||||
if (updated._stale) return reply.code(409).send(staleWrite(updated._stale))
|
||||
|
||||
await publishConfigInvalidate('first_session_discount')
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
// --- Phase 4: Pricing tier groups (chat / call) ---
|
||||
app.get('/pricing-tiers', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'read')],
|
||||
}, async (_req, reply) => {
|
||||
return reply.send({ success: true, data: await getPricingTierGroups() })
|
||||
})
|
||||
|
||||
app.patch('/pricing-tiers/:mode', {
|
||||
preHandler: [authenticate, attachCcUser, requirePermission('config', 'update')],
|
||||
}, async (request, reply) => {
|
||||
const mode = request.params.mode
|
||||
if (mode !== 'chat' && mode !== 'call') {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'mode must be chat or call' } })
|
||||
}
|
||||
const { tiers } = request.body ?? {}
|
||||
if (!Array.isArray(tiers) || tiers.length === 0) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'tiers must be a non-empty array' } })
|
||||
}
|
||||
for (const t of tiers) {
|
||||
if (
|
||||
typeof t.id !== 'string'
|
||||
|| typeof t.minutes !== 'number' || t.minutes <= 0
|
||||
|| typeof t.price_idr !== 'number' || t.price_idr < 0
|
||||
) {
|
||||
return reply.code(422).send({ success: false, error: { code: 'VALIDATION_ERROR', message: 'each tier needs id (string), minutes (number > 0), price_idr (number >= 0)' } })
|
||||
}
|
||||
}
|
||||
const config = await setPricingTierGroup(mode, tiers)
|
||||
await publishConfigInvalidate(`pricing_${mode}_tiers_json`)
|
||||
return reply.send({ success: true, data: config })
|
||||
return reply.send({ success: true, data: updated })
|
||||
})
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
|
||||
@@ -11,12 +11,10 @@ import {
|
||||
isCustomerEligibleForFirstSessionDiscount,
|
||||
isValidTier,
|
||||
findTier,
|
||||
readFirstSessionDiscountConfig,
|
||||
} from '../../services/pricing.service.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
import { UserType, SessionMode } from '../../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
const resolveCustomer = async (request, reply) => {
|
||||
if (request.auth?.userType !== UserType.CUSTOMER) {
|
||||
return reply.code(403).send({
|
||||
@@ -34,25 +32,6 @@ const resolveCustomer = async (request, reply) => {
|
||||
request.customer = customer
|
||||
}
|
||||
|
||||
const readDiscountConfig = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN (
|
||||
'first_session_discount_enabled',
|
||||
'first_session_discount_actual_price_idr',
|
||||
'first_session_discount_duration_minutes',
|
||||
'first_session_discount_modes'
|
||||
)
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment session lifecycle (mocked — no Xendit yet).
|
||||
*
|
||||
@@ -92,7 +71,7 @@ export const clientPaymentRoutes = async (app) => {
|
||||
if (!is_extension) {
|
||||
const eligible = await isCustomerEligibleForFirstSessionDiscount(request.customer.id)
|
||||
if (eligible) {
|
||||
const discount = await readDiscountConfig()
|
||||
const discount = await readFirstSessionDiscountConfig()
|
||||
// Discount is mode-gated. With default config (modes: ['chat']) call-mode never
|
||||
// gets the discount even if the user is eligible.
|
||||
if (
|
||||
|
||||
@@ -35,63 +35,47 @@ export const setMaxCustomersPerMitra = async (value) => {
|
||||
return { max_customers_per_mitra: value }
|
||||
}
|
||||
|
||||
// --- Phase 4: First-session discount (replaces Phase 3 free-trial config) ---
|
||||
// --- Phase 4: First-session discount config (back-compat shim) ---
|
||||
//
|
||||
// The canonical source of truth for the first-session discount lives in the
|
||||
// `pricing_promotions` table (eligibility = 'first_session'). The CC settings
|
||||
// page still calls `/internal/config/free-trial`, which exposes a slim
|
||||
// {enabled, duration_minutes} view — kept as a back-compat shim until the CC
|
||||
// UI is migrated to the richer /internal/config/first-session-discount handler.
|
||||
// Reads and writes go directly against `pricing_promotions` so operator edits
|
||||
// stay in sync with the customer-facing pricing payload.
|
||||
//
|
||||
// The legacy `first_session_discount_*` keys in `app_config` were retired in
|
||||
// Stage 5 (deleted by migrate.js) — do NOT reintroduce them.
|
||||
|
||||
export const getFirstSessionDiscountConfig = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN (
|
||||
'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 byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? true,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? 2000,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? 12000,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? 12,
|
||||
modes: byKey.first_session_discount_modes ?? ['chat'],
|
||||
}
|
||||
}
|
||||
|
||||
export const setFirstSessionDiscountConfig = async (patch) => {
|
||||
const map = {
|
||||
enabled: 'first_session_discount_enabled',
|
||||
actual_price_idr: 'first_session_discount_actual_price_idr',
|
||||
gimmick_price_idr: 'first_session_discount_gimmick_price_idr',
|
||||
duration_minutes: 'first_session_discount_duration_minutes',
|
||||
modes: 'first_session_discount_modes',
|
||||
}
|
||||
for (const [field, key] of Object.entries(map)) {
|
||||
if (patch[field] === undefined) continue
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${key}, ${sql.json({ value: patch[field] })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
return getFirstSessionDiscountConfig()
|
||||
}
|
||||
|
||||
// Back-compat shim — CC settings page still calls /internal/config/free-trial.
|
||||
// Phase 4 routes will be added; until the CC UI is migrated this maps to the new keys.
|
||||
export const getFreeTrialConfig = async () => {
|
||||
const cfg = await getFirstSessionDiscountConfig()
|
||||
const [row] = await sql`
|
||||
SELECT enabled, duration_minutes FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
duration_minutes: cfg.duration_minutes,
|
||||
enabled: row?.enabled ?? true,
|
||||
duration_minutes: row?.duration_minutes ?? 12,
|
||||
}
|
||||
}
|
||||
|
||||
export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => {
|
||||
return setFirstSessionDiscountConfig({
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(duration_minutes !== undefined ? { duration_minutes } : {}),
|
||||
})
|
||||
// Build a sparse UPDATE so undefined fields are left alone (matches the prior
|
||||
// semantics where missing patch fields were no-ops). Use COALESCE on each
|
||||
// column with the sentinel-when-undefined pattern; postgres.js parameterizes
|
||||
// null/undefined identically, so we branch on which fields the caller sent.
|
||||
if (enabled === undefined && duration_minutes === undefined) {
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = ${enabled === undefined ? sql`enabled` : enabled},
|
||||
duration_minutes = ${duration_minutes === undefined ? sql`duration_minutes` : duration_minutes},
|
||||
updated_at = NOW()
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
return getFreeTrialConfig()
|
||||
}
|
||||
|
||||
// --- Phase 4: Support handles ---
|
||||
@@ -120,30 +104,6 @@ export const setSupportHandles = async ({ wa, telegram }) => {
|
||||
return next
|
||||
}
|
||||
|
||||
// --- Phase 4: Pricing tier groups ---
|
||||
|
||||
export const getPricingTierGroups = async () => {
|
||||
const rows = await sql`
|
||||
SELECT key, value FROM app_config
|
||||
WHERE key IN ('pricing_chat_tiers_json', 'pricing_call_tiers_json')
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value]))
|
||||
return {
|
||||
chat: byKey.pricing_chat_tiers_json?.tiers ?? [],
|
||||
call: byKey.pricing_call_tiers_json?.tiers ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export const setPricingTierGroup = async (mode, tiers) => {
|
||||
const key = mode === 'call' ? 'pricing_call_tiers_json' : 'pricing_chat_tiers_json'
|
||||
await sql`
|
||||
INSERT INTO app_config (key, value, updated_at)
|
||||
VALUES (${key}, ${sql.json({ tiers })}, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
return getPricingTierGroups()
|
||||
}
|
||||
|
||||
export const getExtensionTimeoutConfig = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'`
|
||||
// Default 10s pairs with the auto-approve-on-timeout flow; raise this if you change the policy to auto-reject.
|
||||
|
||||
@@ -3,8 +3,13 @@ import { SessionStatus } from '../constants.js'
|
||||
|
||||
const sql = getDb()
|
||||
|
||||
// Default tiers as fallback (used if app_config row is missing). Match the seed
|
||||
// values in migrate.js so a missing row never breaks pricing in the wild.
|
||||
// Default tiers as fallback (used if pricing_tiers is empty — e.g. fresh dev DB or
|
||||
// test fixture without backfill). Match the DEFAULT_*_BACKFILL arrays in migrate.js
|
||||
// so a missing row never breaks pricing in the wild. Stage 3 keeps these as a
|
||||
// belt-and-suspenders only — `id` here is the legacy bare string, which Stage 3
|
||||
// preserves for the "no rows" edge case so the customer-facing shape (UUID strings
|
||||
// once populated, bare strings only in this degraded fallback path) keeps the
|
||||
// same JSON keys.
|
||||
const DEFAULT_CHAT_TIERS = [
|
||||
{ id: '5', minutes: 5, price_idr: 5000, tag: null },
|
||||
{ id: '12', minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
@@ -26,34 +31,60 @@ const DEFAULT_DISCOUNT = {
|
||||
modes: ['chat'],
|
||||
}
|
||||
|
||||
// Map a pricing_tiers row to the customer-facing tier object. `original_price_idr`
|
||||
// is deliberately omitted from the public shape (Stage 3 keeps it schema-only).
|
||||
const toCustomerTier = (row) => ({
|
||||
id: row.id,
|
||||
tag: row.tag,
|
||||
minutes: row.minutes,
|
||||
price_idr: row.price_idr,
|
||||
})
|
||||
|
||||
const readChatTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CHAT_TIERS
|
||||
const rows = await sql`
|
||||
SELECT id, minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers
|
||||
WHERE mode = 'chat' AND is_active = true
|
||||
ORDER BY sort_order ASC, minutes ASC
|
||||
`
|
||||
if (rows.length === 0) return DEFAULT_CHAT_TIERS
|
||||
return rows.map(toCustomerTier)
|
||||
}
|
||||
|
||||
const readCallTiers = async () => {
|
||||
const [row] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`
|
||||
return row?.value?.tiers ?? DEFAULT_CALL_TIERS
|
||||
const rows = await sql`
|
||||
SELECT id, minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers
|
||||
WHERE mode = 'call' AND is_active = true
|
||||
ORDER BY sort_order ASC, minutes ASC
|
||||
`
|
||||
if (rows.length === 0) return DEFAULT_CALL_TIERS
|
||||
return rows.map(toCustomerTier)
|
||||
}
|
||||
|
||||
const readDiscountConfig = async () => {
|
||||
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)}
|
||||
/**
|
||||
* Read the canonical first-session-discount config from `pricing_promotions`
|
||||
* (eligibility = 'first_session'). Falls back to DEFAULT_DISCOUNT if the row
|
||||
* is missing (fresh dev DB or partial migration).
|
||||
*
|
||||
* Exported because the customer-facing payment-session route needs the exact
|
||||
* same source-of-truth as `getPricingForCustomer` and the eligibility predicate
|
||||
* — duplicating the read in route code would be a foot-gun for operator edits
|
||||
* in CC (Stage 5 moved this off `app_config` JSON to the relational table).
|
||||
*/
|
||||
export const readFirstSessionDiscountConfig = async () => {
|
||||
const [row] = await sql`
|
||||
SELECT enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))
|
||||
if (!row) return DEFAULT_DISCOUNT
|
||||
return {
|
||||
enabled: byKey.first_session_discount_enabled ?? DEFAULT_DISCOUNT.enabled,
|
||||
actual_price_idr: byKey.first_session_discount_actual_price_idr ?? DEFAULT_DISCOUNT.actual_price_idr,
|
||||
gimmick_price_idr: byKey.first_session_discount_gimmick_price_idr ?? DEFAULT_DISCOUNT.gimmick_price_idr,
|
||||
duration_minutes: byKey.first_session_discount_duration_minutes ?? DEFAULT_DISCOUNT.duration_minutes,
|
||||
modes: byKey.first_session_discount_modes ?? DEFAULT_DISCOUNT.modes,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +92,7 @@ const readDiscountConfig = async () => {
|
||||
* Per-customer first-session-discount eligibility.
|
||||
*
|
||||
* Predicate (Phase 4):
|
||||
* - app_config.first_session_discount_enabled == true, AND
|
||||
* - pricing_promotions.enabled (for eligibility='first_session') == true, AND
|
||||
* - customer is phone-verified (customers.phone IS NOT NULL — phone only gets set
|
||||
* via the OTP-verify path, so non-null is proof of verification), AND
|
||||
* - customer has no completed/closing chat_sessions row (returning users pay full price).
|
||||
@@ -70,7 +101,7 @@ const readDiscountConfig = async () => {
|
||||
* column. `phone IS NOT NULL` is the equivalent invariant in this schema.
|
||||
*/
|
||||
export const isCustomerEligibleForFirstSessionDiscount = async (customerId) => {
|
||||
const discount = await readDiscountConfig()
|
||||
const discount = await readFirstSessionDiscountConfig()
|
||||
if (!discount.enabled) return false
|
||||
|
||||
const [customer] = await sql`
|
||||
@@ -103,7 +134,7 @@ export const getPricingForCustomer = async (customerId) => {
|
||||
const [chatTiers, callTiers, discount, eligible] = await Promise.all([
|
||||
readChatTiers(),
|
||||
readCallTiers(),
|
||||
readDiscountConfig(),
|
||||
readFirstSessionDiscountConfig(),
|
||||
isCustomerEligibleForFirstSessionDiscount(customerId),
|
||||
])
|
||||
return {
|
||||
@@ -124,7 +155,17 @@ export const getPricingForCustomer = async (customerId) => {
|
||||
* Used by payment-session creation as a defense-in-depth check.
|
||||
*/
|
||||
export const isValidTier = async ({ mode, durationMinutes, priceIdr }) => {
|
||||
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
|
||||
const [row] = await sql`
|
||||
SELECT id FROM pricing_tiers
|
||||
WHERE mode = ${mode}
|
||||
AND minutes = ${durationMinutes}
|
||||
AND price_idr = ${priceIdr}
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
`
|
||||
if (row) return true
|
||||
// Fallback: empty table → use in-memory defaults. Keeps tests with a clean DB green.
|
||||
const tiers = mode === 'call' ? DEFAULT_CALL_TIERS : DEFAULT_CHAT_TIERS
|
||||
return tiers.some((t) => t.minutes === durationMinutes && t.price_idr === priceIdr)
|
||||
}
|
||||
|
||||
@@ -132,7 +173,17 @@ export const isValidTier = async ({ mode, durationMinutes, priceIdr }) => {
|
||||
* Look up the canonical tier for (mode, duration_minutes). Returns null if no match.
|
||||
*/
|
||||
export const findTier = async ({ mode, durationMinutes }) => {
|
||||
const tiers = mode === 'call' ? await readCallTiers() : await readChatTiers()
|
||||
const [row] = await sql`
|
||||
SELECT id, minutes, price_idr, tag
|
||||
FROM pricing_tiers
|
||||
WHERE mode = ${mode}
|
||||
AND minutes = ${durationMinutes}
|
||||
AND is_active = true
|
||||
LIMIT 1
|
||||
`
|
||||
if (row) return { id: row.id, minutes: row.minutes, price_idr: row.price_idr, tag: row.tag }
|
||||
// Fallback: empty table → in-memory defaults.
|
||||
const tiers = mode === 'call' ? DEFAULT_CALL_TIERS : DEFAULT_CHAT_TIERS
|
||||
return tiers.find((t) => t.minutes === durationMinutes) ?? null
|
||||
}
|
||||
|
||||
|
||||
508
backend/test/db/pricing-migration.test.js
Normal file
508
backend/test/db/pricing-migration.test.js
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import postgres from 'postgres'
|
||||
|
||||
/**
|
||||
* Stage 1 migration test for the pricing relational rollout.
|
||||
*
|
||||
* Scope (matches §"Tests" #1 in requirement/pricing-relational-migration-plan.md):
|
||||
* 1. Empty DB → backfill seeds the DEFAULT_* values from pricing.service.js.
|
||||
* 2. Pre-existing app_config JSON → backfill copies those values, not defaults.
|
||||
* 3. Re-running the migration → no duplicate rows.
|
||||
*
|
||||
* Isolation strategy
|
||||
* ------------------
|
||||
* Each test creates its OWN throwaway schema (`pricing_mig_test_<random>`),
|
||||
* runs migrate.js as a child process against that schema, inspects the
|
||||
* resulting rows via a one-off postgres client scoped to the schema, then
|
||||
* drops the schema in afterAll.
|
||||
*
|
||||
* Why a child process: migrate.js calls `sql.end()` at the bottom, which would
|
||||
* tear down the singleton sql client shared with the rest of the test process
|
||||
* if invoked in-process. Same trick setup.js uses for the global migration.
|
||||
*
|
||||
* Why a separate schema per test (vs. the shared `halobestie_test` schema):
|
||||
* the shared schema already has app_config seeded with Phase 4 defaults, which
|
||||
* would make the "empty app_config → fall back to DEFAULTS" path untestable.
|
||||
* Per-schema isolation lets us control the initial state precisely.
|
||||
*/
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const backendRoot = resolve(__dirname, '../..')
|
||||
const migratePath = resolve(backendRoot, 'src/db/migrate.js')
|
||||
|
||||
// Mirrors DEFAULT_CHAT_TIERS / DEFAULT_CALL_TIERS / DEFAULT_DISCOUNT in
|
||||
// src/services/pricing.service.js. Duplicated here so the test fails loudly
|
||||
// if either side drifts — that's a feature, not duplication-tax.
|
||||
const DEFAULT_CHAT_TIERS = [
|
||||
{ minutes: 5, price_idr: 5000, tag: null },
|
||||
{ minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ minutes: 60, price_idr: 45000, tag: null },
|
||||
{ minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]
|
||||
const DEFAULT_CALL_TIERS = [
|
||||
{ minutes: 10, price_idr: 9000, tag: null },
|
||||
{ minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ minutes: 45, price_idr: 35000, tag: null },
|
||||
{ minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]
|
||||
const DEFAULT_DISCOUNT = {
|
||||
enabled: true,
|
||||
actual_price_idr: 2000,
|
||||
gimmick_price_idr: 12000,
|
||||
duration_minutes: 12,
|
||||
modes: ['chat'],
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DATABASE_URL that pins search_path to the given schema. Postgres applies
|
||||
* the search_path on every new connection, so all CREATE TABLE / INSERT / SELECT
|
||||
* resolve to this schema without further qualification.
|
||||
*/
|
||||
const scopedUrl = (baseUrl, schema) => {
|
||||
const sep = baseUrl.includes('?') ? '&' : '?'
|
||||
return `${baseUrl}${sep}options=${encodeURIComponent(`-c search_path=${schema},public`)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn migrate.js as a child process against the given schema. Throws on non-zero
|
||||
* exit so the test fails with the migration's stdout/stderr.
|
||||
*/
|
||||
const runMigration = (schema) => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[migratePath],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: scopedUrl(process.env.TEST_DATABASE_URL, schema),
|
||||
},
|
||||
cwd: backendRoot,
|
||||
encoding: 'utf8',
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Migration failed (exit ${result.status}):\n` +
|
||||
`stdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a one-off postgres client scoped to the given schema. Caller MUST `.end()` it.
|
||||
* We intentionally do not reuse the singleton from src/db/client.js — that's scoped to
|
||||
* the shared test schema, not the throwaway one we built here.
|
||||
*/
|
||||
const openClient = (schema) => postgres(scopedUrl(process.env.TEST_DATABASE_URL, schema))
|
||||
|
||||
describe('pricing relational migration — Stage 1 backfill', () => {
|
||||
// Test schemas we create. Tracked so afterAll can drop them even if a test throws.
|
||||
const createdSchemas = []
|
||||
|
||||
/**
|
||||
* Allocate a fresh schema, register it for teardown, return its name. Schemas are
|
||||
* suffixed with a random hex string + the test's nominal label so collisions across
|
||||
* parallel CI runs (or repeated local runs after a crash) are vanishingly unlikely.
|
||||
*/
|
||||
const newSchema = async (label) => {
|
||||
const suffix = Math.random().toString(16).slice(2, 10)
|
||||
const name = `pricing_mig_test_${label}_${suffix}`
|
||||
const admin = postgres(process.env.TEST_DATABASE_URL)
|
||||
try {
|
||||
await admin`CREATE SCHEMA ${admin(name)}`
|
||||
} finally {
|
||||
await admin.end()
|
||||
}
|
||||
createdSchemas.push(name)
|
||||
return name
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (!process.env.TEST_DATABASE_URL) {
|
||||
throw new Error('TEST_DATABASE_URL must be set (loaded by test/setup.js)')
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Drop every schema we created. CASCADE so we don't have to enumerate tables.
|
||||
const admin = postgres(process.env.TEST_DATABASE_URL)
|
||||
try {
|
||||
for (const schema of createdSchemas) {
|
||||
await admin`DROP SCHEMA IF EXISTS ${admin(schema)} CASCADE`
|
||||
}
|
||||
} finally {
|
||||
await admin.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('empty DB → backfill seeds DEFAULT_CHAT_TIERS / DEFAULT_CALL_TIERS / DEFAULT_DISCOUNT', async () => {
|
||||
const schema = await newSchema('empty')
|
||||
runMigration(schema)
|
||||
|
||||
const sql = openClient(schema)
|
||||
try {
|
||||
// 1. Tiers: count + per-row equivalence with DEFAULT_* (sort_order = array index).
|
||||
const chatTiers = await sql`
|
||||
SELECT minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers
|
||||
WHERE mode = 'chat'
|
||||
ORDER BY sort_order
|
||||
`
|
||||
expect(chatTiers).toHaveLength(DEFAULT_CHAT_TIERS.length)
|
||||
chatTiers.forEach((row, idx) => {
|
||||
expect(row.minutes).toBe(DEFAULT_CHAT_TIERS[idx].minutes)
|
||||
expect(row.price_idr).toBe(DEFAULT_CHAT_TIERS[idx].price_idr)
|
||||
expect(row.tag).toBe(DEFAULT_CHAT_TIERS[idx].tag)
|
||||
expect(row.sort_order).toBe(idx)
|
||||
})
|
||||
|
||||
const callTiers = await sql`
|
||||
SELECT minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers
|
||||
WHERE mode = 'call'
|
||||
ORDER BY sort_order
|
||||
`
|
||||
expect(callTiers).toHaveLength(DEFAULT_CALL_TIERS.length)
|
||||
callTiers.forEach((row, idx) => {
|
||||
expect(row.minutes).toBe(DEFAULT_CALL_TIERS[idx].minutes)
|
||||
expect(row.price_idr).toBe(DEFAULT_CALL_TIERS[idx].price_idr)
|
||||
expect(row.tag).toBe(DEFAULT_CALL_TIERS[idx].tag)
|
||||
expect(row.sort_order).toBe(idx)
|
||||
})
|
||||
|
||||
// All freshly inserted rows must be active.
|
||||
const [{ inactive_count }] = await sql`
|
||||
SELECT COUNT(*)::int AS inactive_count FROM pricing_tiers WHERE is_active = false
|
||||
`
|
||||
expect(inactive_count).toBe(0)
|
||||
|
||||
// 2. Promotions: single 'first_session' row matching DEFAULT_DISCOUNT.
|
||||
const promos = await sql`
|
||||
SELECT enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions
|
||||
`
|
||||
expect(promos).toHaveLength(1)
|
||||
expect(promos[0]).toMatchObject({
|
||||
enabled: DEFAULT_DISCOUNT.enabled,
|
||||
eligibility: 'first_session',
|
||||
actual_price_idr: DEFAULT_DISCOUNT.actual_price_idr,
|
||||
gimmick_price_idr: DEFAULT_DISCOUNT.gimmick_price_idr,
|
||||
duration_minutes: DEFAULT_DISCOUNT.duration_minutes,
|
||||
})
|
||||
expect(promos[0].modes).toEqual(DEFAULT_DISCOUNT.modes)
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('pre-existing app_config JSON → backfill copies those values, not defaults', async () => {
|
||||
const schema = await newSchema('preseed')
|
||||
|
||||
// Custom values that DO NOT overlap with any DEFAULT_* entry — guarantees the
|
||||
// assertions can tell the two sources apart.
|
||||
const customChat = [
|
||||
{ minutes: 7, price_idr: 7700, tag: 'custom-a' },
|
||||
{ minutes: 22, price_idr: 21000, tag: null },
|
||||
{ minutes: 99, price_idr: 75000, tag: 'custom-b' },
|
||||
]
|
||||
const customCall = [
|
||||
{ minutes: 11, price_idr: 9900, tag: 'custom-c' },
|
||||
{ minutes: 33, price_idr: 27500, tag: null },
|
||||
]
|
||||
const customDiscount = {
|
||||
enabled: false,
|
||||
actual_price_idr: 1500,
|
||||
gimmick_price_idr: 11000,
|
||||
duration_minutes: 8,
|
||||
modes: ['chat', 'call'],
|
||||
}
|
||||
|
||||
// Seed app_config BEFORE the pricing migration block runs. The earlier
|
||||
// migration sections create the app_config table itself; running the migration
|
||||
// once gives us the table + the default Phase 4 app_config rows. But that same
|
||||
// run also backfills pricing_tiers from those defaults — we don't want that.
|
||||
//
|
||||
// Workaround: bootstrap just enough of the migration prologue manually
|
||||
// (create app_config + insert our custom JSON), THEN run migrate.js. The
|
||||
// tier/promo CREATE TABLE IF NOT EXISTS is idempotent, and the backfill is
|
||||
// gated on "table empty", so it'll read our app_config rows on first pass.
|
||||
const bootstrap = openClient(schema)
|
||||
try {
|
||||
await bootstrap`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`
|
||||
await bootstrap`
|
||||
CREATE TABLE app_config (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
await bootstrap`
|
||||
INSERT INTO app_config (key, value) VALUES
|
||||
('pricing_chat_tiers_json', ${bootstrap.json({ tiers: customChat })}),
|
||||
('pricing_call_tiers_json', ${bootstrap.json({ tiers: customCall })}),
|
||||
('first_session_discount_enabled', ${bootstrap.json({ value: customDiscount.enabled })}),
|
||||
('first_session_discount_actual_price_idr', ${bootstrap.json({ value: customDiscount.actual_price_idr })}),
|
||||
('first_session_discount_gimmick_price_idr', ${bootstrap.json({ value: customDiscount.gimmick_price_idr })}),
|
||||
('first_session_discount_duration_minutes', ${bootstrap.json({ value: customDiscount.duration_minutes })}),
|
||||
('first_session_discount_modes', ${bootstrap.json({ value: customDiscount.modes })})
|
||||
`
|
||||
} finally {
|
||||
await bootstrap.end()
|
||||
}
|
||||
|
||||
// Now run the full migration. The pricing block sees pre-seeded app_config
|
||||
// and an empty pricing_tiers, so it should copy our custom values.
|
||||
runMigration(schema)
|
||||
|
||||
const sql = openClient(schema)
|
||||
try {
|
||||
const chatTiers = await sql`
|
||||
SELECT minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers WHERE mode = 'chat' ORDER BY sort_order
|
||||
`
|
||||
expect(chatTiers).toHaveLength(customChat.length)
|
||||
chatTiers.forEach((row, idx) => {
|
||||
expect(row.minutes).toBe(customChat[idx].minutes)
|
||||
expect(row.price_idr).toBe(customChat[idx].price_idr)
|
||||
expect(row.tag).toBe(customChat[idx].tag)
|
||||
expect(row.sort_order).toBe(idx)
|
||||
})
|
||||
|
||||
// Spot-check that DEFAULTS are NOT present (e.g. 5/12/30/60/120 minute chat
|
||||
// tiers from DEFAULT_CHAT_TIERS shouldn't appear unless customChat includes them).
|
||||
const defaultsThatShouldNotBeHere = await sql`
|
||||
SELECT minutes FROM pricing_tiers
|
||||
WHERE mode = 'chat' AND minutes IN (5, 12, 30, 120)
|
||||
`
|
||||
expect(defaultsThatShouldNotBeHere).toHaveLength(0)
|
||||
|
||||
const callTiers = await sql`
|
||||
SELECT minutes, price_idr, tag, sort_order
|
||||
FROM pricing_tiers WHERE mode = 'call' ORDER BY sort_order
|
||||
`
|
||||
expect(callTiers).toHaveLength(customCall.length)
|
||||
callTiers.forEach((row, idx) => {
|
||||
expect(row.minutes).toBe(customCall[idx].minutes)
|
||||
expect(row.price_idr).toBe(customCall[idx].price_idr)
|
||||
expect(row.tag).toBe(customCall[idx].tag)
|
||||
expect(row.sort_order).toBe(idx)
|
||||
})
|
||||
|
||||
const [promo] = await sql`
|
||||
SELECT enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions
|
||||
`
|
||||
expect(promo).toMatchObject({
|
||||
enabled: customDiscount.enabled,
|
||||
eligibility: 'first_session',
|
||||
actual_price_idr: customDiscount.actual_price_idr,
|
||||
gimmick_price_idr: customDiscount.gimmick_price_idr,
|
||||
duration_minutes: customDiscount.duration_minutes,
|
||||
})
|
||||
expect(promo.modes).toEqual(customDiscount.modes)
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('Stage 5 cleanup: legacy pricing app_config rows are deleted after backfill, idempotent on re-run', async () => {
|
||||
const schema = await newSchema('stage5')
|
||||
|
||||
// First migration pass: seeds the legacy app_config keys, backfills relational
|
||||
// tables, then the Stage 5 DELETE block removes the legacy keys.
|
||||
runMigration(schema)
|
||||
|
||||
const sql = openClient(schema)
|
||||
try {
|
||||
// The seven legacy keys must be gone post-migration.
|
||||
const legacyKeys = [
|
||||
'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',
|
||||
'pricing_chat_tiers_json',
|
||||
'pricing_call_tiers_json',
|
||||
]
|
||||
const remaining = await sql`
|
||||
SELECT key FROM app_config WHERE key IN ${sql(legacyKeys)}
|
||||
`
|
||||
expect(remaining).toHaveLength(0)
|
||||
|
||||
// Sanity: relational tables still hold the values that were copied off.
|
||||
const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
|
||||
expect(tierCount).toBeGreaterThan(0)
|
||||
const [{ n: promoCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_promotions`
|
||||
expect(promoCount).toBe(1)
|
||||
|
||||
// Re-run the migration. The DELETE must remain a no-op (zero affected rows)
|
||||
// and not error — i.e. cleanup is idempotent.
|
||||
runMigration(schema)
|
||||
|
||||
const remainingAfter = await sql`
|
||||
SELECT key FROM app_config WHERE key IN ${sql(legacyKeys)}
|
||||
`
|
||||
expect(remainingAfter).toHaveLength(0)
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('Stage 5 cleanup: legacy keys present from a pre-Stage-5 DB are still deleted on first run', async () => {
|
||||
// Simulates a DB that was already migrated to Stage 1 (pricing_tiers/promotions
|
||||
// populated, legacy app_config keys still around) and is being upgraded to
|
||||
// Stage 5 for the first time. The DELETE should remove the legacy keys
|
||||
// without disturbing the relational data.
|
||||
const schema = await newSchema('stage5_existing')
|
||||
|
||||
// Bootstrap: schema + app_config table + legacy keys with custom values, then
|
||||
// run the migration. Pre-seeding here is what differentiates this from the
|
||||
// empty-DB case — it proves the DELETE doesn't depend on the seeding INSERT.
|
||||
const bootstrap = openClient(schema)
|
||||
try {
|
||||
await bootstrap`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`
|
||||
await bootstrap`
|
||||
CREATE TABLE app_config (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`
|
||||
await bootstrap`
|
||||
INSERT INTO app_config (key, value) VALUES
|
||||
('first_session_discount_enabled', ${bootstrap.json({ value: false })}),
|
||||
('first_session_discount_actual_price_idr', ${bootstrap.json({ value: 1234 })}),
|
||||
('first_session_discount_gimmick_price_idr', ${bootstrap.json({ value: 5678 })}),
|
||||
('first_session_discount_duration_minutes', ${bootstrap.json({ value: 9 })}),
|
||||
('first_session_discount_modes', ${bootstrap.json({ value: ['chat', 'call'] })}),
|
||||
('pricing_chat_tiers_json', ${bootstrap.json({ tiers: [{ minutes: 3, price_idr: 3000, tag: null }] })}),
|
||||
('pricing_call_tiers_json', ${bootstrap.json({ tiers: [{ minutes: 4, price_idr: 4000, tag: null }] })})
|
||||
`
|
||||
} finally {
|
||||
await bootstrap.end()
|
||||
}
|
||||
|
||||
runMigration(schema)
|
||||
|
||||
const sql = openClient(schema)
|
||||
try {
|
||||
const remaining = await sql`
|
||||
SELECT key FROM app_config
|
||||
WHERE key IN (
|
||||
'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',
|
||||
'pricing_chat_tiers_json',
|
||||
'pricing_call_tiers_json'
|
||||
)
|
||||
`
|
||||
expect(remaining).toHaveLength(0)
|
||||
|
||||
// Backfill copied the pre-seeded values into the relational tables (Stage 1
|
||||
// semantics) and the Stage 5 DELETE didn't touch them.
|
||||
const [promo] = await sql`
|
||||
SELECT enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
expect(promo.enabled).toBe(false)
|
||||
expect(promo.actual_price_idr).toBe(1234)
|
||||
expect(promo.gimmick_price_idr).toBe(5678)
|
||||
expect(promo.duration_minutes).toBe(9)
|
||||
expect(promo.modes).toEqual(['chat', 'call'])
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('re-running the migration → no duplicate rows, existing data preserved', async () => {
|
||||
const schema = await newSchema('rerun')
|
||||
|
||||
// First migration pass: backfills defaults (app_config rows are seeded by the
|
||||
// earlier sections of the same migration, then pricing_tiers fills from them).
|
||||
runMigration(schema)
|
||||
|
||||
// Capture state after pass 1 — ids included so we can prove rows weren't
|
||||
// re-inserted (UUIDs would change if they were).
|
||||
const snapshot = openClient(schema)
|
||||
let tiersBefore, promoBefore
|
||||
try {
|
||||
tiersBefore = await snapshot`
|
||||
SELECT id, mode, minutes, price_idr, tag, sort_order, is_active
|
||||
FROM pricing_tiers ORDER BY mode, sort_order
|
||||
`
|
||||
promoBefore = await snapshot`
|
||||
SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes
|
||||
FROM pricing_promotions
|
||||
`
|
||||
expect(tiersBefore.length).toBeGreaterThan(0)
|
||||
expect(promoBefore).toHaveLength(1)
|
||||
} finally {
|
||||
await snapshot.end()
|
||||
}
|
||||
|
||||
// Second migration pass: must be a no-op for pricing data.
|
||||
runMigration(schema)
|
||||
|
||||
const sql = openClient(schema)
|
||||
try {
|
||||
const tiersAfter = await sql`
|
||||
SELECT id, mode, minutes, price_idr, tag, sort_order, is_active
|
||||
FROM pricing_tiers ORDER BY mode, sort_order
|
||||
`
|
||||
const promoAfter = await sql`
|
||||
SELECT id, enabled, eligibility, actual_price_idr, gimmick_price_idr,
|
||||
duration_minutes, modes
|
||||
FROM pricing_promotions
|
||||
`
|
||||
|
||||
// Row count unchanged.
|
||||
expect(tiersAfter).toHaveLength(tiersBefore.length)
|
||||
expect(promoAfter).toHaveLength(1)
|
||||
|
||||
// Every id from pass 1 still exists with identical values in pass 2.
|
||||
// UUIDs are stable across re-runs because the backfill is gated on "table empty".
|
||||
expect(tiersAfter.map((r) => r.id).sort()).toEqual(
|
||||
tiersBefore.map((r) => r.id).sort()
|
||||
)
|
||||
expect(promoAfter[0].id).toBe(promoBefore[0].id)
|
||||
|
||||
// Defense against an "INSERT … ON CONFLICT DO UPDATE" regression: spot-check
|
||||
// that price_idr / tag for one tier wasn't silently clobbered.
|
||||
tiersAfter.forEach((after) => {
|
||||
const before = tiersBefore.find((b) => b.id === after.id)
|
||||
expect(after.price_idr).toBe(before.price_idr)
|
||||
expect(after.tag).toBe(before.tag)
|
||||
expect(after.sort_order).toBe(before.sort_order)
|
||||
})
|
||||
|
||||
// UNIQUE (mode, minutes) on pricing_tiers should give us zero duplicate
|
||||
// (mode, minutes) pairs. Explicit query in case the constraint is ever
|
||||
// accidentally dropped in a future migration.
|
||||
const dupes = await sql`
|
||||
SELECT mode, minutes, COUNT(*)::int AS n
|
||||
FROM pricing_tiers
|
||||
GROUP BY mode, minutes
|
||||
HAVING COUNT(*) > 1
|
||||
`
|
||||
expect(dupes).toHaveLength(0)
|
||||
|
||||
// UNIQUE (eligibility) on pricing_promotions: same check.
|
||||
const promoDupes = await sql`
|
||||
SELECT eligibility, COUNT(*)::int AS n
|
||||
FROM pricing_promotions
|
||||
GROUP BY eligibility
|
||||
HAVING COUNT(*) > 1
|
||||
`
|
||||
expect(promoDupes).toHaveLength(0)
|
||||
} finally {
|
||||
await sql.end()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -52,8 +52,13 @@ export const resetDbHard = async () => {
|
||||
|
||||
/**
|
||||
* Drop and re-seed the configurable app_config rows back to their canonical defaults.
|
||||
* Tests that mutate config (e.g. flipping first_session_discount_enabled) call this
|
||||
* in afterEach.
|
||||
* Tests that mutate config (e.g. flipping pricing_promotions.enabled) call this in
|
||||
* afterEach.
|
||||
*
|
||||
* Note: the first-session discount config no longer lives in app_config (Stage 5
|
||||
* deleted those legacy keys). It now lives in the `pricing_promotions` table, which
|
||||
* is also reset here back to the seed defaults that match migrate.js + the
|
||||
* DEFAULT_DISCOUNT in pricing.service.js.
|
||||
*/
|
||||
export const resetAppConfig = async () => {
|
||||
const sql = db()
|
||||
@@ -69,12 +74,6 @@ export const resetAppConfig = async () => {
|
||||
['returning_chat_confirmation_timeout_seconds', { value: 20 }],
|
||||
['extension_default_action_on_timeout', { value: 'auto_approve' }],
|
||||
['pairing_blast_timeout_seconds', { value: 60 }],
|
||||
// Phase 4
|
||||
['first_session_discount_enabled', { value: true }],
|
||||
['first_session_discount_actual_price_idr', { value: 2000 }],
|
||||
['first_session_discount_gimmick_price_idr', { value: 12000 }],
|
||||
['first_session_discount_duration_minutes', { value: 12 }],
|
||||
['first_session_discount_modes', { value: ['chat'] }],
|
||||
['three_minute_warning_enabled', { value: true }],
|
||||
]
|
||||
for (const [key, value] of defaults) {
|
||||
@@ -84,4 +83,18 @@ export const resetAppConfig = async () => {
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
`
|
||||
}
|
||||
|
||||
// Reset pricing_promotions to canonical Phase 4 defaults. The Stage 1 backfill
|
||||
// gates on "table empty" so we can't rely on migrate.js to restore values after
|
||||
// a test mutates them — this UPDATE is the test-side reset hook.
|
||||
await sql`
|
||||
UPDATE pricing_promotions
|
||||
SET enabled = true,
|
||||
actual_price_idr = 2000,
|
||||
gimmick_price_idr = 12000,
|
||||
duration_minutes = 12,
|
||||
modes = ${['chat']},
|
||||
updated_at = NOW()
|
||||
WHERE eligibility = 'first_session'
|
||||
`
|
||||
}
|
||||
|
||||
@@ -57,6 +57,44 @@ export const createMitra = async ({
|
||||
*/
|
||||
export const seedDefaultConfig = () => resetAppConfig()
|
||||
|
||||
/**
|
||||
* Insert (or fetch) a control-center user with full `config` permissions. Used by
|
||||
* /internal/config/* route tests that need a JWT subject that survives the
|
||||
* `attachCcUser` + `requirePermission('config', …)` preHandler chain.
|
||||
*
|
||||
* Idempotent: re-runs return the same row by email. We do NOT truncate cc_user / roles
|
||||
* between tests (db.js documents the rationale), so subsequent test files inherit
|
||||
* whatever this seeded.
|
||||
*/
|
||||
export const createCcUser = async ({
|
||||
email = `cc-test-${randomUUID().slice(0, 8)}@halobestie.test`,
|
||||
displayName = 'CC Test User',
|
||||
permissions = {
|
||||
mitra: ['create', 'read', 'update', 'delete'],
|
||||
control_center_users: ['create', 'read', 'update', 'delete'],
|
||||
config: ['read', 'update'],
|
||||
roles: ['create', 'read', 'update', 'delete'],
|
||||
},
|
||||
} = {}) => {
|
||||
const sql = db()
|
||||
// One role per test invocation, named after a slice of the email so re-runs don't
|
||||
// collide with the seeded `super_admin` role from seed.js.
|
||||
const roleName = `cc-test-role-${email.slice(0, 16)}`
|
||||
const [role] = await sql`
|
||||
INSERT INTO roles (name, permissions)
|
||||
VALUES (${roleName}, ${sql.json(permissions)})
|
||||
ON CONFLICT (name) DO UPDATE SET permissions = EXCLUDED.permissions
|
||||
RETURNING id
|
||||
`
|
||||
const [user] = await sql`
|
||||
INSERT INTO control_center_users (email, display_name, role_id, password_hash)
|
||||
VALUES (${email}, ${displayName}, ${role.id}, 'unused-for-jwt-tests')
|
||||
ON CONFLICT (email) DO UPDATE SET role_id = EXCLUDED.role_id
|
||||
RETURNING id, email, display_name, role_id, created_at
|
||||
`
|
||||
return { ...user, role: { id: role.id, permissions } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: full reset between tests. Truncates Phase 3.7 tables, restores
|
||||
* default config rows.
|
||||
|
||||
178
backend/test/routes/internal/first-session-discount.test.js
Normal file
178
backend/test/routes/internal/first-session-discount.test.js
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
|
||||
vi.mock('../../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('../../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const { buildInternal } = await import('../../helpers/server.js')
|
||||
const { resetAppConfig, db } = await import('../../helpers/db.js')
|
||||
const { createCcUser } = await import('../../helpers/fixtures.js')
|
||||
const { ccJwt, authHeader } = await import('../../helpers/jwt.js')
|
||||
|
||||
/**
|
||||
* Stage 3 tests for the relational first-session-discount endpoints.
|
||||
*
|
||||
* The migration seeded the single 'first_session' promotion row; we mutate values
|
||||
* inside the test and restore in afterAll so other test files inherit clean state.
|
||||
*/
|
||||
describe('/internal/config/first-session-discount', () => {
|
||||
let app
|
||||
let ccUser
|
||||
let token
|
||||
let initialSnapshot
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
app = await buildInternal()
|
||||
ccUser = await createCcUser({ displayName: 'DiscountOperator' })
|
||||
token = ccJwt(ccUser.id)
|
||||
|
||||
// Snapshot the pre-test row so we can restore it after the suite.
|
||||
const sql = db()
|
||||
const [row] = await sql`
|
||||
SELECT id, enabled, actual_price_idr, gimmick_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions WHERE eligibility = 'first_session'
|
||||
`
|
||||
initialSnapshot = row
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (initialSnapshot) {
|
||||
const sql = db()
|
||||
await sql`
|
||||
UPDATE pricing_promotions SET
|
||||
enabled = ${initialSnapshot.enabled},
|
||||
actual_price_idr = ${initialSnapshot.actual_price_idr},
|
||||
gimmick_price_idr = ${initialSnapshot.gimmick_price_idr},
|
||||
duration_minutes = ${initialSnapshot.duration_minutes},
|
||||
modes = ${initialSnapshot.modes},
|
||||
updated_at = NOW()
|
||||
WHERE id = ${initialSnapshot.id}
|
||||
`
|
||||
// Drop any history rows this test file authored so the table doesn't bloat.
|
||||
await sql`
|
||||
DELETE FROM pricing_promotions_history
|
||||
WHERE promotion_id = ${initialSnapshot.id} AND changed_by = ${ccUser.id}
|
||||
`
|
||||
}
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('GET returns the current promotion row including updated_at', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data.eligibility).toBe('first_session')
|
||||
expect(typeof body.data.enabled).toBe('boolean')
|
||||
expect(typeof body.data.actual_price_idr).toBe('number')
|
||||
expect(typeof body.data.duration_minutes).toBe('number')
|
||||
expect(Array.isArray(body.data.modes)).toBe(true)
|
||||
expect(body.data.updated_at).toBeDefined()
|
||||
})
|
||||
|
||||
it('PATCH with correct updated_at updates the row and writes an update history row', async () => {
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const current = getRes.json().data
|
||||
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: {
|
||||
updated_at: current.updated_at,
|
||||
actual_price_idr: 2500,
|
||||
duration_minutes: 15,
|
||||
modes: ['chat', 'call'],
|
||||
},
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(200)
|
||||
const updated = patchRes.json().data
|
||||
expect(updated.actual_price_idr).toBe(2500)
|
||||
expect(updated.duration_minutes).toBe(15)
|
||||
expect(updated.modes).toEqual(['chat', 'call'])
|
||||
// updated_at advanced.
|
||||
expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(current.updated_at).getTime())
|
||||
|
||||
// History row.
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, changed_by, actual_price_idr, duration_minutes, modes
|
||||
FROM pricing_promotions_history
|
||||
WHERE promotion_id = ${current.id} AND changed_by = ${ccUser.id}
|
||||
ORDER BY changed_at DESC LIMIT 1
|
||||
`
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0].change_kind).toBe('update')
|
||||
expect(history[0].actual_price_idr).toBe(2500)
|
||||
expect(history[0].duration_minutes).toBe(15)
|
||||
expect(history[0].modes).toEqual(['chat', 'call'])
|
||||
})
|
||||
|
||||
it('PATCH with stale updated_at returns 409 STALE_WRITE', async () => {
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const current = getRes.json().data
|
||||
|
||||
const stale = new Date(new Date(current.updated_at).getTime() - 60_000).toISOString()
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: stale, actual_price_idr: 3000 },
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(409)
|
||||
const err = patchRes.json().error
|
||||
expect(err.code).toBe('STALE_WRITE')
|
||||
expect(err.server_updated_at).toBeDefined()
|
||||
})
|
||||
|
||||
it('PATCH without updated_at returns 422', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: { actual_price_idr: 3000 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('updated_at')
|
||||
})
|
||||
|
||||
it('PATCH with invalid modes returns 422', async () => {
|
||||
const getRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const current = getRes.json().data
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/first-session-discount',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: current.updated_at, modes: ['video'] },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('modes')
|
||||
})
|
||||
})
|
||||
349
backend/test/routes/internal/pricing-tiers.test.js
Normal file
349
backend/test/routes/internal/pricing-tiers.test.js
Normal file
@@ -0,0 +1,349 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
|
||||
// The internal app doesn't pull the WS plugin but does pull notification.service via
|
||||
// session routes. Mock both for parity with other route tests + safety.
|
||||
vi.mock('../../../src/plugins/websocket.js', () => ({
|
||||
sendToUser: vi.fn(() => false),
|
||||
sendToSessionParticipant: vi.fn(() => false),
|
||||
registerWebSocketPlugin: vi.fn(async () => {}),
|
||||
registerWebSocketRoute: vi.fn(),
|
||||
isUserOnlineWs: vi.fn(() => false),
|
||||
getSessionConnections: vi.fn(() => ({})),
|
||||
}))
|
||||
vi.mock('../../../src/services/notification.service.js', () => ({
|
||||
sendPushNotification: vi.fn(async () => true),
|
||||
registerDeviceToken: vi.fn(async () => {}),
|
||||
}))
|
||||
|
||||
const { buildInternal } = await import('../../helpers/server.js')
|
||||
const { resetAppConfig, db } = await import('../../helpers/db.js')
|
||||
const { createCcUser } = await import('../../helpers/fixtures.js')
|
||||
const { ccJwt, authHeader } = await import('../../helpers/jwt.js')
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* Stage 3 route tests for the relational pricing CRUD endpoints.
|
||||
*
|
||||
* We do NOT use resetDb() because:
|
||||
* 1. resetDb() doesn't truncate pricing_tiers (deliberate — backfill is expensive).
|
||||
* 2. The migration already seeded the canonical chat+call catalog at setup time.
|
||||
*
|
||||
* Tests that mutate the catalog use sql DELETE/UPDATE inside the test body and clean
|
||||
* up in afterEach. Tests that read the catalog rely on the seeded rows.
|
||||
*/
|
||||
describe('/internal/config/pricing-tiers', () => {
|
||||
let app
|
||||
let ccUser
|
||||
let token
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
app = await buildInternal()
|
||||
ccUser = await createCcUser({ displayName: 'PricingTierOperator' })
|
||||
token = ccJwt(ccUser.id)
|
||||
})
|
||||
|
||||
// Track tier UUIDs we POST/seed across ALL tests in this file so afterAll can
|
||||
// clean them up without disturbing the canonical seeded catalog. Deliberately
|
||||
// NOT reset between tests — every test that creates a row appends and the IDs
|
||||
// accumulate; afterAll iterates and DELETEs them at the end.
|
||||
const createdTierIds = []
|
||||
|
||||
afterAll(async () => {
|
||||
const sql = db()
|
||||
if (createdTierIds.length > 0) {
|
||||
await sql`DELETE FROM pricing_tiers_history WHERE tier_id = ANY(${createdTierIds})`
|
||||
await sql`DELETE FROM pricing_tiers WHERE id = ANY(${createdTierIds})`
|
||||
}
|
||||
// Defense-in-depth: also wipe any non-canonical chat/call tiers that slipped through.
|
||||
// The canonical catalog is the 5 chat + 4 call minutes seeded by migrate.js.
|
||||
await sql`
|
||||
DELETE FROM pricing_tiers
|
||||
WHERE (mode = 'chat' AND minutes NOT IN (5, 12, 30, 60, 120))
|
||||
OR (mode = 'call' AND minutes NOT IN (10, 20, 45, 60))
|
||||
`
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
describe('GET /internal/config/pricing-tiers', () => {
|
||||
it('returns the canonical chat + call catalog with updated_at on every row', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(Array.isArray(body.data.chat)).toBe(true)
|
||||
expect(Array.isArray(body.data.call)).toBe(true)
|
||||
expect(body.data.chat.length).toBeGreaterThan(0)
|
||||
expect(body.data.call.length).toBeGreaterThan(0)
|
||||
|
||||
const sample = body.data.chat[0]
|
||||
expect(UUID_RE.test(sample.id)).toBe(true)
|
||||
expect(typeof sample.minutes).toBe('number')
|
||||
expect(typeof sample.price_idr).toBe('number')
|
||||
expect('original_price_idr' in sample).toBe(true) // schema-only, but operator-facing GET exposes it
|
||||
expect('updated_at' in sample).toBe(true)
|
||||
expect('is_active' in sample).toBe(true)
|
||||
})
|
||||
|
||||
it('includes is_active=false tiers (operators must be able to see and re-activate them)', async () => {
|
||||
const sql = db()
|
||||
// Create a soft-deleted tier and verify it shows up in the internal GET.
|
||||
const [row] = await sql`
|
||||
INSERT INTO pricing_tiers (mode, minutes, price_idr, sort_order, is_active)
|
||||
VALUES ('chat', 777, 77000, 99, false)
|
||||
RETURNING id
|
||||
`
|
||||
createdTierIds.push(row.id)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
})
|
||||
const ids = res.json().data.chat.map((t) => t.id)
|
||||
expect(ids).toContain(row.id)
|
||||
const inactive = res.json().data.chat.find((t) => t.id === row.id)
|
||||
expect(inactive.is_active).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /internal/config/pricing-tiers — create', () => {
|
||||
it('happy path: creates a tier, writes a history row with change_kind=create, returns 201', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 45, price_idr: 33000, tag: 'test-create', sort_order: 99 },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json()
|
||||
expect(body.success).toBe(true)
|
||||
expect(UUID_RE.test(body.data.id)).toBe(true)
|
||||
expect(body.data.mode).toBe('chat')
|
||||
expect(body.data.minutes).toBe(45)
|
||||
expect(body.data.price_idr).toBe(33000)
|
||||
expect(body.data.tag).toBe('test-create')
|
||||
expect(body.data.is_active).toBe(true)
|
||||
createdTierIds.push(body.data.id)
|
||||
|
||||
// History row was written in the same transaction.
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, changed_by, price_idr, minutes, mode
|
||||
FROM pricing_tiers_history WHERE tier_id = ${body.data.id}
|
||||
`
|
||||
expect(history).toHaveLength(1)
|
||||
expect(history[0].change_kind).toBe('create')
|
||||
expect(history[0].changed_by).toBe(ccUser.id)
|
||||
expect(history[0].price_idr).toBe(33000)
|
||||
})
|
||||
|
||||
it('422 on duplicate (mode, minutes)', async () => {
|
||||
const sql = db()
|
||||
const [existing] = await sql`SELECT mode, minutes FROM pricing_tiers WHERE mode = 'chat' AND minutes = 12`
|
||||
expect(existing).toBeDefined()
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 12, price_idr: 99999 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.code).toBe('VALIDATION')
|
||||
})
|
||||
|
||||
it('422 on negative price', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 88, price_idr: -1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('price_idr')
|
||||
})
|
||||
|
||||
it('422 on non-positive minutes', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 0, price_idr: 1000 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('minutes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /internal/config/pricing-tiers/:id — update with optimistic-lock', () => {
|
||||
it('happy path: updates price_idr and writes update history with correct changed_by', async () => {
|
||||
// Create a fresh tier we own so we don't mutate the canonical catalog.
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 91, price_idr: 9100 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: created.updated_at, price_idr: 9999, tag: 'updated' },
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(200)
|
||||
const updated = patchRes.json().data
|
||||
expect(updated.price_idr).toBe(9999)
|
||||
expect(updated.tag).toBe('updated')
|
||||
expect(new Date(updated.updated_at).getTime()).toBeGreaterThanOrEqual(new Date(created.updated_at).getTime())
|
||||
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, changed_by, price_idr
|
||||
FROM pricing_tiers_history WHERE tier_id = ${created.id} ORDER BY changed_at ASC
|
||||
`
|
||||
expect(history).toHaveLength(2)
|
||||
expect(history[0].change_kind).toBe('create')
|
||||
expect(history[1].change_kind).toBe('update')
|
||||
expect(history[1].price_idr).toBe(9999)
|
||||
expect(history[1].changed_by).toBe(ccUser.id)
|
||||
})
|
||||
|
||||
it('409 STALE_WRITE on mismatched updated_at, with server_updated_at in the error', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 92, price_idr: 9200 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const stale = new Date(new Date(created.updated_at).getTime() - 60_000).toISOString()
|
||||
const patchRes = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: stale, price_idr: 1 },
|
||||
})
|
||||
expect(patchRes.statusCode).toBe(409)
|
||||
const err = patchRes.json().error
|
||||
expect(err.code).toBe('STALE_WRITE')
|
||||
expect(err.server_updated_at).toBeDefined()
|
||||
})
|
||||
|
||||
it('404 on unknown UUID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/pricing-tiers/00000000-0000-0000-0000-000000000000',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: new Date().toISOString(), price_idr: 1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('422 when id is not a UUID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/internal/config/pricing-tiers/not-a-uuid',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: new Date().toISOString(), price_idr: 1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('id')
|
||||
})
|
||||
|
||||
it('422 when updated_at is missing', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 93, price_idr: 9300 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { price_idr: 1 },
|
||||
})
|
||||
expect(res.statusCode).toBe(422)
|
||||
expect(res.json().error.field).toBe('updated_at')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /internal/config/pricing-tiers/:id — soft delete', () => {
|
||||
it('flips is_active=false and writes a delete history row', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 94, price_idr: 9400 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const delRes = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: created.updated_at },
|
||||
})
|
||||
expect(delRes.statusCode).toBe(200)
|
||||
expect(delRes.json().data.is_active).toBe(false)
|
||||
|
||||
const sql = db()
|
||||
const history = await sql`
|
||||
SELECT change_kind, is_active, changed_by
|
||||
FROM pricing_tiers_history WHERE tier_id = ${created.id} ORDER BY changed_at ASC
|
||||
`
|
||||
const last = history[history.length - 1]
|
||||
expect(last.change_kind).toBe('delete')
|
||||
// Per the contract: snapshot reflects POST-state, so is_active=false.
|
||||
expect(last.is_active).toBe(false)
|
||||
expect(last.changed_by).toBe(ccUser.id)
|
||||
})
|
||||
|
||||
it('409 STALE_WRITE on mismatched updated_at', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/internal/config/pricing-tiers',
|
||||
headers: authHeader(token),
|
||||
payload: { mode: 'chat', minutes: 95, price_idr: 9500 },
|
||||
})
|
||||
const created = createRes.json().data
|
||||
createdTierIds.push(created.id)
|
||||
|
||||
const stale = new Date(new Date(created.updated_at).getTime() - 60_000).toISOString()
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/internal/config/pricing-tiers/${created.id}`,
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: stale },
|
||||
})
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json().error.code).toBe('STALE_WRITE')
|
||||
})
|
||||
|
||||
it('404 on unknown UUID', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/internal/config/pricing-tiers/00000000-0000-0000-0000-000000000000',
|
||||
headers: authHeader(token),
|
||||
payload: { updated_at: new Date().toISOString() },
|
||||
})
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
})
|
||||
199
backend/test/services/pricing.service.test.js
Normal file
199
backend/test/services/pricing.service.test.js
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'
|
||||
import {
|
||||
getPricingForCustomer,
|
||||
isValidTier,
|
||||
findTier,
|
||||
isCustomerEligibleForFirstSessionDiscount,
|
||||
} from '../../src/services/pricing.service.js'
|
||||
import { SessionStatus } from '../../src/constants.js'
|
||||
import { resetDb, resetAppConfig, db } from '../helpers/db.js'
|
||||
import { createCustomer } from '../helpers/fixtures.js'
|
||||
|
||||
/**
|
||||
* Stage 3 service-layer tests for the relational-pricing rewrite.
|
||||
*
|
||||
* Scope:
|
||||
* - getPricingForCustomer returns the expected shape with UUIDs as `id`.
|
||||
* - isValidTier / findTier work for every default chat + call tier seeded by migrate.js.
|
||||
* - isCustomerEligibleForFirstSessionDiscount predicate unchanged (smoke).
|
||||
*
|
||||
* Notes:
|
||||
* - The test schema is seeded by migrate.js (run in setup.js), so pricing_tiers
|
||||
* already has the DEFAULT_* rows. We don't truncate them between tests — the
|
||||
* resetDb helper deliberately leaves pricing_* alone.
|
||||
* - UUIDs are stable across runs once seeded (backfill is empty-gated), so
|
||||
* assertions can compare on (mode, minutes) and just sanity-check `id` is a UUID.
|
||||
*/
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
// Expected post-backfill catalog (mirrors DEFAULT_* in pricing.service.js and migrate.js).
|
||||
const EXPECTED_CHAT = [
|
||||
{ minutes: 5, price_idr: 5000, tag: null },
|
||||
{ minutes: 12, price_idr: 12000, tag: 'paling pas' },
|
||||
{ minutes: 30, price_idr: 25000, tag: 'hemat' },
|
||||
{ minutes: 60, price_idr: 45000, tag: null },
|
||||
{ minutes: 120, price_idr: 80000, tag: 'best deal' },
|
||||
]
|
||||
const EXPECTED_CALL = [
|
||||
{ minutes: 10, price_idr: 9000, tag: null },
|
||||
{ minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
||||
{ minutes: 45, price_idr: 35000, tag: null },
|
||||
{ minutes: 60, price_idr: 45000, tag: 'hemat' },
|
||||
]
|
||||
|
||||
describe('pricing.service (Stage 3 — relational backing tables)', () => {
|
||||
let customer
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetAppConfig()
|
||||
// Defense against a sibling test file (e.g. pricing-tiers route tests) leaking
|
||||
// non-canonical rows: scrub anything that's not in the seeded catalog before we
|
||||
// start asserting on counts. The canonical seed is 5 chat + 4 call rows.
|
||||
const sql = db()
|
||||
await sql`
|
||||
DELETE FROM pricing_tiers
|
||||
WHERE (mode = 'chat' AND minutes NOT IN (5, 12, 30, 60, 120))
|
||||
OR (mode = 'call' AND minutes NOT IN (10, 20, 45, 60))
|
||||
`
|
||||
// Restore any soft-deleted canonical rows.
|
||||
await sql`
|
||||
UPDATE pricing_tiers
|
||||
SET is_active = true
|
||||
WHERE ((mode = 'chat' AND minutes IN (5, 12, 30, 60, 120))
|
||||
OR (mode = 'call' AND minutes IN (10, 20, 45, 60)))
|
||||
AND is_active = false
|
||||
`
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDb()
|
||||
const phone = `+628${Math.floor(Math.random() * 1e10).toString().padStart(10, '0')}`
|
||||
customer = await createCustomer({ callName: 'PricingSvcTester', phone })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Leave seeded pricing rows + the customer in place; resetDb between files is sufficient.
|
||||
})
|
||||
|
||||
describe('getPricingForCustomer', () => {
|
||||
it('returns chat + call groups with UUID ids, no original_price_idr leak, and the discount block', async () => {
|
||||
const data = await getPricingForCustomer(customer.id)
|
||||
|
||||
expect(Array.isArray(data.chat.tiers)).toBe(true)
|
||||
expect(Array.isArray(data.call.tiers)).toBe(true)
|
||||
expect(data.chat.tiers).toHaveLength(EXPECTED_CHAT.length)
|
||||
expect(data.call.tiers).toHaveLength(EXPECTED_CALL.length)
|
||||
|
||||
// Per-row checks: shape + UUID `id` + correct (minutes, price, tag).
|
||||
data.chat.tiers.forEach((tier, idx) => {
|
||||
expect(UUID_RE.test(tier.id)).toBe(true)
|
||||
expect(tier.minutes).toBe(EXPECTED_CHAT[idx].minutes)
|
||||
expect(tier.price_idr).toBe(EXPECTED_CHAT[idx].price_idr)
|
||||
expect(tier.tag).toBe(EXPECTED_CHAT[idx].tag)
|
||||
// original_price_idr must NOT be in the customer-facing shape.
|
||||
expect('original_price_idr' in tier).toBe(false)
|
||||
})
|
||||
data.call.tiers.forEach((tier, idx) => {
|
||||
expect(UUID_RE.test(tier.id)).toBe(true)
|
||||
expect(tier.minutes).toBe(EXPECTED_CALL[idx].minutes)
|
||||
expect(tier.price_idr).toBe(EXPECTED_CALL[idx].price_idr)
|
||||
expect(tier.tag).toBe(EXPECTED_CALL[idx].tag)
|
||||
expect('original_price_idr' in tier).toBe(false)
|
||||
})
|
||||
|
||||
// Discount block — phone-verified customer with no prior sessions = eligible.
|
||||
expect(data.first_session_discount).toMatchObject({
|
||||
eligible: true,
|
||||
actual_price_idr: 2000,
|
||||
gimmick_price_idr: 12000,
|
||||
duration_minutes: 12,
|
||||
modes: ['chat'],
|
||||
})
|
||||
})
|
||||
|
||||
it('honors sort_order ASC, minutes ASC and hides is_active=false tiers', async () => {
|
||||
const sql = db()
|
||||
// Soft-delete the chat-12-min tier and confirm it disappears from the customer feed.
|
||||
await sql`UPDATE pricing_tiers SET is_active = false WHERE mode = 'chat' AND minutes = 12`
|
||||
|
||||
try {
|
||||
const data = await getPricingForCustomer(customer.id)
|
||||
expect(data.chat.tiers.some((t) => t.minutes === 12)).toBe(false)
|
||||
// The remaining 4 chat tiers must come back in minutes-ASC order (sort_order is
|
||||
// 0..4 from backfill, and dropping 12-min keeps the rest monotone).
|
||||
const minutes = data.chat.tiers.map((t) => t.minutes)
|
||||
expect(minutes).toEqual([...minutes].sort((a, b) => a - b))
|
||||
} finally {
|
||||
// Restore so later tests in this file see the canonical catalog.
|
||||
await sql`UPDATE pricing_tiers SET is_active = true WHERE mode = 'chat' AND minutes = 12`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidTier / findTier', () => {
|
||||
it('isValidTier accepts every default chat tier and rejects bogus combos', async () => {
|
||||
for (const t of EXPECTED_CHAT) {
|
||||
const ok = await isValidTier({ mode: 'chat', durationMinutes: t.minutes, priceIdr: t.price_idr })
|
||||
expect(ok).toBe(true)
|
||||
}
|
||||
// Right minutes, wrong price → reject.
|
||||
expect(await isValidTier({ mode: 'chat', durationMinutes: 12, priceIdr: 9999 })).toBe(false)
|
||||
// Right price, wrong mode → reject (12000 is the 12-min chat price; no 12-min call tier).
|
||||
expect(await isValidTier({ mode: 'call', durationMinutes: 12, priceIdr: 12000 })).toBe(false)
|
||||
})
|
||||
|
||||
it('isValidTier accepts every default call tier', async () => {
|
||||
for (const t of EXPECTED_CALL) {
|
||||
const ok = await isValidTier({ mode: 'call', durationMinutes: t.minutes, priceIdr: t.price_idr })
|
||||
expect(ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('findTier returns the row for every (mode, minutes) in the seed catalog', async () => {
|
||||
for (const t of EXPECTED_CHAT) {
|
||||
const row = await findTier({ mode: 'chat', durationMinutes: t.minutes })
|
||||
expect(row).toBeTruthy()
|
||||
expect(row.minutes).toBe(t.minutes)
|
||||
expect(row.price_idr).toBe(t.price_idr)
|
||||
}
|
||||
for (const t of EXPECTED_CALL) {
|
||||
const row = await findTier({ mode: 'call', durationMinutes: t.minutes })
|
||||
expect(row).toBeTruthy()
|
||||
expect(row.minutes).toBe(t.minutes)
|
||||
expect(row.price_idr).toBe(t.price_idr)
|
||||
}
|
||||
// Unknown duration → null.
|
||||
expect(await findTier({ mode: 'chat', durationMinutes: 999 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCustomerEligibleForFirstSessionDiscount (smoke — predicate unchanged)', () => {
|
||||
it('phone-verified customer with no completed sessions is eligible', async () => {
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(customer.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('anonymous customer (no phone) is NOT eligible', async () => {
|
||||
const anon = await createCustomer({ callName: 'Anon', phone: null })
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(anon.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('customer with a completed session is NOT eligible', async () => {
|
||||
const sql = db()
|
||||
await sql`
|
||||
INSERT INTO chat_sessions (customer_id, status, duration_minutes, price)
|
||||
VALUES (${customer.id}, ${SessionStatus.COMPLETED}, 12, 12000)
|
||||
`
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(customer.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('eligibility goes false when the promotion is disabled', async () => {
|
||||
const sql = db()
|
||||
await sql`UPDATE pricing_promotions SET enabled = false, updated_at = NOW() WHERE eligibility = 'first_session'`
|
||||
try {
|
||||
expect(await isCustomerEligibleForFirstSessionDiscount(customer.id)).toBe(false)
|
||||
} finally {
|
||||
await sql`UPDATE pricing_promotions SET enabled = true, updated_at = NOW() WHERE eligibility = 'first_session'`
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
6
control_center/package-lock.json
generated
6
control_center/package-lock.json
generated
@@ -54,7 +54,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1220,7 +1219,6 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1307,7 +1305,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -1909,7 +1906,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -1922,7 +1918,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -2084,7 +2079,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -44,3 +44,16 @@ export const ExtensionTimeoutAction = Object.freeze({
|
||||
AUTO_APPROVE: 'auto_approve',
|
||||
AUTO_REJECT: 'auto_reject',
|
||||
})
|
||||
|
||||
// Session / pricing modes. Mirrors backend `SessionMode` enum.
|
||||
export const SessionMode = Object.freeze({
|
||||
CHAT: 'chat',
|
||||
CALL: 'call',
|
||||
})
|
||||
|
||||
// Mirror of backend error codes returned on optimistic-lock conflict / validation.
|
||||
export const ApiErrorCode = Object.freeze({
|
||||
STALE_WRITE: 'STALE_WRITE',
|
||||
VALIDATION: 'VALIDATION',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiClient } from '../../core/api/api-client'
|
||||
import { ExtensionTimeoutAction } from '../../core/constants'
|
||||
import { ExtensionTimeoutAction, SessionMode, ApiErrorCode } from '../../core/constants'
|
||||
|
||||
const fetchAnonymityConfig = async () => {
|
||||
const res = await apiClient.get('/internal/config/anonymity')
|
||||
@@ -127,13 +127,26 @@ const updateFirstSessionDiscount = async (patch) => {
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
// Phase 4: Pricing tier groups
|
||||
// Phase 4 / Stage 4: Per-row CRUD for pricing tiers (relational migration).
|
||||
// GET shape: { chat: [{id, mode, minutes, price_idr, original_price_idr, tag,
|
||||
// sort_order, is_active, updated_at, created_at}, ...], call: [...] }
|
||||
const fetchPricingTiers = async () => {
|
||||
const res = await apiClient.get('/internal/config/pricing-tiers')
|
||||
return res.data.data
|
||||
}
|
||||
const updatePricingTier = async ({ mode, tiers }) => {
|
||||
const res = await apiClient.patch(`/internal/config/pricing-tiers/${mode}`, { tiers })
|
||||
const createPricingTier = async (body) => {
|
||||
const res = await apiClient.post('/internal/config/pricing-tiers', body)
|
||||
return res.data.data
|
||||
}
|
||||
const patchPricingTier = async ({ id, ...body }) => {
|
||||
const res = await apiClient.patch(`/internal/config/pricing-tiers/${id}`, body)
|
||||
return res.data.data
|
||||
}
|
||||
// Soft-delete: backend sets is_active=false. updated_at is the optimistic-lock token.
|
||||
const deletePricingTier = async ({ id, updated_at }) => {
|
||||
const res = await apiClient.delete(`/internal/config/pricing-tiers/${id}`, {
|
||||
data: { updated_at },
|
||||
})
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
@@ -256,24 +269,56 @@ export default function SettingsPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-extension-default-action'] }),
|
||||
})
|
||||
|
||||
// Phase 4: First-session discount
|
||||
const { data: fsdData, isLoading: fsdLoading } = useQuery({
|
||||
// Stale-write toast surfaced from any of the per-row pricing mutations.
|
||||
// Plain string for now — settings page has no toast lib; the banner sits above the section.
|
||||
const [pricingToast, setPricingToast] = useState(null)
|
||||
|
||||
const handlePricingError = (err, { onRefetch }) => {
|
||||
const code = err?.response?.data?.error?.code
|
||||
const msg = err?.response?.data?.error?.message
|
||||
if (code === ApiErrorCode.STALE_WRITE || code === ApiErrorCode.NOT_FOUND) {
|
||||
setPricingToast('Someone else just edited this. Refreshing...')
|
||||
onRefetch?.()
|
||||
return
|
||||
}
|
||||
if (code === ApiErrorCode.VALIDATION) {
|
||||
setPricingToast(msg ? `Validation: ${msg}` : 'Validation failed.')
|
||||
return
|
||||
}
|
||||
setPricingToast(msg || 'Failed to save.')
|
||||
}
|
||||
|
||||
// Phase 4 / Stage 4: First-session discount with optimistic locking.
|
||||
const { data: fsdData, isLoading: fsdLoading, refetch: refetchFsd } = useQuery({
|
||||
queryKey: ['config-first-session-discount'],
|
||||
queryFn: fetchFirstSessionDiscount,
|
||||
})
|
||||
const fsdMutation = useMutation({
|
||||
mutationFn: updateFirstSessionDiscount,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-first-session-discount'] }),
|
||||
onError: (err) => handlePricingError(err, { onRefetch: refetchFsd }),
|
||||
})
|
||||
|
||||
// Phase 4: Pricing tier groups
|
||||
const { data: ptData, isLoading: ptLoading } = useQuery({
|
||||
// Phase 4 / Stage 4: Pricing tiers — per-row CRUD with optimistic locking.
|
||||
const { data: ptData, isLoading: ptLoading, refetch: refetchTiers } = useQuery({
|
||||
queryKey: ['config-pricing-tiers'],
|
||||
queryFn: fetchPricingTiers,
|
||||
})
|
||||
const ptMutation = useMutation({
|
||||
mutationFn: updatePricingTier,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['config-pricing-tiers'] }),
|
||||
const invalidateTiers = () => queryClient.invalidateQueries({ queryKey: ['config-pricing-tiers'] })
|
||||
const ptCreateMutation = useMutation({
|
||||
mutationFn: createPricingTier,
|
||||
onSuccess: invalidateTiers,
|
||||
onError: (err) => handlePricingError(err, { onRefetch: refetchTiers }),
|
||||
})
|
||||
const ptPatchMutation = useMutation({
|
||||
mutationFn: patchPricingTier,
|
||||
onSuccess: invalidateTiers,
|
||||
onError: (err) => handlePricingError(err, { onRefetch: refetchTiers }),
|
||||
})
|
||||
const ptDeleteMutation = useMutation({
|
||||
mutationFn: deletePricingTier,
|
||||
onSuccess: invalidateTiers,
|
||||
onError: (err) => handlePricingError(err, { onRefetch: refetchTiers }),
|
||||
})
|
||||
|
||||
// Phase 4: Support handles
|
||||
@@ -556,94 +601,42 @@ export default function SettingsPage() {
|
||||
{edaMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||
</section>
|
||||
|
||||
{/* Phase 4: First-session discount */}
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2>Diskon Sesi Pertama (Phase 4)</h2>
|
||||
<p>Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP. Menggantikan free trial Phase 3.</p>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fsdData?.enabled ?? false}
|
||||
onChange={e => fsdMutation.mutate({ enabled: e.target.checked })}
|
||||
disabled={fsdMutation.isPending}
|
||||
{/* Phase 4: First-session discount (Stage 4 — per-row optimistic lock) */}
|
||||
<FirstSessionDiscountSection
|
||||
data={fsdData}
|
||||
mutation={fsdMutation}
|
||||
toast={pricingToast}
|
||||
onDismissToast={() => setPricingToast(null)}
|
||||
/>
|
||||
Aktifkan diskon sesi pertama
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label>Harga aktual (IDR):</label>
|
||||
<input
|
||||
type="number" min="0"
|
||||
value={fsdData?.actual_price_idr ?? 2000}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ actual_price_idr: v })
|
||||
}}
|
||||
disabled={fsdMutation.isPending}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label>Harga gimik / coret (IDR):</label>
|
||||
<input
|
||||
type="number" min="0"
|
||||
value={fsdData?.gimmick_price_idr ?? 12000}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (Number.isFinite(v) && v >= 0) fsdMutation.mutate({ gimmick_price_idr: v })
|
||||
}}
|
||||
disabled={fsdMutation.isPending}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label>Durasi (menit):</label>
|
||||
<input
|
||||
type="number" min="1"
|
||||
value={fsdData?.duration_minutes ?? 12}
|
||||
onChange={e => {
|
||||
const v = parseInt(e.target.value, 10)
|
||||
if (Number.isFinite(v) && v >= 1) fsdMutation.mutate({ duration_minutes: v })
|
||||
}}
|
||||
disabled={fsdMutation.isPending}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span>Mode yang dapat diskon:</span>
|
||||
{['chat', 'call'].map(m => (
|
||||
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(fsdData?.modes ?? []).includes(m)}
|
||||
onChange={e => {
|
||||
const current = new Set(fsdData?.modes ?? [])
|
||||
if (e.target.checked) current.add(m)
|
||||
else current.delete(m)
|
||||
fsdMutation.mutate({ modes: Array.from(current) })
|
||||
}}
|
||||
disabled={fsdMutation.isPending}
|
||||
/>
|
||||
{m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{fsdMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan.</p>}
|
||||
</section>
|
||||
|
||||
{/* Phase 4: Pricing tier groups (mock) */}
|
||||
{/* Phase 4: Pricing tiers (Stage 4 — per-row CRUD) */}
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2>Tier Harga (Mock — Phase 4)</h2>
|
||||
<p>Daftar tier untuk chat dan voice call. JSON harus berupa array of {`{ id, minutes, price_idr, tag? }`}. Pricing masih di-mock — Xendit nyusul di phase berikutnya.</p>
|
||||
{['chat', 'call'].map((mode) => (
|
||||
<PricingTierEditor
|
||||
<h2>Tier Harga (Phase 4)</h2>
|
||||
<p>
|
||||
Daftar tier untuk chat dan voice call. Setiap baris di-edit terpisah dengan
|
||||
optimistic locking — perubahan operator lain akan trigger auto-refresh.
|
||||
</p>
|
||||
{pricingToast && (
|
||||
<div style={{
|
||||
marginBottom: 8, padding: 8, background: '#fff3cd', border: '1px solid #ffeeba',
|
||||
color: '#856404', borderRadius: 4, display: 'flex', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>{pricingToast}</span>
|
||||
<button type="button" onClick={() => setPricingToast(null)} style={{ marginLeft: 12 }}>
|
||||
Tutup
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{[SessionMode.CHAT, SessionMode.CALL].map((mode) => (
|
||||
<PricingTierTable
|
||||
key={mode}
|
||||
mode={mode}
|
||||
tiers={ptData?.[mode] ?? []}
|
||||
onSave={(tiers) => ptMutation.mutate({ mode, tiers })}
|
||||
isPending={ptMutation.isPending}
|
||||
createMutation={ptCreateMutation}
|
||||
patchMutation={ptPatchMutation}
|
||||
deleteMutation={ptDeleteMutation}
|
||||
/>
|
||||
))}
|
||||
{ptMutation.isError && <p style={{ color: 'red' }}>Gagal menyimpan tier — pastikan JSON valid.</p>}
|
||||
</section>
|
||||
|
||||
{/* Phase 4: Support handles */}
|
||||
@@ -698,50 +691,413 @@ export default function SettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Local helper — JSON-validated textarea editor for one mode's tier list. Keeps the
|
||||
// editing UX simple (paste JSON, hit Save) without forcing per-row form widgets.
|
||||
function PricingTierEditor({ mode, tiers, onSave, isPending }) {
|
||||
const initial = JSON.stringify(tiers, null, 2)
|
||||
const [draft, setDraft] = useState(initial)
|
||||
const [error, setError] = useState(null)
|
||||
// ============================================================================
|
||||
// Pricing tiers — per-row table editor (Stage 4)
|
||||
// ============================================================================
|
||||
//
|
||||
// Per-mode table. Each row carries its own `updated_at` token; PATCH/DELETE
|
||||
// echo it back to the backend so concurrent edits 409 instead of clobbering.
|
||||
//
|
||||
// Layout choice: inline-edit rows over modal. Operators here typically tweak one
|
||||
// price at a time; a modal would add a click without adding clarity.
|
||||
|
||||
// Reset draft when the upstream tiers change (e.g. after a successful save).
|
||||
useEffect(() => {
|
||||
setDraft(JSON.stringify(tiers, null, 2))
|
||||
setError(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(tiers)])
|
||||
const formatIdr = (n) => {
|
||||
if (n === null || n === undefined || n === '') return ''
|
||||
const v = typeof n === 'number' ? n : parseInt(n, 10)
|
||||
if (!Number.isFinite(v)) return ''
|
||||
return v.toLocaleString('id-ID')
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(draft)
|
||||
if (!Array.isArray(parsed)) throw new Error('expected an array')
|
||||
for (const t of parsed) {
|
||||
if (typeof t.id !== 'string' || typeof t.minutes !== 'number' || typeof t.price_idr !== 'number') {
|
||||
throw new Error('each tier needs id (string), minutes (number), price_idr (number)')
|
||||
}
|
||||
}
|
||||
setError(null)
|
||||
onSave(parsed)
|
||||
} catch (e) {
|
||||
setError(String(e.message || e))
|
||||
}
|
||||
const parseIdr = (s) => {
|
||||
if (s === null || s === undefined) return null
|
||||
const cleaned = String(s).replace(/[^\d-]/g, '')
|
||||
if (cleaned === '' || cleaned === '-') return null
|
||||
const v = parseInt(cleaned, 10)
|
||||
return Number.isFinite(v) ? v : null
|
||||
}
|
||||
|
||||
function PricingTierTable({ mode, tiers, createMutation, patchMutation, deleteMutation }) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<h3 style={{ margin: '12px 0 4px' }}>{mode === 'chat' ? 'Chat tiers' : 'Voice call tiers'}</h3>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
rows={10}
|
||||
style={{ width: '100%', fontFamily: 'monospace', fontSize: 12 }}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button onClick={handleSave} disabled={isPending} type="button">Simpan tier {mode}</button>
|
||||
{error && <span style={{ color: 'red', marginLeft: 8 }}>{error}</span>}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{mode === SessionMode.CHAT ? 'Chat tiers' : 'Voice call tiers'}
|
||||
</h3>
|
||||
<button type="button" onClick={() => setShowAdd(s => !s)} disabled={createMutation.isPending}>
|
||||
{showAdd ? 'Batal tambah' : '+ Tambah tier'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<AddTierForm
|
||||
mode={mode}
|
||||
onSubmit={(body) => createMutation.mutate(body, {
|
||||
onSuccess: () => setShowAdd(false),
|
||||
})}
|
||||
onCancel={() => setShowAdd(false)}
|
||||
isPending={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f7f7f7', textAlign: 'left' }}>
|
||||
<th style={th}>Menit</th>
|
||||
<th style={th}>Harga (IDR)</th>
|
||||
<th style={th}>Harga Coret (IDR)</th>
|
||||
<th style={th}>Tag</th>
|
||||
<th style={th}>Sort</th>
|
||||
<th style={th}>Aktif</th>
|
||||
<th style={th}>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tiers.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ ...td, color: '#999', fontStyle: 'italic' }}>Belum ada tier.</td></tr>
|
||||
)}
|
||||
{tiers.map((tier) => (
|
||||
editingId === tier.id ? (
|
||||
<EditTierRow
|
||||
key={tier.id}
|
||||
tier={tier}
|
||||
onSave={(patch) => patchMutation.mutate(
|
||||
{ id: tier.id, updated_at: tier.updated_at, ...patch },
|
||||
{ onSuccess: () => setEditingId(null) },
|
||||
)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
isPending={patchMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<ReadOnlyTierRow
|
||||
key={tier.id}
|
||||
tier={tier}
|
||||
onEdit={() => setEditingId(tier.id)}
|
||||
onDelete={() => {
|
||||
if (!window.confirm(`Soft-delete tier ${tier.minutes} menit (${formatIdr(tier.price_idr)} IDR)?`)) return
|
||||
deleteMutation.mutate({ id: tier.id, updated_at: tier.updated_at })
|
||||
}}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const th = { padding: '6px 8px', borderBottom: '1px solid #ddd' }
|
||||
const td = { padding: '6px 8px', borderBottom: '1px solid #eee' }
|
||||
|
||||
function ReadOnlyTierRow({ tier, onEdit, onDelete, isDeleting }) {
|
||||
return (
|
||||
<tr style={{ opacity: tier.is_active ? 1 : 0.55 }}>
|
||||
<td style={td}>{tier.minutes}</td>
|
||||
<td style={td}>{formatIdr(tier.price_idr)}</td>
|
||||
<td style={td}>{tier.original_price_idr ? formatIdr(tier.original_price_idr) : <span style={{ color: '#aaa' }}>—</span>}</td>
|
||||
<td style={td}>{tier.tag || <span style={{ color: '#aaa' }}>—</span>}</td>
|
||||
<td style={td}>{tier.sort_order}</td>
|
||||
<td style={td}>{tier.is_active ? 'ya' : 'tidak'}</td>
|
||||
<td style={td}>
|
||||
<button type="button" onClick={onEdit} style={{ marginRight: 4 }}>Edit</button>
|
||||
{tier.is_active && (
|
||||
<button type="button" onClick={onDelete} disabled={isDeleting} style={{ color: '#a00' }}>
|
||||
Soft-delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function EditTierRow({ tier, onSave, onCancel, isPending }) {
|
||||
// Local draft state; only fields that differ are sent on submit (PATCH semantics).
|
||||
const [priceStr, setPriceStr] = useState(formatIdr(tier.price_idr))
|
||||
const [origStr, setOrigStr] = useState(tier.original_price_idr ? formatIdr(tier.original_price_idr) : '')
|
||||
const [tag, setTag] = useState(tier.tag ?? '')
|
||||
const [sortOrder, setSortOrder] = useState(String(tier.sort_order))
|
||||
const [isActive, setIsActive] = useState(tier.is_active)
|
||||
const [minutes, setMinutes] = useState(String(tier.minutes))
|
||||
const [localError, setLocalError] = useState(null)
|
||||
|
||||
const submit = () => {
|
||||
const price = parseIdr(priceStr)
|
||||
const orig = origStr.trim() === '' ? null : parseIdr(origStr)
|
||||
const so = parseInt(sortOrder, 10)
|
||||
const mins = parseInt(minutes, 10)
|
||||
|
||||
if (price === null || price < 0) return setLocalError('Harga wajib diisi dan >= 0.')
|
||||
if (orig !== null && orig < price) return setLocalError('Harga coret harus >= harga.')
|
||||
if (!Number.isInteger(so)) return setLocalError('Sort order harus integer.')
|
||||
if (!Number.isInteger(mins) || mins <= 0) return setLocalError('Menit harus integer positif.')
|
||||
|
||||
// Build minimal patch: only include changed fields. mode/minutes are not patchable
|
||||
// server-side (backend PATCH ignores them) so we skip those here.
|
||||
const patch = {}
|
||||
if (price !== tier.price_idr) patch.price_idr = price
|
||||
if (orig !== tier.original_price_idr) patch.original_price_idr = orig
|
||||
if ((tag || null) !== (tier.tag || null)) patch.tag = tag.trim() === '' ? null : tag
|
||||
if (so !== tier.sort_order) patch.sort_order = so
|
||||
if (isActive !== tier.is_active) patch.is_active = isActive
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
setLocalError(null)
|
||||
onSave(patch)
|
||||
}
|
||||
|
||||
return (
|
||||
<tr style={{ background: '#fffdf3' }}>
|
||||
<td style={td}>
|
||||
{/* minutes is part of the unique key; backend doesn't support changing it,
|
||||
so we render it read-only but show the value for context. */}
|
||||
<input
|
||||
type="number" min="1" value={minutes}
|
||||
onChange={e => setMinutes(e.target.value)}
|
||||
style={{ width: 60 }}
|
||||
disabled
|
||||
title="Menit tidak bisa diubah — hapus dan buat tier baru."
|
||||
/>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<input
|
||||
type="text" inputMode="numeric"
|
||||
value={priceStr}
|
||||
onChange={e => setPriceStr(formatIdr(parseIdr(e.target.value) ?? ''))}
|
||||
style={{ width: 110 }}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<input
|
||||
type="text" inputMode="numeric" placeholder="opsional"
|
||||
value={origStr}
|
||||
onChange={e => setOrigStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))}
|
||||
style={{ width: 110 }}
|
||||
disabled={isPending}
|
||||
title="Anchor / strikethrough price (optional). Harus >= harga."
|
||||
/>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<input
|
||||
type="text" value={tag}
|
||||
onChange={e => setTag(e.target.value)}
|
||||
style={{ width: 110 }}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<input
|
||||
type="number" value={sortOrder}
|
||||
onChange={e => setSortOrder(e.target.value)}
|
||||
style={{ width: 60 }}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<input
|
||||
type="checkbox" checked={isActive}
|
||||
onChange={e => setIsActive(e.target.checked)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</td>
|
||||
<td style={td}>
|
||||
<button type="button" onClick={submit} disabled={isPending} style={{ marginRight: 4 }}>
|
||||
{isPending ? '...' : 'Simpan'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
|
||||
{localError && <div style={{ color: 'red', fontSize: 11, marginTop: 2 }}>{localError}</div>}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function AddTierForm({ mode, onSubmit, onCancel, isPending }) {
|
||||
const [minutes, setMinutes] = useState('')
|
||||
const [priceStr, setPriceStr] = useState('')
|
||||
const [origStr, setOrigStr] = useState('')
|
||||
const [tag, setTag] = useState('')
|
||||
const [sortOrder, setSortOrder] = useState('0')
|
||||
const [localError, setLocalError] = useState(null)
|
||||
|
||||
const submit = (e) => {
|
||||
e.preventDefault()
|
||||
const mins = parseInt(minutes, 10)
|
||||
const price = parseIdr(priceStr)
|
||||
const orig = origStr.trim() === '' ? null : parseIdr(origStr)
|
||||
const so = parseInt(sortOrder, 10)
|
||||
|
||||
if (!Number.isInteger(mins) || mins <= 0) return setLocalError('Menit harus integer positif.')
|
||||
if (price === null || price < 0) return setLocalError('Harga wajib diisi dan >= 0.')
|
||||
if (orig !== null && orig < price) return setLocalError('Harga coret harus >= harga.')
|
||||
if (!Number.isInteger(so)) return setLocalError('Sort order harus integer.')
|
||||
|
||||
setLocalError(null)
|
||||
onSubmit({
|
||||
mode,
|
||||
minutes: mins,
|
||||
price_idr: price,
|
||||
original_price_idr: orig,
|
||||
tag: tag.trim() === '' ? null : tag,
|
||||
sort_order: so,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} style={{
|
||||
padding: 8, marginBottom: 8, background: '#f0f7ff', border: '1px solid #cde',
|
||||
display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end',
|
||||
}}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||
Menit
|
||||
<input type="number" min="1" value={minutes} onChange={e => setMinutes(e.target.value)}
|
||||
style={{ width: 70 }} disabled={isPending} required />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||
Harga (IDR)
|
||||
<input type="text" inputMode="numeric" value={priceStr}
|
||||
onChange={e => setPriceStr(formatIdr(parseIdr(e.target.value) ?? ''))}
|
||||
style={{ width: 110 }} disabled={isPending} required />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||
Harga Coret (opsional)
|
||||
<input type="text" inputMode="numeric" value={origStr}
|
||||
onChange={e => setOrigStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))}
|
||||
style={{ width: 110 }} disabled={isPending}
|
||||
title="Anchor / strikethrough price (optional). Harus >= harga." />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||
Tag
|
||||
<input type="text" value={tag} onChange={e => setTag(e.target.value)}
|
||||
style={{ width: 110 }} disabled={isPending} placeholder="paling pas" />
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||
Sort
|
||||
<input type="number" value={sortOrder} onChange={e => setSortOrder(e.target.value)}
|
||||
style={{ width: 60 }} disabled={isPending} />
|
||||
</label>
|
||||
<button type="submit" disabled={isPending}>{isPending ? '...' : 'Tambah'}</button>
|
||||
<button type="button" onClick={onCancel} disabled={isPending}>Batal</button>
|
||||
{localError && <div style={{ color: 'red', fontSize: 12, width: '100%' }}>{localError}</div>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// First-session discount — single-row form with optimistic locking
|
||||
// ============================================================================
|
||||
//
|
||||
// Buffered "edit then Save" UX (not the per-keystroke pattern the old code used)
|
||||
// because optimistic locking requires us to send a single coherent patch with
|
||||
// the last-seen `updated_at`. Each field-level mutation would race itself.
|
||||
|
||||
function FirstSessionDiscountSection({ data, mutation, toast, onDismissToast }) {
|
||||
// Local form state, hydrated from server data. Reset whenever upstream changes
|
||||
// (e.g. after a successful PATCH or after a 409 auto-refetch).
|
||||
const [enabled, setEnabled] = useState(data?.enabled ?? false)
|
||||
const [actualStr, setActualStr] = useState(formatIdr(data?.actual_price_idr ?? 2000))
|
||||
const [gimmickStr, setGimmickStr] = useState(data?.gimmick_price_idr ? formatIdr(data.gimmick_price_idr) : '')
|
||||
const [duration, setDuration] = useState(String(data?.duration_minutes ?? 12))
|
||||
const [modes, setModes] = useState(data?.modes ?? [SessionMode.CHAT])
|
||||
const [localError, setLocalError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
setEnabled(data.enabled ?? false)
|
||||
setActualStr(formatIdr(data.actual_price_idr ?? 2000))
|
||||
setGimmickStr(data.gimmick_price_idr ? formatIdr(data.gimmick_price_idr) : '')
|
||||
setDuration(String(data.duration_minutes ?? 12))
|
||||
setModes(data.modes ?? [SessionMode.CHAT])
|
||||
setLocalError(null)
|
||||
}, [data?.updated_at])
|
||||
|
||||
const toggleMode = (m, checked) => {
|
||||
setModes(prev => checked ? Array.from(new Set([...prev, m])) : prev.filter(x => x !== m))
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (!data?.updated_at) return setLocalError('Belum ada data — tunggu fetch awal.')
|
||||
const actual = parseIdr(actualStr)
|
||||
const gimmick = gimmickStr.trim() === '' ? null : parseIdr(gimmickStr)
|
||||
const dur = parseInt(duration, 10)
|
||||
|
||||
if (actual === null || actual < 0) return setLocalError('Harga aktual harus >= 0.')
|
||||
if (gimmick !== null && gimmick < actual) return setLocalError('Harga gimik harus >= harga aktual.')
|
||||
if (!Number.isInteger(dur) || dur <= 0) return setLocalError('Durasi harus integer > 0.')
|
||||
if (modes.length === 0) return setLocalError('Pilih minimal satu mode.')
|
||||
|
||||
setLocalError(null)
|
||||
mutation.mutate({
|
||||
updated_at: data.updated_at,
|
||||
enabled,
|
||||
actual_price_idr: actual,
|
||||
gimmick_price_idr: gimmick,
|
||||
duration_minutes: dur,
|
||||
modes,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2>Diskon Sesi Pertama (Phase 4)</h2>
|
||||
<p>
|
||||
Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP.
|
||||
Menggantikan free trial Phase 3.
|
||||
</p>
|
||||
{toast && (
|
||||
<div style={{
|
||||
marginBottom: 8, padding: 8, background: '#fff3cd', border: '1px solid #ffeeba',
|
||||
color: '#856404', borderRadius: 4, display: 'flex', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>{toast}</span>
|
||||
<button type="button" onClick={onDismissToast} style={{ marginLeft: 12 }}>Tutup</button>
|
||||
</div>
|
||||
)}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)}
|
||||
disabled={mutation.isPending} />
|
||||
Aktifkan diskon sesi pertama
|
||||
</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label>Harga aktual (IDR):</label>
|
||||
<input type="text" inputMode="numeric" value={actualStr}
|
||||
onChange={e => setActualStr(formatIdr(parseIdr(e.target.value) ?? ''))}
|
||||
style={{ width: 140 }} disabled={mutation.isPending} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label>Harga gimik / coret (IDR):</label>
|
||||
<input type="text" inputMode="numeric" value={gimmickStr}
|
||||
onChange={e => setGimmickStr(e.target.value === '' ? '' : formatIdr(parseIdr(e.target.value) ?? ''))}
|
||||
style={{ width: 140 }} disabled={mutation.isPending}
|
||||
title="Optional. Harus >= harga aktual." />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<label>Durasi (menit):</label>
|
||||
<input type="number" min="1" value={duration} onChange={e => setDuration(e.target.value)}
|
||||
style={{ width: 80 }} disabled={mutation.isPending} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<span>Mode yang dapat diskon:</span>
|
||||
{[SessionMode.CHAT, SessionMode.CALL].map(m => (
|
||||
<label key={m} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<input type="checkbox" checked={modes.includes(m)}
|
||||
onChange={e => toggleMode(m, e.target.checked)}
|
||||
disabled={mutation.isPending} />
|
||||
{m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button type="button" onClick={save} disabled={mutation.isPending || !data}>
|
||||
{mutation.isPending ? 'Menyimpan...' : 'Simpan'}
|
||||
</button>
|
||||
{localError && <span style={{ color: 'red', fontSize: 12 }}>{localError}</span>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
511
requirement/pricing-relational-migration-plan.md
Normal file
511
requirement/pricing-relational-migration-plan.md
Normal file
@@ -0,0 +1,511 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user