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