import type { PayMeSubscriptionRow } from './payme-types'; /** * Verifying a subscription callback by asking PayMe — the same pattern as sales * (sale-query-verification.ts). Partners and direct sellers both use this. * * PayMe does not sign subscription callbacks, so a `sub-*` callback is a trigger * only: look the subscription up by the `sub_payme_id` YOU stored at * generate-subscription, query POST /get-subscriptions with the seller's MPL (in * the JSON body, as a single value) and that id, and apply PayMe's answer. * Nothing from the callback body is trusted. * * Side effects (granting a period of service, dunning, ending access) key on * PayMe's numbers, not on the notification type: grant one period for each * increase in `sub_iterations_completed` you have not granted before. That is * idempotent however many callbacks arrive, in whatever order. * * Reference: https://docs.payme.io/payments/query-apis/post-get-subscriptions */ export type SubscriptionStatus = | 'initial' | 'active' | 'paused' | 'failed' | 'cancelled' | 'completed' /** 7: an iteration failed and PayMe will retry. Do not dun. */ | 'failed-retry'; const BY_CODE: Record = { '1': 'initial', '2': 'active', '3': 'paused', '4': 'failed', '5': 'cancelled', '6': 'completed', '7': 'failed-retry', }; const BY_NAME: Record = { initial: 'initial', active: 'active', paused: 'paused', failed: 'failed', cancelled: 'cancelled', canceled: 'cancelled', completed: 'completed', 'failed-retry': 'failed-retry', }; /** * PayMe's `sub_status` is a code. It arrives as a number from get-subscriptions * and as a string on a form-encoded callback; names are accepted too. Anything * else is null: an unknown status must not be acted on. * Statuses: https://docs.payme.io/payments/codes-statuses/subscription-statuses */ export function normaliseSubscriptionStatus(value: unknown): SubscriptionStatus | null { if (typeof value === 'number' && Number.isInteger(value)) return BY_CODE[String(value)] ?? null; if (typeof value !== 'string') return null; const text = value.trim().toLowerCase(); return BY_CODE[text] ?? BY_NAME[text] ?? null; } export interface ExpectedSubscription { /** The sub_payme_id you stored when generate-subscription answered. */ subPaymeId: string; /** The MPL of the seller this subscription belongs to. */ sellerPaymeId: string; currency: string; } export interface SubscriptionVerification { ok: boolean; reason: string; status: SubscriptionStatus | null; /** PayMe's count of charged iterations; null when PayMe did not say. */ iterationsCompleted: number | null; } export function verifySubscriptionAgainstQuery( rows: PayMeSubscriptionRow[] | undefined, expected: ExpectedSubscription, ): SubscriptionVerification { const fail = (reason: string): SubscriptionVerification => ({ ok: false, reason, status: null, iterationsCompleted: null, }); // A row of another seller anywhere in the answer: discard the whole answer // and alert. Never use, store or log another seller's data. const foreign = (Array.isArray(rows) ? rows : []).some( (row) => row.seller_payme_id != null && row.seller_payme_id !== expected.sellerPaymeId, ); if (foreign) { return fail( 'PayMe returned rows of another seller. Answer discarded; alert.', ); } const matching = (Array.isArray(rows) ? rows : []).filter( (row) => row.sub_payme_id === expected.subPaymeId, ); if (!matching.length) { return fail(`PayMe returned no subscription ${expected.subPaymeId} for this seller.`); } if (matching.length > 1) { return fail('PayMe returned more than one row for this subscription; not guessing.'); } const row = matching[0]; if (row.seller_payme_id != null && row.seller_payme_id !== expected.sellerPaymeId) { return fail('PayMe reports this subscription under a different seller.'); } if (row.sub_currency != null && row.sub_currency !== expected.currency) { return fail( `PayMe reports currency ${String(row.sub_currency)}, the subscription is in ${expected.currency}.`, ); } const status = normaliseSubscriptionStatus(row.sub_status); if (!status) { return fail(`PayMe reports an unknown sub_status ${JSON.stringify(row.sub_status ?? null)}.`); } const raw = row.sub_iterations_completed; const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && /^\d+$/.test(raw.trim()) ? Number(raw) : null; const iterationsCompleted = parsed !== null && Number.isSafeInteger(parsed) ? parsed : null; return { ok: true, reason: `Verified against PayMe (get-subscriptions): subscription is ${status}.`, status, iterationsCompleted, }; }