Replaces the two `pricing_*_tiers_json` blobs and five `first_session_discount_*` keys in app_config with dedicated `pricing_tiers` and `pricing_promotions` tables plus matching `_history` audit tables. UUID PKs, UNIQUE(mode, minutes) natural-key constraint, optimistic-lock via `updated_at` token returning 409 STALE_WRITE on conflicts. Every mutation writes a history row capturing the operator (changed_by from request.auth.userId) and change_kind. CC SettingsPage replaces the JSON-textarea editors with per-row tables — add / edit / soft-delete / reactivate / reorder, plus a buffered first-session discount form with the same optimistic-lock contract. `minutes` and `mode` are read-only on edit since they form the natural key; operators soft-delete and recreate to change duration. Stage 5 fixes a latent leak: `client.payment.routes.js` had its own local `readDiscountConfig` that still read from app_config — would have silently fallen to hardcoded defaults once the legacy rows were deleted. Now reads from pricing_promotions via the shared service helper, so CC edits to the first- session discount affect actual payment pricing on the next request. Customer-facing GET /api/client/chat/pricing shape unchanged (id values are now UUIDs instead of "5"/"12"/"60" but lookups happen by (mode, minutes), so no app changes needed). 27 new backend tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
200 lines
8.5 KiB
JavaScript
200 lines
8.5 KiB
JavaScript
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'`
|
|
}
|
|
})
|
|
})
|
|
})
|