import type { PayMeSaleDetails, PayMeSubscriptionDetails } from './payme-types'; /** * Helpers for reading a PayMe callback body, shared by partner-callbacks.ts and * seller-callbacks.ts. * * PayMe POSTs callbacks as application/x-www-form-urlencoded, so every value * arrives as a STRING: "0" and "false" are truthy, and `body.status_code === 0` * is always false until you coerce. * * Use the coerced values for logging and display. The state you write comes from * re-reading PayMe (sale-query-verification.ts, subscription-verification.ts), * never from a callback body. */ export type CallbackKind = | 'sale' | 'subscription' | 'seller' | 'withdrawal' | 'unknown'; /** * Sale notifications this kit routes. PayMe also sends sale-create, sale-paid and * sale-il-direct-debit-update; those land in 'unknown' here and are only recorded. */ const SALE_NOTIFY_TYPES = new Set([ 'sale-complete', 'sale-authorized', 'refund', 'sale-failure', 'sale-chargeback', 'sale-chargeback-refund', ]); export function callbackKind(body: Record): CallbackKind { const type = str(body.notify_type) ?? ''; if (SALE_NOTIFY_TYPES.has(type)) return 'sale'; if (type.startsWith('sub-')) return 'subscription'; if (type.startsWith('seller-')) return 'seller'; if (type.startsWith('withdrawal-')) return 'withdrawal'; return 'unknown'; } export function str(value: unknown): string | null { if (typeof value === 'string' && value.length) return value; if (typeof value === 'number') return String(value); return null; } export function num(value: unknown): number | undefined { if (value === null || value === undefined || value === '') return undefined; const parsed = Number(value); return Number.isNaN(parsed) ? undefined : parsed; } export function bool(value: unknown): boolean { return value === true || value === 'true' || value === '1' || value === 1; } /** * Numeric coercion that PRESERVES ABSENCE. * * PayMe leaves empty fields out of a callback. Defaulting a missing field to 0 * turns "this notification says nothing about the price" into "the price is * zero". Absent keys stay absent; your code must skip anything undefined. */ export function coerceNumericFields( body: Record, keys: readonly string[], ): T { const result: Record = { ...body }; for (const key of keys) { if (key in body) { const parsed = num(body[key]); if (parsed === undefined) delete result[key]; else result[key] = parsed; } } return result as T; } const SALE_NUMERIC_FIELDS = [ 'status_code', 'status_error_code', 'price', 'payme_sale_code', 'installments', ] as const; /** * The result is typed as PayMeSaleDetails for convenience, but any field the * callback did not carry is ABSENT — including status_code and is_token_sale. * Check presence before reading; never treat absence as 0 or false. */ export function coerceSaleDetails( body: Record, ): PayMeSaleDetails { return { ...coerceNumericFields(body, SALE_NUMERIC_FIELDS), ...('is_token_sale' in body ? { is_token_sale: bool(body.is_token_sale) } : {}), }; } const SUBSCRIPTION_NUMERIC_FIELDS = [ 'status_code', 'status_error_code', 'sub_status', 'sub_price', 'sub_iteration_type', 'sub_iterations', 'sub_iterations_completed', 'sub_iterations_skipped', 'sub_iterations_left', 'sub_payme_code', ] as const; /** Same contract as coerceSaleDetails: absent fields stay absent. */ export function coerceSubscriptionDetails( body: Record, ): PayMeSubscriptionDetails { return { ...coerceNumericFields( body, SUBSCRIPTION_NUMERIC_FIELDS, ), ...('sub_paid' in body ? { sub_paid: bool(body.sub_paid) } : {}), }; }