taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c (18220B)
1 /* 2 This file is part of TALER 3 (C) 2026 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify it under the 6 terms of the GNU Affero General Public License as published by the Free Software 7 Foundation; either version 3, or (at your option) any later version. 8 9 TALER is distributed in the hope that it will be useful, but WITHOUT ANY 10 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 13 You should have received a copy of the GNU General Public License along with 14 TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 /** 17 * @file src/backend/taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c 18 * @brief Handle request to record an external refund for an order 19 * @author Bohdan Potuzhnyi 20 * @author Volodymyr Potuzhnyi 21 */ 22 #include "platform.h" 23 #include <jansson.h> 24 #include <taler/taler_dbevents.h> 25 #include <taler/taler_json_lib.h> 26 #include "taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.h" 27 #include "taler-merchant-httpd_get-private-orders.h" 28 #include "taler-merchant-httpd_helper.h" 29 #include "merchant-database/insert_external_refund.h" 30 #include "merchant-database/get_contract_terms_status.h" 31 #include "merchant-database/get_external_refund.h" 32 #include "merchant-database/iterate_refunds.h" 33 #include "merchant-database/event_notify.h" 34 #include "merchant-database/preflight.h" 35 #include "merchant-database/start.h" 36 37 38 /** 39 * How often do we retry the database transaction? 40 */ 41 #define MAX_RETRIES 3 42 43 44 /** 45 * Closure for summing up refund amounts. 46 */ 47 struct RefundSum 48 { 49 /** 50 * Total refunded so far. Invalid if no refunds were seen yet. 51 */ 52 struct TALER_Amount total; 53 54 /** 55 * Set to true if amounts of different currencies were seen. 56 */ 57 bool currency_mismatch; 58 }; 59 60 61 /** 62 * Add @a amount to the given @a sum. 63 * 64 * @param[in,out] sum sum to increment 65 * @param amount amount to add 66 */ 67 static void 68 sum_refund (struct RefundSum *sum, 69 const struct TALER_Amount *amount) 70 { 71 if (GNUNET_OK != 72 TALER_amount_is_valid (&sum->total)) 73 { 74 sum->total = *amount; 75 return; 76 } 77 if (0 > 78 TALER_amount_add (&sum->total, 79 &sum->total, 80 amount)) 81 sum->currency_mismatch = true; 82 } 83 84 85 /** 86 * Function called with information about a Taler refund. 87 * 88 * @param cls a `struct RefundSum *` 89 * @param coin_pub public coin from which the refund comes from 90 * @param refund_amount refund amount which is being taken from @a coin_pub 91 */ 92 static void 93 taler_refund_cb (void *cls, 94 const struct TALER_CoinSpendPublicKeyP *coin_pub, 95 const struct TALER_Amount *refund_amount) 96 { 97 struct RefundSum *sum = cls; 98 99 (void) coin_pub; 100 sum_refund (sum, 101 refund_amount); 102 } 103 104 105 enum MHD_Result 106 TMH_private_post_orders_ID_refund_external ( 107 const struct TMH_RequestHandler *rh, 108 struct MHD_Connection *connection, 109 struct TMH_HandlerContext *hc) 110 { 111 const char *method; 112 const char *refund_id; 113 const char *payment_id = NULL; 114 struct TALER_Amount amount; 115 const char *reason; 116 struct TALER_MerchantPostDataHashP h_post_data; 117 struct GNUNET_JSON_Specification spec[] = { 118 GNUNET_JSON_spec_string ("method", 119 &method), 120 GNUNET_JSON_spec_string ("id", 121 &refund_id), 122 GNUNET_JSON_spec_mark_optional ( 123 GNUNET_JSON_spec_string ("payment_id", 124 &payment_id), 125 NULL), 126 TALER_JSON_spec_amount_any ("amount", 127 &amount), 128 GNUNET_JSON_spec_string ("reason", 129 &reason), 130 GNUNET_JSON_spec_end () 131 }; 132 133 (void) rh; 134 { 135 enum GNUNET_GenericReturnValue res; 136 137 res = TALER_MHD_parse_json_data (connection, 138 hc->request_body, 139 spec); 140 if (GNUNET_OK != res) 141 { 142 return (GNUNET_NO == res) 143 ? MHD_YES 144 : MHD_NO; 145 } 146 } 147 if (! TALER_MERCHANT_payment_method_valid (method)) 148 return TALER_MHD_reply_with_error (connection, 149 MHD_HTTP_BAD_REQUEST, 150 TALER_EC_GENERIC_PARAMETER_MALFORMED, 151 "method"); 152 if ('\0' == refund_id[0]) 153 { 154 GNUNET_break_op (0); 155 return TALER_MHD_reply_with_error (connection, 156 MHD_HTTP_BAD_REQUEST, 157 TALER_EC_GENERIC_PARAMETER_MALFORMED, 158 "id"); 159 } 160 /* Compute h_post_data (for idempotency check) */ 161 { 162 char *req_body_enc; 163 164 /* Dump normalized JSON to string. */ 165 if (NULL == (req_body_enc 166 = json_dumps (hc->request_body, 167 JSON_ENCODE_ANY 168 | JSON_COMPACT 169 | JSON_SORT_KEYS))) 170 { 171 GNUNET_break (0); 172 return TALER_MHD_reply_with_error ( 173 connection, 174 MHD_HTTP_INTERNAL_SERVER_ERROR, 175 TALER_EC_GENERIC_ALLOCATION_FAILURE, 176 "request body normalization for hashing"); 177 } 178 GNUNET_CRYPTO_hash (req_body_enc, 179 strlen (req_body_enc), 180 &h_post_data.hash); 181 GNUNET_free (req_body_enc); 182 } 183 184 for (unsigned int i = 0; i<MAX_RETRIES; i++) 185 { 186 json_t *contract_terms = NULL; 187 uint64_t order_serial; 188 int16_t choice_index; 189 bool paid = false; 190 struct TALER_Amount total; 191 struct TALER_Amount remaining; 192 struct RefundSum taler_sum = { 0 }; 193 struct TALER_Amount external_total = { 0 }; 194 bool external_mismatch = false; 195 enum GNUNET_DB_QueryStatus qs; 196 197 TALER_MERCHANTDB_preflight (TMH_db); 198 if (GNUNET_OK != 199 TALER_MERCHANTDB_start (TMH_db, 200 "record external refund")) 201 { 202 GNUNET_break (0); 203 return TALER_MHD_reply_with_error (connection, 204 MHD_HTTP_INTERNAL_SERVER_ERROR, 205 TALER_EC_GENERIC_DB_START_FAILED, 206 NULL); 207 } 208 { 209 bool wired; 210 bool session_matches; 211 212 qs = TALER_MERCHANTDB_get_contract_terms_status (TMH_db, 213 hc->instance->settings.id, 214 hc->infix, 215 NULL, 216 &contract_terms, 217 &order_serial, 218 &paid, 219 &wired, 220 &session_matches, 221 NULL, 222 &choice_index); 223 } 224 switch (qs) 225 { 226 case GNUNET_DB_STATUS_SOFT_ERROR: 227 TALER_MERCHANTDB_rollback (TMH_db); 228 continue; 229 case GNUNET_DB_STATUS_HARD_ERROR: 230 TALER_MERCHANTDB_rollback (TMH_db); 231 return TALER_MHD_reply_with_error (connection, 232 MHD_HTTP_INTERNAL_SERVER_ERROR, 233 TALER_EC_GENERIC_DB_FETCH_FAILED, 234 "get_contract_terms_status"); 235 case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: 236 TALER_MERCHANTDB_rollback (TMH_db); 237 return TALER_MHD_reply_with_error (connection, 238 MHD_HTTP_NOT_FOUND, 239 TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN, 240 hc->infix); 241 case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: 242 break; 243 } 244 if (! paid) 245 { 246 /* Unpaid orders have no settled payments to reverse; the 247 POS should delete the order and create a new one instead. */ 248 TALER_MERCHANTDB_rollback (TMH_db); 249 json_decref (contract_terms); 250 return TALER_MHD_reply_with_error ( 251 connection, 252 MHD_HTTP_CONFLICT, 253 TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_ORDER_UNPAID, 254 hc->infix); 255 } 256 /* Check for an existing refund under the same ID before looking at 257 the refund limits: a replay must not be rejected for exceeding 258 the limit by the very entry it is replaying. */ 259 { 260 struct TALER_MerchantPostDataHashP orig_post; 261 262 qs = TALER_MERCHANTDB_get_external_refund (TMH_db, 263 hc->instance->settings.id, 264 hc->infix, 265 refund_id, 266 &orig_post); 267 switch (qs) 268 { 269 case GNUNET_DB_STATUS_SOFT_ERROR: 270 TALER_MERCHANTDB_rollback (TMH_db); 271 json_decref (contract_terms); 272 continue; 273 case GNUNET_DB_STATUS_HARD_ERROR: 274 TALER_MERCHANTDB_rollback (TMH_db); 275 json_decref (contract_terms); 276 return TALER_MHD_reply_with_error (connection, 277 MHD_HTTP_INTERNAL_SERVER_ERROR, 278 TALER_EC_GENERIC_DB_FETCH_FAILED, 279 "get_external_refund"); 280 case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: 281 TALER_MERCHANTDB_rollback (TMH_db); 282 json_decref (contract_terms); 283 if (0 != 284 GNUNET_memcmp (&orig_post, 285 &h_post_data)) 286 { 287 GNUNET_break_op (0); 288 return TALER_MHD_reply_with_error ( 289 connection, 290 MHD_HTTP_CONFLICT, 291 TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_ALREADY_EXISTS, 292 refund_id); 293 } 294 GNUNET_log (GNUNET_ERROR_TYPE_INFO, 295 "External refund `%s' already recorded, idempotent\n", 296 refund_id); 297 return TALER_MHD_REPLY_JSON_PACK ( 298 connection, 299 MHD_HTTP_OK, 300 GNUNET_JSON_pack_string ("refund_id", 301 refund_id)); 302 case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: 303 /* No refund under this ID yet, record it below. */ 304 break; 305 } 306 } 307 if (GNUNET_OK != 308 TMH_compute_order_total (contract_terms, 309 choice_index, 310 &total)) 311 { 312 TALER_MERCHANTDB_rollback (TMH_db); 313 json_decref (contract_terms); 314 return TALER_MHD_reply_with_error ( 315 connection, 316 MHD_HTTP_INTERNAL_SERVER_ERROR, 317 TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID, 318 "amount"); 319 } 320 if (GNUNET_OK != 321 TALER_amount_cmp_currency (&amount, 322 &total)) 323 { 324 TALER_MERCHANTDB_rollback (TMH_db); 325 json_decref (contract_terms); 326 return TALER_MHD_reply_with_error ( 327 connection, 328 MHD_HTTP_CONFLICT, 329 TALER_EC_MERCHANT_GENERIC_CURRENCY_MISMATCH, 330 "refund currency does not match order currency"); 331 } 332 { 333 struct TALER_PrivateContractHashP h_contract_terms; 334 335 if (GNUNET_OK != 336 TALER_JSON_contract_hash (contract_terms, 337 &h_contract_terms)) 338 { 339 GNUNET_break (0); 340 TALER_MERCHANTDB_rollback (TMH_db); 341 json_decref (contract_terms); 342 return TALER_MHD_reply_with_error ( 343 connection, 344 MHD_HTTP_INTERNAL_SERVER_ERROR, 345 TALER_EC_GENERIC_FAILED_COMPUTE_JSON_HASH, 346 NULL); 347 } 348 qs = TALER_MERCHANTDB_iterate_refunds (TMH_db, 349 hc->instance->settings.id, 350 &h_contract_terms, 351 &taler_refund_cb, 352 &taler_sum); 353 } 354 if (0 > qs) 355 { 356 TALER_MERCHANTDB_rollback (TMH_db); 357 json_decref (contract_terms); 358 if (GNUNET_DB_STATUS_SOFT_ERROR == qs) 359 continue; 360 return TALER_MHD_reply_with_error (connection, 361 MHD_HTTP_INTERNAL_SERVER_ERROR, 362 TALER_EC_GENERIC_DB_FETCH_FAILED, 363 "lookup refunds"); 364 } 365 qs = TALER_MERCHANTDB_get_external_refunds_total ( 366 TMH_db, 367 hc->instance->settings.id, 368 hc->infix, 369 &external_total, 370 &external_mismatch); 371 if (0 > qs) 372 { 373 TALER_MERCHANTDB_rollback (TMH_db); 374 json_decref (contract_terms); 375 if (GNUNET_DB_STATUS_SOFT_ERROR == qs) 376 continue; 377 return TALER_MHD_reply_with_error (connection, 378 MHD_HTTP_INTERNAL_SERVER_ERROR, 379 TALER_EC_GENERIC_DB_FETCH_FAILED, 380 "select external refunds"); 381 } 382 json_decref (contract_terms); 383 if (taler_sum.currency_mismatch || 384 external_mismatch) 385 { 386 GNUNET_break (0); 387 TALER_MERCHANTDB_rollback (TMH_db); 388 return TALER_MHD_reply_with_error ( 389 connection, 390 MHD_HTTP_INTERNAL_SERVER_ERROR, 391 TALER_EC_GENERIC_DB_FETCH_FAILED, 392 "refund currency in database does not match order currency"); 393 } 394 /* The cumulative externally refunded amount must not exceed 395 the full order total minus the amount already refunded 396 through Taler. */ 397 remaining = total; 398 if (TALER_amount_is_valid (&taler_sum.total)) 399 { 400 if (0 > 401 TALER_amount_subtract (&remaining, 402 &remaining, 403 &taler_sum.total)) 404 { 405 GNUNET_break (0); 406 TALER_MERCHANTDB_rollback (TMH_db); 407 return TALER_MHD_reply_with_error ( 408 connection, 409 MHD_HTTP_CONFLICT, 410 TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT, 411 "Taler refunds exceed order total"); 412 } 413 } 414 if (TALER_amount_is_valid (&external_total)) 415 { 416 if (0 > 417 TALER_amount_subtract (&remaining, 418 &remaining, 419 &external_total)) 420 { 421 GNUNET_break (0); 422 TALER_MERCHANTDB_rollback (TMH_db); 423 return TALER_MHD_reply_with_error ( 424 connection, 425 MHD_HTTP_CONFLICT, 426 TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT, 427 "external refunds exceed remaining order total"); 428 } 429 } 430 if (1 == 431 TALER_amount_cmp (&amount, 432 &remaining)) 433 { 434 TALER_MERCHANTDB_rollback (TMH_db); 435 GNUNET_log (GNUNET_ERROR_TYPE_WARNING, 436 "Refusing external refund of %s that would exceed remaining refundable amount of %s\n", 437 TALER_amount2s (&amount), 438 TALER_amount2s (&remaining)); 439 return TALER_MHD_reply_with_error ( 440 connection, 441 MHD_HTTP_CONFLICT, 442 TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT, 443 "amount above remaining refundable order total"); 444 } 445 qs = TALER_MERCHANTDB_insert_external_refund (TMH_db, 446 hc->instance->settings.id, 447 hc->infix, 448 refund_id, 449 &h_post_data, 450 method, 451 payment_id, 452 &amount, 453 reason); 454 switch (qs) 455 { 456 case GNUNET_DB_STATUS_SOFT_ERROR: 457 TALER_MERCHANTDB_rollback (TMH_db); 458 continue; 459 case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: 460 TALER_MERCHANTDB_rollback (TMH_db); 461 continue; 462 case GNUNET_DB_STATUS_HARD_ERROR: 463 TALER_MERCHANTDB_rollback (TMH_db); 464 GNUNET_break (0); 465 return TALER_MHD_reply_with_error (connection, 466 MHD_HTTP_INTERNAL_SERVER_ERROR, 467 TALER_EC_GENERIC_DB_STORE_FAILED, 468 "insert external refund"); 469 case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: 470 break; 471 } 472 qs = TALER_MERCHANTDB_commit (TMH_db); 473 if (0 > qs) 474 { 475 if (GNUNET_DB_STATUS_SOFT_ERROR == qs) 476 continue; 477 return TALER_MHD_reply_with_error (connection, 478 MHD_HTTP_INTERNAL_SERVER_ERROR, 479 TALER_EC_GENERIC_DB_COMMIT_FAILED, 480 NULL); 481 } 482 { 483 struct TMH_OrderPayEventP pay_eh = { 484 .header.size = htons (sizeof (pay_eh)), 485 .header.type = htons (TALER_DBEVENT_MERCHANT_ORDER_STATUS_CHANGED), 486 .merchant_pub = hc->instance->merchant_pub 487 }; 488 489 GNUNET_log (GNUNET_ERROR_TYPE_INFO, 490 "Notifying clients about status change of order %s\n", 491 hc->infix); 492 GNUNET_CRYPTO_hash (hc->infix, 493 strlen (hc->infix), 494 &pay_eh.h_order_id); 495 TALER_MERCHANTDB_event_notify (TMH_db, 496 &pay_eh.header, 497 NULL, 498 0); 499 } 500 return TALER_MHD_REPLY_JSON_PACK ( 501 connection, 502 MHD_HTTP_OK, 503 GNUNET_JSON_pack_string ("refund_id", 504 refund_id)); 505 } /* retries loop */ 506 return TALER_MHD_reply_with_error (connection, 507 MHD_HTTP_INTERNAL_SERVER_ERROR, 508 TALER_EC_GENERIC_DB_SOFT_FAILURE, 509 NULL); 510 } 511 512 513 /* end of taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c */