import { callbackKind, CallbackKind, str } from './callback-fields'; import { SignatureStatus, verifySaleSignature } from './partner-signature'; /** * Framework-free callback routing for a PARTNER. Your route handler: * * 1. parse the body (PayMe POSTs application/x-www-form-urlencoded) * 2. const decision = decideCallback(body, credentials) * 3. record the event; for 'verify-by-query', look the record up by the id YOU * stored, query PayMe with the seller's MPL and apply only what it confirms: * sale -> get-transactions -> verifySaleAgainstTransactions() * subscription -> get-subscriptions -> verifySubscriptionAgainstQuery() * seller -> get-sellers * withdrawal -> get-withdrawals * 4. answer HTTP 200 for every decision, including 'reject' * * No decision here applies a callback body. Even a valid signature only means * "worth checking": it covers the credentials and two ids, not the status, the * amount or the notification type (partner-signature.ts). * * Why 200 even when rejecting: PayMe treats a status of 400 or above as a failed * delivery and retries it, up to three attempts in all. A retry cannot fix a bad * signature. Repeated failures can also make PayMe skip callbacks to your domain * for a while, so reconcile against the query APIs as well. * * Never validate the body against a strict schema that rejects unknown fields. * A strict schema would start dropping real callbacks the day PayMe adds a field. * * Reference: https://docs.payme.io/payments/getting-started/callbacks */ export interface PartnerCredentials { /** Partner key — the value sent as `payme_client_key`. */ clientKey: string; /** Partner secret (`merchant_password`). Stays on your server. */ clientSecret: string | null | undefined; } export type PartnerCallbackDecision = /** Confirm with get-transactions, then apply PayMe's answer — never the body. */ | { action: 'verify-by-query'; kind: 'sale'; paymeSaleId: string; reason: string } /** Confirm with get-subscriptions, then apply PayMe's answer — never the body. */ | { action: 'verify-by-query'; kind: 'subscription'; subPaymeId: string; reason: string } /** Re-read with get-sellers or get-withdrawals, then apply PayMe's answer — never the body. */ | { action: 'verify-by-query'; kind: 'seller' | 'withdrawal'; sellerPaymeId: string; reason: string } /** * Record it; change nothing because of it. When `paymeSaleId` is set you may * also query that sale, and apply PayMe's answer, but never move money from the body. */ | { action: 'record'; kind: CallbackKind; paymeSaleId?: string; reason: string } /** The signature is wrong or could not be checked. Record it, alert, and change nothing. */ | { action: 'reject'; kind: CallbackKind; signature: SignatureStatus; reason: string }; export function decideCallback( body: Record, credentials: PartnerCredentials, ): PartnerCallbackDecision { const kind = callbackKind(body); const { clientKey, clientSecret } = credentials; switch (kind) { case 'sale': { const check = verifySaleSignature(body, clientKey, clientSecret); const paymeSaleId = str(body.payme_sale_id); if (check.status === 'unsigned') { return { action: 'record', kind, ...(paymeSaleId ? { paymeSaleId } : {}), reason: 'Unsigned sale callback: no transaction behind it, so it proves nothing about money.', }; } return check.ok && paymeSaleId ? { action: 'verify-by-query', kind, paymeSaleId, reason: 'Signature matches, which is not proof of payment. Confirm with get-transactions.', } : { action: 'reject', kind, signature: check.status, reason: check.reason }; } case 'subscription': { // PayMe does not sign subscription callbacks, so any signature on the body // is ignored. The re-read decides, whatever the callback says. const subPaymeId = str(body.sub_payme_id); return subPaymeId ? { action: 'verify-by-query', kind, subPaymeId, reason: 'Subscription callbacks are unsigned. Re-read with get-subscriptions.', } : { action: 'record', kind, reason: 'Subscription callback without sub_payme_id: nothing to look up.', }; } case 'seller': case 'withdrawal': { // Unsigned by PayMe. Ignore the body, including its own seller_approved. const sellerPaymeId = str(body.seller_payme_id); return sellerPaymeId ? { action: 'verify-by-query', kind, sellerPaymeId, reason: kind === 'seller' ? 'Seller callbacks are unsigned. Re-read the seller with get-sellers.' : 'Withdrawal callbacks are unsigned. Re-read with get-withdrawals.', } : { action: 'record', kind, reason: 'Callback without seller_payme_id: nothing to look up.', }; } default: return { action: 'record', kind, reason: `Unknown notify_type ${JSON.stringify(body.notify_type ?? null)}.`, }; } }