- Test-OTP bypass allowlist for Apple reviewers / QA: phone-scoped static OTPs
managed in CC (Settings → Test OTP Bypass), bcrypt-hashed on save, kill-switch
toggle, per-entry expires_at. New `otp_requests` columns (is_bypass, code_hash)
+ DB CHECK enforcing bypass-row shape.
- Hash-at-rest for stub OTPs: replaced plaintext `<ref>:<code>` storage with
bcrypt(code_hash); reference goes to fazpass_reference alone. Verify routes on
sovereign is_bypass flag, defers code_hash-NULL rows to Fazpass.
- Fazpass integration (gated by FAZPASS_ENABLED env, default off): new
fazpass.service.js calling /v1/otp/{request,verify}; distinct errors for wrong
OTP (CODE_MISMATCH 401) vs provider outage (OTP_PROVIDER_FAILED 502).
- Removed redundant Free Trial CC section (was a back-compat shim for the same
pricing_promotions row as "Diskon Sesi Pertama") + unused alias in
pricing.service.js.
208 tests green (34 new for OTP + Fazpass). Fazpass API + dashboard PDFs added
at project root for reference (docs are auth-gated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
7.6 KiB
JavaScript
221 lines
7.6 KiB
JavaScript
import { getDb } from '../db/client.js'
|
|
import { SessionStatus } from '../constants.js'
|
|
|
|
const sql = getDb()
|
|
|
|
// 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' },
|
|
{ id: '30', minutes: 30, price_idr: 25000, tag: 'hemat' },
|
|
{ id: '60', minutes: 60, price_idr: 45000, tag: null },
|
|
{ id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
|
|
]
|
|
const DEFAULT_CALL_TIERS = [
|
|
{ id: '10', minutes: 10, price_idr: 9000, tag: null },
|
|
{ id: '20', minutes: 20, price_idr: 17000, tag: 'paling pas' },
|
|
{ id: '45', minutes: 45, price_idr: 35000, tag: null },
|
|
{ id: '60', 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'],
|
|
}
|
|
|
|
// 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 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 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)
|
|
}
|
|
|
|
/**
|
|
* 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'
|
|
`
|
|
if (!row) return DEFAULT_DISCOUNT
|
|
return {
|
|
enabled: row.enabled,
|
|
actual_price_idr: row.actual_price_idr,
|
|
gimmick_price_idr: row.gimmick_price_idr,
|
|
duration_minutes: row.duration_minutes,
|
|
modes: row.modes,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-customer first-session-discount eligibility.
|
|
*
|
|
* Predicate (Phase 4):
|
|
* - 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).
|
|
*
|
|
* Note: deviates from the plan's `users.phone_verified_at` reference — there is no such
|
|
* column. `phone IS NOT NULL` is the equivalent invariant in this schema.
|
|
*/
|
|
export const isCustomerEligibleForFirstSessionDiscount = async (customerId) => {
|
|
const discount = await readFirstSessionDiscountConfig()
|
|
if (!discount.enabled) return false
|
|
|
|
const [customer] = await sql`
|
|
SELECT phone FROM customers WHERE id = ${customerId}
|
|
`
|
|
if (!customer || !customer.phone) return false
|
|
|
|
const [prior] = await sql`
|
|
SELECT id FROM chat_sessions
|
|
WHERE customer_id = ${customerId}
|
|
AND status IN (${SessionStatus.COMPLETED}, ${SessionStatus.CLOSING})
|
|
LIMIT 1
|
|
`
|
|
return !prior
|
|
}
|
|
|
|
/**
|
|
* Pricing payload for the client. Returns chat + call tier groups plus a per-customer
|
|
* first-session-discount block.
|
|
*
|
|
* Shape:
|
|
* { chat: { tiers: [...] },
|
|
* call: { tiers: [...] },
|
|
* first_session_discount: {
|
|
* eligible: boolean,
|
|
* actual_price_idr, gimmick_price_idr, duration_minutes, modes: string[]
|
|
* } }
|
|
*/
|
|
export const getPricingForCustomer = async (customerId) => {
|
|
const [chatTiers, callTiers, discount, eligible] = await Promise.all([
|
|
readChatTiers(),
|
|
readCallTiers(),
|
|
readFirstSessionDiscountConfig(),
|
|
isCustomerEligibleForFirstSessionDiscount(customerId),
|
|
])
|
|
return {
|
|
chat: { tiers: chatTiers },
|
|
call: { tiers: callTiers },
|
|
first_session_discount: {
|
|
eligible,
|
|
actual_price_idr: discount.actual_price_idr,
|
|
gimmick_price_idr: discount.gimmick_price_idr,
|
|
duration_minutes: discount.duration_minutes,
|
|
modes: discount.modes,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a (mode, duration_minutes, price_idr) selection against the configured tiers.
|
|
* Used by payment-session creation as a defense-in-depth check.
|
|
*/
|
|
export const isValidTier = async ({ mode, durationMinutes, priceIdr }) => {
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Look up the canonical tier for (mode, duration_minutes). Returns null if no match.
|
|
*/
|
|
export const findTier = async ({ mode, durationMinutes }) => {
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Extension pricing — same chat tiers, but first-session discount NEVER applies.
|
|
* (Kept for parity with the old pricing.service shape; voice-call extensions are not
|
|
* a current feature, so we return chat tiers only.)
|
|
*/
|
|
// eslint-disable-next-line no-unused-vars
|
|
export const getExtensionPriceTiers = async (customerId) => {
|
|
const tiers = await readChatTiers()
|
|
return {
|
|
tiers,
|
|
first_session_discount: { eligible: false },
|
|
}
|
|
}
|
|
|
|
// ---- Back-compat aliases (will be removed after Phase 4 frontend cutover) ----
|
|
|
|
/**
|
|
* @deprecated Tiers now live in `chat`/`call` groups; callers should pick one.
|
|
* Returns chat tiers in the legacy shape (single array, no group wrapper).
|
|
*/
|
|
export const getPriceTiers = async () => {
|
|
const tiers = await readChatTiers()
|
|
// Legacy callers expected `{duration_minutes, price, label}` keys. Map.
|
|
return tiers.map((t) => ({
|
|
duration_minutes: t.minutes,
|
|
price: t.price_idr,
|
|
label: `${t.minutes} Menit`,
|
|
id: t.id,
|
|
tag: t.tag,
|
|
}))
|
|
}
|