From 1c9d81d81daa293c298a553929369d52982590ab Mon Sep 17 00:00:00 2001 From: ramadhan sjamsani Date: Sat, 16 May 2026 00:12:11 +0800 Subject: [PATCH] Pricing: migrate from app_config JSON to relational tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/package-lock.json | 28 +- backend/src/db/migrate.js | 236 +++++++ backend/src/routes/internal/config.routes.js | 441 +++++++++++-- .../routes/public/client.payment.routes.js | 25 +- backend/src/services/config.service.js | 108 +-- backend/src/services/pricing.service.js | 105 ++- backend/test/db/pricing-migration.test.js | 508 ++++++++++++++ backend/test/helpers/db.js | 29 +- backend/test/helpers/fixtures.js | 38 ++ .../internal/first-session-discount.test.js | 178 +++++ .../routes/internal/pricing-tiers.test.js | 349 ++++++++++ backend/test/services/pricing.service.test.js | 199 ++++++ control_center/package-lock.json | 6 - control_center/src/core/constants.js | 13 + .../src/pages/settings/SettingsPage.jsx | 618 ++++++++++++++---- .../pricing-relational-migration-plan.md | 511 +++++++++++++++ 16 files changed, 3076 insertions(+), 316 deletions(-) create mode 100644 backend/test/db/pricing-migration.test.js create mode 100644 backend/test/routes/internal/first-session-discount.test.js create mode 100644 backend/test/routes/internal/pricing-tiers.test.js create mode 100644 backend/test/services/pricing.service.test.js create mode 100644 requirement/pricing-relational-migration-plan.md diff --git a/backend/package-lock.json b/backend/package-lock.json index d68e314..157dd8b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index 6a7e071..d2b5608 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -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() } diff --git a/backend/src/routes/internal/config.routes.js b/backend/src/routes/internal/config.routes.js index bc049a2..14d3fc3 100644 --- a/backend/src/routes/internal/config.routes.js +++ b/backend/src/routes/internal/config.routes.js @@ -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) - 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() }) - }) + 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)) + } - 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' } }) + // 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 { 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 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 config = await setPricingTierGroup(mode, tiers) - await publishConfigInvalidate(`pricing_${mode}_tiers_json`) - return reply.send({ success: true, data: config }) + + 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: updated }) }) // --- Phase 4: Support handles --- diff --git a/backend/src/routes/public/client.payment.routes.js b/backend/src/routes/public/client.payment.routes.js index 8b52040..fa626dc 100644 --- a/backend/src/routes/public/client.payment.routes.js +++ b/backend/src/routes/public/client.payment.routes.js @@ -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 ( diff --git a/backend/src/services/config.service.js b/backend/src/services/config.service.js index f4d158e..2b8e0d6 100644 --- a/backend/src/services/config.service.js +++ b/backend/src/services/config.service.js @@ -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. diff --git a/backend/src/services/pricing.service.js b/backend/src/services/pricing.service.js index a1d2a8e..b804ce4 100644 --- a/backend/src/services/pricing.service.js +++ b/backend/src/services/pricing.service.js @@ -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 } diff --git a/backend/test/db/pricing-migration.test.js b/backend/test/db/pricing-migration.test.js new file mode 100644 index 0000000..cdddf4d --- /dev/null +++ b/backend/test/db/pricing-migration.test.js @@ -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_`), + * 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() + } + }) +}) diff --git a/backend/test/helpers/db.js b/backend/test/helpers/db.js index faa0137..3b714e3 100644 --- a/backend/test/helpers/db.js +++ b/backend/test/helpers/db.js @@ -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' + ` } diff --git a/backend/test/helpers/fixtures.js b/backend/test/helpers/fixtures.js index ffc43bf..2388935 100644 --- a/backend/test/helpers/fixtures.js +++ b/backend/test/helpers/fixtures.js @@ -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. diff --git a/backend/test/routes/internal/first-session-discount.test.js b/backend/test/routes/internal/first-session-discount.test.js new file mode 100644 index 0000000..163ebb0 --- /dev/null +++ b/backend/test/routes/internal/first-session-discount.test.js @@ -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') + }) +}) diff --git a/backend/test/routes/internal/pricing-tiers.test.js b/backend/test/routes/internal/pricing-tiers.test.js new file mode 100644 index 0000000..64b5d7e --- /dev/null +++ b/backend/test/routes/internal/pricing-tiers.test.js @@ -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) + }) + }) +}) diff --git a/backend/test/services/pricing.service.test.js b/backend/test/services/pricing.service.test.js new file mode 100644 index 0000000..9650e65 --- /dev/null +++ b/backend/test/services/pricing.service.test.js @@ -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'` + } + }) + }) +}) diff --git a/control_center/package-lock.json b/control_center/package-lock.json index d226ae6..924d582 100644 --- a/control_center/package-lock.json +++ b/control_center/package-lock.json @@ -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", diff --git a/control_center/src/core/constants.js b/control_center/src/core/constants.js index dcdc948..84f3a6d 100644 --- a/control_center/src/core/constants.js +++ b/control_center/src/core/constants.js @@ -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', +}) diff --git a/control_center/src/pages/settings/SettingsPage.jsx b/control_center/src/pages/settings/SettingsPage.jsx index 7d0c3e7..672b728 100644 --- a/control_center/src/pages/settings/SettingsPage.jsx +++ b/control_center/src/pages/settings/SettingsPage.jsx @@ -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 &&

Gagal menyimpan.

} - {/* Phase 4: First-session discount */} -
-

Diskon Sesi Pertama (Phase 4)

-

Diskon untuk sesi pertama customer yang sudah terverifikasi nomor HP. Menggantikan free trial Phase 3.

- -
- - { - 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 }} - /> -
-
- - { - 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 }} - /> -
-
- - { - const v = parseInt(e.target.value, 10) - if (Number.isFinite(v) && v >= 1) fsdMutation.mutate({ duration_minutes: v }) - }} - disabled={fsdMutation.isPending} - style={{ width: 80 }} - /> -
-
- Mode yang dapat diskon: - {['chat', 'call'].map(m => ( - - ))} -
- {fsdMutation.isError &&

Gagal menyimpan.

} -
+ {/* Phase 4: First-session discount (Stage 4 — per-row optimistic lock) */} + setPricingToast(null)} + /> - {/* Phase 4: Pricing tier groups (mock) */} + {/* Phase 4: Pricing tiers (Stage 4 — per-row CRUD) */}
-

Tier Harga (Mock — Phase 4)

-

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.

- {['chat', 'call'].map((mode) => ( - Tier Harga (Phase 4) +

+ Daftar tier untuk chat dan voice call. Setiap baris di-edit terpisah dengan + optimistic locking — perubahan operator lain akan trigger auto-refresh. +

+ {pricingToast && ( +
+ {pricingToast} + +
+ )} + {[SessionMode.CHAT, SessionMode.CALL].map((mode) => ( + ptMutation.mutate({ mode, tiers })} - isPending={ptMutation.isPending} + createMutation={ptCreateMutation} + patchMutation={ptPatchMutation} + deleteMutation={ptDeleteMutation} /> ))} - {ptMutation.isError &&

Gagal menyimpan tier — pastikan JSON valid.

}
{/* 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 ( -
-

{mode === 'chat' ? 'Chat tiers' : 'Voice call tiers'}

-