// 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); } const authMissing = !!req.contractHash && !!req.claimToken; // For this endpoint, when the order does not have a claim token, // the order status can be accessed *without* h_contract. const authOk = ord.contractHash === req.contractHash || (ord.requireClaimToken && ord.claimToken === req.claimToken) || !ord.requireClaimToken; if (authMissing && ord.requireClaimToken) { // Client is trying to get the order status of an // 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); } // Even if an order is paid for, // 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). if (!authOk) { return respForbidden(req); } if (!!req.sessionId && req.sessionId !== ord.lastPaidSessionId) { if (!!ord.fulfillmentUrl) { const alreadyPaidOrd = findAlreadyPaid( mos, req.sessionId, ord.fulfillmentUrl ); if (!!alreadyPaidOrd) { return respAlreadyPaid(req, alreadyPaidOrd); } } } if (ord.paid) { return respPaid(req, ord); } return respUnpaid(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 doesn'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: "402 PaymentRequired", contentType: "json", responseType: "StatusUnpaidResponse", response: { fulfillment_url: alreadyPaidOrd.fulfillmentUrl, already_paid_order_id: alreadyPaidOrd.orderId, }, }; } 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/...", }; } // We do not redirect here. Only // the JS on the QR code page automatically redirects. // Without JS, the user has to manually click through to // the fulfillment URL. return { httpStatus: "200 OK", contentType: "html", responseType: "OrderStatusHtmlPage", }; } 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, fulfillmentUrl: string ): MerchantOrderInfo | undefined { for (const orderId of Object.keys(mos)) { if ( mos[orderId].lastPaidSessionId === sessionId && mos[orderId].fulfillmentUrl === fulfillmentUrl ) { return mos[orderId]; } } return undefined; }