public-orders-get.ts (6247B)
1 // Merchant's DB state for one particular order. 2 // Invariants: 3 // paid => claimed 4 // claimed => !!contractHash 5 // requireClaimToken => !!claimToken 6 // !!lastPaidSessionId => paid 7 interface MerchantOrderInfo { 8 orderId: string; 9 requireClaimToken: boolean; 10 claimToken?: string; 11 contractHash?: string; 12 claimed: boolean; 13 paid: boolean; 14 // Refund hasn't been picked up yet 15 refundPending: boolean; 16 fulfillmentUrl?: string; 17 publicReorderUrl?: string; 18 lastPaidSessionId?: string; 19 } 20 21 // Data from the client's request to /orders/{id} 22 interface Req { 23 orderId: string; 24 contractHash?: string; 25 claimToken?: string; 26 sessionId?: string; 27 accept: "json" | "html"; 28 } 29 30 // (Abstract) response to /orders/{id} 31 interface Resp { 32 httpStatus: string; 33 contentType: "json" | "html"; 34 // Schema type of the response 35 responseType: string; 36 // Redirect "Location: " if applicable to status code 37 redirectLocation?: string; 38 // "Taler: " header in the response 39 talerHeader?: string; 40 // Additional details about response 41 response?: any; 42 } 43 44 // Abstracted merchant database 45 type MerchantOrderStore = { [orderId: string]: MerchantOrderInfo }; 46 47 // Logic for /orders/{id} 48 function handlePublicOrdersGet(mos: MerchantOrderStore, req: Req): Resp { 49 const ord = mos[req.orderId]; 50 if (!ord) { 51 return respNotFound(req); 52 } 53 54 const authMissing = !!req.contractHash && !!req.claimToken; 55 // For this endpoint, when the order does not have a claim token, 56 // the order status can be accessed *without* h_contract. 57 const authOk = 58 ord.contractHash === req.contractHash || 59 (ord.requireClaimToken && ord.claimToken === req.claimToken) || 60 !ord.requireClaimToken; 61 62 if (authMissing && ord.requireClaimToken) { 63 // Client is trying to get the order status of an 64 // order. However, the client is not showing authentication. 65 // 66 // This can happen when the fulfillment URL includes the order ID, 67 // and the storefront redirects the user to the backend QR code 68 // page, because the order is not paid under the current session. 69 // This happens on bookmarking / link sharing. 70 if (!ord.publicReorderUrl) { 71 return respForbidden(req); 72 } 73 return respGoto(req, ord.publicReorderUrl); 74 } 75 76 // Even if an order is paid for, 77 // we still need the ord.claimToken, because 78 // the QR code page will poll until it gets a 79 // fulfillment URL, but we decided that the 80 // fulfillment URL should only be returned 81 // when the client is authenticated. 82 // (Otherwise, guessing the order ID might leak the 83 // fulfillment URL). 84 if (!authOk) { 85 return respForbidden(req); 86 } 87 88 if (!!req.sessionId && req.sessionId !== ord.lastPaidSessionId) { 89 if (!!ord.fulfillmentUrl) { 90 const alreadyPaidOrd = findAlreadyPaid( 91 mos, 92 req.sessionId, 93 ord.fulfillmentUrl 94 ); 95 if (!!alreadyPaidOrd) { 96 return respAlreadyPaid(req, alreadyPaidOrd); 97 } 98 } 99 } 100 101 if (ord.paid) { 102 return respPaid(req, ord); 103 } 104 105 return respUnpaid(req, ord); 106 } 107 108 function respNotFound(req: Req): Resp { 109 return { 110 contentType: req.accept, 111 httpStatus: "404 Not Found", 112 responseType: "TalerError", 113 }; 114 } 115 116 function respForbidden(req: Req): Resp { 117 return { 118 contentType: req.accept, 119 httpStatus: "403 Forbidden", 120 responseType: "TalerError", 121 }; 122 } 123 124 function respAlreadyPaid(req: Req, alreadyPaidOrd: MerchantOrderInfo): Resp { 125 // This could be called with an empty fulfillment URL, but that doesn't 126 // really make sense for the client's perspective. 127 if (req.accept === "html") { 128 return { 129 httpStatus: "302 Found", 130 contentType: "html", 131 redirectLocation: alreadyPaidOrd.fulfillmentUrl, 132 responseType: "empty", 133 }; 134 } 135 return { 136 httpStatus: "402 PaymentRequired", 137 contentType: "json", 138 responseType: "StatusUnpaidResponse", 139 response: { 140 fulfillment_url: alreadyPaidOrd.fulfillmentUrl, 141 already_paid_order_id: alreadyPaidOrd.orderId, 142 }, 143 }; 144 } 145 146 function respGoto(req: Req, publicReorderUrl: string): Resp { 147 if (req.accept === "html") { 148 return { 149 httpStatus: "302 Found", 150 contentType: "html", 151 redirectLocation: publicReorderUrl, 152 responseType: "empty", 153 }; 154 } 155 return { 156 httpStatus: "202 Accepted", 157 contentType: "json", 158 responseType: "StatusGotoResponse", 159 response: { 160 public_reorder_url: publicReorderUrl, 161 }, 162 }; 163 } 164 165 function respUnpaid(req: Req, ord: MerchantOrderInfo): Resp { 166 if (req.accept === "html") { 167 return { 168 httpStatus: "402 Payment Required", 169 contentType: "html", 170 responseType: "StatusUnpaidResponse", 171 // This must include the claim token. The same taler:// 172 // URI should also be shown as the QR code. 173 talerHeader: "taler://pay/...", 174 }; 175 } 176 return { 177 httpStatus: "402 Payment Required", 178 contentType: "json", 179 responseType: "StatusUnpaidResponse", 180 response: { 181 // Required for repurchase detection 182 fulfillmentUrl: ord.fulfillmentUrl, 183 }, 184 }; 185 } 186 187 function respPaid(req: Req, ord: MerchantOrderInfo): Resp { 188 if (req.accept === "html") { 189 if (ord.refundPending) { 190 return { 191 httpStatus: "402 Payment Required", 192 contentType: "html", 193 responseType: "QRCodeRefundPage", 194 talerHeader: "taler://refund/...", 195 }; 196 } 197 // We do not redirect here. Only 198 // the JS on the QR code page automatically redirects. 199 // Without JS, the user has to manually click through to 200 // the fulfillment URL. 201 return { 202 httpStatus: "200 OK", 203 contentType: "html", 204 responseType: "OrderStatusHtmlPage", 205 }; 206 } 207 return { 208 httpStatus: "200 OK", 209 contentType: "json", 210 responseType: "StatusPaidResponse", 211 response: { 212 fulfillmentUrl: ord.fulfillmentUrl, 213 }, 214 }; 215 } 216 217 // Helper to find an already paid order ID. 218 function findAlreadyPaid( 219 mos: MerchantOrderStore, 220 sessionId: string, 221 fulfillmentUrl: string 222 ): MerchantOrderInfo | undefined { 223 for (const orderId of Object.keys(mos)) { 224 if ( 225 mos[orderId].lastPaidSessionId === sessionId && 226 mos[orderId].fulfillmentUrl === fulfillmentUrl 227 ) { 228 return mos[orderId]; 229 } 230 } 231 return undefined; 232 }