// Minimal payment-service stand-in for the e2e stack. // // WHY THIS EXISTS // // `expireBooking` will not expire an unpaid reservation until it has asked the // payment gateway whether the money landed late — reconcile-before-expire // (booking-batch.service.ts:3484-3506). Any error answering that question is // treated as `unverifiable: true`, and an unverifiable answer DEFERS the // expiry rather than risk expiring a customer who actually paid: // // [BATCH] expire deferred for BK-… — settlement unverifiable at the // gateway; retrying next settle tick // // That is correct in production. In e2e there is no payment microservice, and // PAYMENT_API_URL defaults to the real https://paymentcallback.triaplc.com // (payment-client.service.ts:25), so every reconcile call fails and EVERY // unpaid hold defers forever. Six scenarios turn on a reservation expiring // (G1·S1, G1·S5, G1·S6, G3·S16, G5·S22, G5·S24), and all of them hang on it. // // This server answers the two calls that path makes, so the engine gets a // definite "no payment exists" and expires the hold as designed. It is // deliberately dumb: nothing here simulates a real gateway, and the specs that // need a SUCCESSFUL payment do not come through here at all — they deliver // `payment.succeeded` to the API's own internal webhook (see settleViaGateway // in import-utils.ts), which is the real production path for a settled // payment. const http = require("node:http"); const PORT = process.env.PORT || 4500; /** * `paid: false, unverifiable: false` = "the gateway is reachable and holds no * settled payment for this reference". That is the answer that lets an expiry * proceed. Returning `unverifiable: true` here would reproduce the exact * deadlock this mock exists to remove. */ const NOT_PAID = { paid: false, unverifiable: false }; const server = http.createServer((req, res) => { let body = ""; req.on("data", (chunk) => { body += chunk; }); req.on("end", () => { const send = (status, payload) => { const json = JSON.stringify(payload); res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(json), }); res.end(json); }; // POST /payments/reconcile — the reconcile-before-expire call. if (req.method === "POST" && req.url.startsWith("/payments/reconcile")) { console.log(`[payment-mock] reconcile ${body || "(no body)"} → not paid`); return send(200, NOT_PAID); } // GET /payments/intents?… — the intent lookup. 404 is a valid "no intent // for this reference" answer and the client maps it to null rather than // treating it as an error (payment-client.service.ts:72-73). if (req.method === "GET" && req.url.startsWith("/payments/intents")) { console.log(`[payment-mock] intents ${req.url} → 404 (none)`); return send(404, { message: "No intent for this reference" }); } // Health probe for the compose healthcheck. if (req.method === "GET" && req.url.startsWith("/health")) { return send(200, { ok: true }); } console.log(`[payment-mock] unhandled ${req.method} ${req.url}`); send(404, { message: `Unhandled ${req.method} ${req.url}` }); }); }); server.listen(PORT, () => { console.log(`payment-mock listening on ${PORT}`); });