import type { PayMeTransactionRow } from './payme-types'; /** * Verifying a sale callback by asking PayMe. Partners and direct sellers both * use this. * * WHY: a callback body proves nothing. A direct seller cannot check * `payme_signature` at all, and for a partner a valid one covers only two ids, not * the status or the amount. So the callback is a TRIGGER only: your server asks * PayMe what the sale looks like (POST /get-transactions, with the seller's MPL * in the JSON body, answered over TLS by PayMe itself) and applies PayMe's answer. * * WHY THE QUERY IS KEYED ON YOUR ORDER ID: checking only "is this payme_sale_id * completed?" can be beaten by replay. An attacker buys something cheap, keeps * the real payme_sale_id, then sends a callback carrying that id against an * expensive order. So you look the order up by the payme_sale_id YOU stored at * generate-sale, query the transactions of YOUR order id (`transaction_id`), and * every row must agree with the order on sale id, order id, seller, currency and * — for a paid sale — amount. * * AUTHORIZED SALES: by default get-transactions returns only transaction_type 1 * (sale). An authorization that has not been captured is type 40, so send * `transaction_type: 40` when confirming a sale-authorized callback. * * Reference: https://docs.payme.io/payments/query-apis/post-get-transactions */ export interface ExpectedSale { /** Your order id — what you sent as `transaction_id` on generate-sale. */ orderId: string; /** The payme_sale_id you stored when generate-sale answered. */ paymeSaleId: string; /** The MPL of the seller this order belongs to. */ sellerPaymeId: string; /** The order amount, minor units. */ priceMinor: number; currency: string; } export interface QueryVerification { ok: boolean; reason: string; /** PayMe's current sale_status, when verified. */ saleStatus: string | null; /** The transaction that carried the order amount, when there is one. */ paymeTransactionId: string | null; } /** Statuses that claim money moved, and so must match the order amount. */ const PAID_STATUSES = new Set(['completed', 'authorized']); export function verifySaleAgainstTransactions( rows: PayMeTransactionRow[] | undefined, expected: ExpectedSale, ): QueryVerification { const fail = (reason: string): QueryVerification => ({ ok: false, reason, saleStatus: null, paymeTransactionId: 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 forSale = (Array.isArray(rows) ? rows : []).filter( (row) => row.sale_payme_id === expected.paymeSaleId, ); if (!forSale.length) { return fail( `PayMe returned no transaction for order ${expected.orderId} with sale ${expected.paymeSaleId}.`, ); } for (const row of forSale) { // Checked only when PayMe returned the field: absence is not evidence of // a mismatch, but a different value is. if (row.transaction_id != null && row.transaction_id !== expected.orderId) { return fail( `PayMe reports order ${String(row.transaction_id)} for this sale, not ${expected.orderId}.`, ); } if ( row.seller_payme_id != null && row.seller_payme_id !== expected.sellerPaymeId ) { return fail('PayMe reports this sale under a different seller.'); } if (row.sale_currency != null && row.sale_currency !== expected.currency) { return fail( `PayMe reports currency ${String(row.sale_currency)}, the order is in ${expected.currency}.`, ); } } // Use the status on the newest row. The sort is stable, so rows without a // parseable timestamp keep PayMe's order. const latest = [...forSale].sort( (a, b) => timeOf(a.transaction_created_at) - timeOf(b.transaction_created_at), )[forSale.length - 1]; const saleStatus = typeof latest.sale_status === 'string' ? latest.sale_status : null; if (!saleStatus) { return fail('PayMe returned the sale without a sale_status.'); } // Refund rows carry the refunded amount, so look for any row with the full // order amount rather than depending on action names. const charge = forSale.find( (row) => row.transaction_price === expected.priceMinor, ); if (PAID_STATUSES.has(saleStatus) && !charge) { return fail( `PayMe reports the sale as ${saleStatus}, but no transaction matches the order amount of ${expected.priceMinor}.`, ); } return { ok: true, reason: `Verified against PayMe (get-transactions): sale is ${saleStatus}.`, saleStatus, paymeTransactionId: typeof charge?.payme_transaction_id === 'string' ? charge.payme_transaction_id : null, }; } /** * PayMe returns "Y-m-d H:i:s" ("2026-10-12 14:33:07"). Not every parser * accepts the space, so normalise to ISO first. Unparseable or missing * timestamps sort first. */ export function timeOf(value: unknown): number { if (typeof value !== 'string' || !value) return -Infinity; const time = Date.parse(value.trim().replace(' ', 'T')); return Number.isNaN(time) ? -Infinity : time; }