← back to Costa Rica
test/webhooks-refund-idempotency.test.js
91 lines
'use strict';
// Payment-webhook refund + idempotency-key fixes (Cody state-machine audit, cycle 29):
// #1 ONVO STATUS_MAP maps refunded/reversed -> 'refunded' (was silently 'processing',
// so an ONVO refund never flipped the booking).
// #2 the webhook idempotency key is per-EVENT: a `paymentId:type` composite when the
// payload carries no distinct event id, so a 'succeeded' then a genuinely distinct
// 'refunded' for the SAME charge no longer collide on the key + drop the refund.
// #3 the refunded booking UPDATE sets updated_at and is idempotent (status<>'refunded').
//
// Secrets set before require (captured at module load). pool.query is a recording
// mock (captures args); tilopay.getCharge is stubbed per-test.
process.env.TILOPAY_WEBHOOK_SECRET = 'itest-tilo-secret';
const { test, before, after } = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const crypto = require('crypto');
const express = require('express');
const db = require('../lib/db');
const tilopay = require('../lib/payments/tilopay');
const onvo = require('../lib/payments/onvo');
const webhooks = require('../routes/webhooks');
let calls = [];
let webhookInsertRowCounts = []; // queue for successive webhook_events inserts (default 1 = first-seen)
let getChargeStub = null;
const origQuery = db.pool.query;
const origGetCharge = tilopay.getCharge;
let server, base;
before(async () => {
db.pool.query = async (sql, args) => {
calls.push({ sql, args });
if (/INSERT INTO webhook_events/.test(sql)) return { rows: [], rowCount: webhookInsertRowCounts.length ? webhookInsertRowCounts.shift() : 1 };
if (/UPDATE payments/.test(sql)) return { rows: [{ id: 1, booking_id: 42 }], rowCount: 1 }; // resolve a booking so the branch fires
return { rows: [], rowCount: 1 };
};
tilopay.getCharge = async (ref) => (getChargeStub ? getChargeStub(ref) : { status: 'processing', raw: {} });
const app = express();
app.use('/webhooks', webhooks);
await new Promise(r => { server = app.listen(0, r); });
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => { db.pool.query = origQuery; tilopay.getCharge = origGetCharge; server && server.close(); });
const tiloSign = (raw) => crypto.createHmac('sha256', 'itest-tilo-secret').update(raw).digest('hex');
function postTilo(obj) {
const body = JSON.stringify(obj);
const opts = { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), 'x-tilopay-signature': tiloSign(body) } };
return new Promise((resolve, reject) => {
const r = http.request(base + '/webhooks/tilopay', opts, res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode, body: b })); });
r.on('error', reject); r.end(body);
});
}
const externalIdsInserted = () => calls.filter(c => /INSERT INTO webhook_events/.test(c.sql)).map(c => c.args && c.args[1]);
test('#1 ONVO mapStatus maps a refund/reversal to refunded (was silently processing)', () => {
assert.equal(onvo.mapStatus('refunded'), 'refunded');
assert.equal(onvo.mapStatus('reversed'), 'refunded');
assert.equal(onvo.mapStatus('succeeded'), 'succeeded');
assert.equal(onvo.mapStatus('gibberish'), 'processing'); // default unchanged
});
test('#2 succeeded then refunded for the SAME charge get DISTINCT idempotency keys (refund not dropped)', async () => {
calls = []; webhookInsertRowCounts = [];
await postTilo({ paymentId: 'chg-1', status: 'succeeded' });
await postTilo({ paymentId: 'chg-1', status: 'refunded' });
assert.deepEqual(externalIdsInserted(), ['chg-1:succeeded', 'chg-1:refunded'],
'the two lifecycle events must key on distinct external_ids so the refund is not deduped away');
});
test('#2b a true replay (same charge + same status) still dedupes to ONE processing', async () => {
calls = []; webhookInsertRowCounts = [1, 0]; // 1st insert wins; 2nd hits ON CONFLICT -> dup
const a = await postTilo({ paymentId: 'chg-2', status: 'succeeded' });
const b = await postTilo({ paymentId: 'chg-2', status: 'succeeded' });
assert.equal(a.status, 200); assert.equal(b.status, 200);
assert.equal(calls.filter(c => /UPDATE payments/.test(c.sql)).length, 1, 'a replayed identical event must be deduped (one payments UPDATE only)');
});
test('#3 a refunded charge flips the booking with updated_at + an idempotent status guard', async () => {
calls = []; webhookInsertRowCounts = [];
getChargeStub = () => ({ status: 'refunded', raw: {} });
const r = await postTilo({ paymentId: 'chg-3', status: 'refunded' });
getChargeStub = null;
assert.equal(r.status, 200);
const refundUpd = calls.find(c => /UPDATE bookings SET status='refunded'/.test(c.sql));
assert.ok(refundUpd, 'a refunded charge must reach the booking refund UPDATE');
assert.match(refundUpd.sql, /updated_at=NOW\(\)/, 'refund UPDATE must set updated_at (reconcile keys off it)');
assert.match(refundUpd.sql, /status<>'refunded'/, 'refund UPDATE must be idempotent on replay');
});