// Merchant's DB state for one particular order. // Invariants: // paid => claimed // claimed => !!contractHash // requireClaimToken => !!claimToken // !!lastPaidSessionId => paid interface MerchantOrderInfo { orderId: string; requireClaimToken: boolean; claimToken?: string; contractHash?: string; claimed: boolean; paid: boolean; // Refund hasn't been picked up yet refundPending: boolean; fulfillmentUrl?: string; publicReorderUrl?: string; lastPaidSessionId?: string; } // Data from the client's request to /orders/{id} interface Req { orderId: string; contractHash?: string; claimToken?: string; sessionId?: string; accept: "json" | "html"; } // (Abstract) response to /orders/{id} interface Resp { httpStatus: string; contentType: "json" | "html"; // Schema type of the response responseType: string; // Redirect "Location: " if applicable to status code redirectLocation?: string; // "Taler: " header in the response talerHeader?: string; // Additional details about response response?: any; } // Abstracted merchant database type MerchantOrderStore = { [orderId: string]: MerchantOrderInfo }; // Logic for /orders/{id} function handlePublicOrdersGet(mos: MerchantOrderStore, req: Req): Resp { const ord = mos[req.orderId]; if (!ord) { return respNotFound(req); } if (!ord.claimed) { if (ord.requireClaimToken && ord.claimToken !== req.claimToken) { return respForbidden(req); } return respUnpaid(req, ord); } if (!ord.paid) { const hcOk = ord.contractHash === req.contractHash; const ctOk = ord.claimToken === req.claimToken; if (req.contractHash && !hcOk) { // Contract terms hash given but wrong return respForbidden(req); } if (req.claimToken && !ctOk) { // Claim token given but wrong return respForbidden(req); } if (ord.requireClaimToken && !req.claimToken && !hcOk) { // Client is trying to get the order status of a claimed, // unpaid order. However, the client is not showing authentication. // // This can happen when the fulfillment URL includes the order ID, // and the storefront redirects the user to the backend QR code // page, because the order is not paid under the current session. // This happens on bookmarking / link sharing. if (!ord.publicReorderUrl) { return respForbidden(req); } return respGoto(req, ord.publicReorderUrl); } return respUnpaid(req, ord); } // Here, we know that the order is paid for. // But we still need the ord.claimToken, because // the QR code page will poll until it gets a // fulfillment URL, but we decided that the // fulfillment URL should only be returned // when the client is authenticated. // (Otherwise, guessing the order ID might leak the // fulfillment URL). const authOk = ord.contractHash === req.contractHash || (ord.requireClaimToken && ord.claimToken === req.claimToken); if (!authOk) { return respForbidden(req); } if (!!req.sessionId && req.sessionId !== ord.lastPaidSessionId) { const alreadyPaidOrd = findAlreadyPaid(mos, req.sessionId); if (!!alreadyPaidOrd) { return respAlreadyPaid(req, alreadyPaidOrd); } return respUnpaid(req, ord); } return respPaid(req, ord); } function respNotFound(req: Req): Resp { return { contentType: req.accept, httpStatus: "404 Not Found", responseType: "TalerError", }; } function respForbidden(req: Req): Resp { return { contentType: req.accept, httpStatus: "403 Forbidden", responseType: "TalerError", }; } function respAlreadyPaid(req: Req, alreadyPaidOrd: MerchantOrderInfo): Resp { // This could be called with an empty fulfillment URL, but that doens't // really make sense for the client's perspective. if (req.accept === "html") { return { httpStatus: "302 Found", contentType: "html", redirectLocation: alreadyPaidOrd.fulfillmentUrl, responseType: "empty", }; } return { httpStatus: "202 Accepted", contentType: "json", responseType: "StatusGotoResponse", response: { fulfillment_url: alreadyPaidOrd.fulfillmentUrl, }, }; } function respGoto(req: Req, publicReorderUrl: string): Resp { if (req.accept === "html") { return { httpStatus: "302 Found", contentType: "html", redirectLocation: publicReorderUrl, responseType: "empty", }; } return { httpStatus: "202 Accepted", contentType: "json", responseType: "StatusGotoResponse", response: { public_reorder_url: publicReorderUrl, }, }; } function respUnpaid(req: Req, ord: MerchantOrderInfo): Resp { if (req.accept === "html") { return { httpStatus: "402 Payment Required", contentType: "html", responseType: "StatusUnpaidResponse", // This must include the claim token. The same taler:// // URI should also be shown as the QR code. talerHeader: "taler://pay/...", }; } return { httpStatus: "402 Payment Required", contentType: "json", responseType: "StatusUnpaidResponse", response: { // Required for repurchase detection fulfillmentUrl: ord.fulfillmentUrl, }, }; } function respPaid(req: Req, ord: MerchantOrderInfo): Resp { if (req.accept === "html") { if (ord.refundPending) { return { httpStatus: "402 Payment Required", contentType: "html", responseType: "QRCodeRefundPage", talerHeader: "taler://refund/...", }; } return { httpStatus: "302 Found", contentType: "html", redirectLocation: ord.fulfillmentUrl || "", responseType: "empty", }; } return { httpStatus: "200 OK", contentType: "json", responseType: "StatusPaidResponse", response: { fulfillmentUrl: ord.fulfillmentUrl, }, }; } // Helper to find an already paid order ID. function findAlreadyPaid( mos: MerchantOrderStore, sessionId: string ): MerchantOrderInfo | undefined { for (const orderId of Object.keys(mos)) { if (mos[orderId].lastPaidSessionId === sessionId) { return mos[orderId]; } } return undefined; }