Files
edr-platform/e2e/freight/payment-mock/server.js

81 lines
3.3 KiB
JavaScript

// 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}`);
});