Line data Source code
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 10 : sum_refund (struct RefundSum *sum,
70 : const struct TALER_Amount *amount)
71 : {
72 10 : if (GNUNET_OK !=
73 10 : TALER_amount_is_valid (&sum->total))
74 : {
75 6 : sum->total = *amount;
76 6 : return;
77 : }
78 4 : if (0 >
79 4 : TALER_amount_add (&sum->total,
80 4 : &sum->total,
81 : amount))
82 0 : 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 10 : taler_refund_cb (void *cls,
95 : const struct TALER_CoinSpendPublicKeyP *coin_pub,
96 : const struct TALER_Amount *refund_amount)
97 : {
98 10 : struct RefundSum *sum = cls;
99 :
100 : (void) coin_pub;
101 10 : sum_refund (sum,
102 : refund_amount);
103 10 : }
104 :
105 :
106 : enum MHD_Result
107 24 : 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 24 : 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 24 : GNUNET_JSON_spec_string ("method",
120 : &method),
121 24 : GNUNET_JSON_spec_string ("id",
122 : &refund_id),
123 24 : GNUNET_JSON_spec_mark_optional (
124 : GNUNET_JSON_spec_string ("payment_id",
125 : &payment_id),
126 : NULL),
127 24 : TALER_JSON_spec_amount_any ("amount",
128 : &amount),
129 24 : GNUNET_JSON_spec_string ("reason",
130 : &reason),
131 24 : GNUNET_JSON_spec_end ()
132 : };
133 :
134 : (void) rh;
135 : {
136 : enum GNUNET_GenericReturnValue res;
137 :
138 24 : res = TALER_MHD_parse_json_data (connection,
139 24 : hc->request_body,
140 : spec);
141 24 : if (GNUNET_OK != res)
142 : {
143 : return (GNUNET_NO == res)
144 : ? MHD_YES
145 0 : : MHD_NO;
146 : }
147 : }
148 24 : if (! TALER_MERCHANT_payment_method_valid (method))
149 2 : return TALER_MHD_reply_with_error (connection,
150 : MHD_HTTP_BAD_REQUEST,
151 : TALER_EC_GENERIC_PARAMETER_MALFORMED,
152 : "method");
153 22 : if ('\0' == refund_id[0])
154 : {
155 0 : GNUNET_break_op (0);
156 0 : 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 22 : if (NULL == (req_body_enc
167 22 : = json_dumps (hc->request_body,
168 : JSON_ENCODE_ANY
169 : | JSON_COMPACT
170 : | JSON_SORT_KEYS)))
171 : {
172 0 : GNUNET_break (0);
173 0 : 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 22 : GNUNET_CRYPTO_hash (req_body_enc,
180 : strlen (req_body_enc),
181 : &h_post_data.hash);
182 22 : GNUNET_free (req_body_enc);
183 : }
184 :
185 22 : for (unsigned int i = 0; i<MAX_RETRIES; i++)
186 : {
187 22 : json_t *contract_terms = NULL;
188 : uint64_t order_serial;
189 : int16_t choice_index;
190 22 : bool paid = false;
191 : struct TALER_Amount total;
192 : struct TALER_Amount remaining;
193 22 : struct RefundSum taler_sum = { 0 };
194 22 : struct TALER_Amount external_total = { 0 };
195 22 : bool external_mismatch = false;
196 : enum GNUNET_DB_QueryStatus qs;
197 :
198 22 : TALER_MERCHANTDB_preflight (TMH_db);
199 22 : if (GNUNET_OK !=
200 22 : TALER_MERCHANTDB_start (TMH_db,
201 : "record external refund"))
202 : {
203 0 : GNUNET_break (0);
204 22 : 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 22 : qs = TALER_MERCHANTDB_get_contract_terms_status (TMH_db,
214 22 : hc->instance->settings.id,
215 22 : hc->infix,
216 : NULL,
217 : &contract_terms,
218 : &order_serial,
219 : &paid,
220 : &wired,
221 : &session_matches,
222 : NULL,
223 : &choice_index);
224 : }
225 22 : switch (qs)
226 : {
227 0 : case GNUNET_DB_STATUS_SOFT_ERROR:
228 0 : TALER_MERCHANTDB_rollback (TMH_db);
229 0 : continue;
230 0 : case GNUNET_DB_STATUS_HARD_ERROR:
231 0 : TALER_MERCHANTDB_rollback (TMH_db);
232 0 : 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 2 : case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
237 2 : TALER_MERCHANTDB_rollback (TMH_db);
238 2 : return TALER_MHD_reply_with_error (connection,
239 : MHD_HTTP_NOT_FOUND,
240 : TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN,
241 2 : hc->infix);
242 20 : case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
243 20 : break;
244 : }
245 20 : 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 2 : TALER_MERCHANTDB_rollback (TMH_db);
250 2 : json_decref (contract_terms);
251 2 : return TALER_MHD_reply_with_error (
252 : connection,
253 : MHD_HTTP_CONFLICT,
254 : TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_ORDER_UNPAID,
255 2 : 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 18 : qs = TALER_MERCHANTDB_get_external_refund (TMH_db,
264 18 : hc->instance->settings.id,
265 18 : hc->infix,
266 : refund_id,
267 : &orig_post);
268 18 : switch (qs)
269 0 : {
270 0 : case GNUNET_DB_STATUS_SOFT_ERROR:
271 0 : TALER_MERCHANTDB_rollback (TMH_db);
272 0 : json_decref (contract_terms);
273 0 : continue;
274 0 : case GNUNET_DB_STATUS_HARD_ERROR:
275 0 : TALER_MERCHANTDB_rollback (TMH_db);
276 0 : json_decref (contract_terms);
277 4 : 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 4 : case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
282 4 : TALER_MERCHANTDB_rollback (TMH_db);
283 4 : json_decref (contract_terms);
284 4 : if (0 !=
285 4 : GNUNET_memcmp (&orig_post,
286 : &h_post_data))
287 : {
288 2 : GNUNET_break_op (0);
289 2 : 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 2 : GNUNET_log (GNUNET_ERROR_TYPE_INFO,
296 : "External refund `%s' already recorded, idempotent\n",
297 : refund_id);
298 2 : return TALER_MHD_REPLY_JSON_PACK (
299 : connection,
300 : MHD_HTTP_OK,
301 : GNUNET_JSON_pack_string ("refund_id",
302 : refund_id));
303 14 : case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
304 : /* No refund under this ID yet, record it below. */
305 14 : break;
306 : }
307 : }
308 14 : if (GNUNET_OK !=
309 14 : TMH_compute_order_total (contract_terms,
310 : choice_index,
311 : &total))
312 : {
313 0 : TALER_MERCHANTDB_rollback (TMH_db);
314 0 : json_decref (contract_terms);
315 0 : 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 14 : if (GNUNET_OK !=
322 14 : TALER_amount_cmp_currency (&amount,
323 : &total))
324 : {
325 2 : TALER_MERCHANTDB_rollback (TMH_db);
326 2 : json_decref (contract_terms);
327 2 : 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 12 : if (GNUNET_OK !=
337 12 : TALER_JSON_contract_hash (contract_terms,
338 : &h_contract_terms))
339 : {
340 0 : GNUNET_break (0);
341 0 : TALER_MERCHANTDB_rollback (TMH_db);
342 0 : json_decref (contract_terms);
343 0 : 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 12 : qs = TALER_MERCHANTDB_iterate_refunds (TMH_db,
350 12 : hc->instance->settings.id,
351 : &h_contract_terms,
352 : &taler_refund_cb,
353 : &taler_sum);
354 : }
355 12 : if (0 > qs)
356 : {
357 0 : TALER_MERCHANTDB_rollback (TMH_db);
358 0 : json_decref (contract_terms);
359 0 : if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
360 0 : continue;
361 0 : 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 12 : qs = TALER_MERCHANTDB_get_external_refunds_total (
367 : TMH_db,
368 12 : hc->instance->settings.id,
369 12 : hc->infix,
370 : &external_total,
371 : &external_mismatch);
372 12 : if (0 > qs)
373 : {
374 0 : TALER_MERCHANTDB_rollback (TMH_db);
375 0 : json_decref (contract_terms);
376 0 : if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
377 0 : continue;
378 0 : 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 12 : json_decref (contract_terms);
384 12 : if (taler_sum.currency_mismatch ||
385 : external_mismatch)
386 : {
387 0 : GNUNET_break (0);
388 0 : TALER_MERCHANTDB_rollback (TMH_db);
389 0 : 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 12 : remaining = total;
399 12 : if (TALER_amount_is_valid (&taler_sum.total))
400 : {
401 6 : if (0 >
402 6 : TALER_amount_subtract (&remaining,
403 : &remaining,
404 : &taler_sum.total))
405 : {
406 0 : GNUNET_break (0);
407 0 : TALER_MERCHANTDB_rollback (TMH_db);
408 0 : 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 12 : if (TALER_amount_is_valid (&external_total))
416 : {
417 6 : if (0 >
418 6 : TALER_amount_subtract (&remaining,
419 : &remaining,
420 : &external_total))
421 : {
422 0 : GNUNET_break (0);
423 0 : TALER_MERCHANTDB_rollback (TMH_db);
424 0 : 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 12 : if (1 ==
432 12 : TALER_amount_cmp (&amount,
433 : &remaining))
434 : {
435 4 : TALER_MERCHANTDB_rollback (TMH_db);
436 4 : 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 4 : 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 8 : qs = TALER_MERCHANTDB_insert_external_refund (TMH_db,
447 8 : hc->instance->settings.id,
448 8 : hc->infix,
449 : refund_id,
450 : &h_post_data,
451 : method,
452 : payment_id,
453 : &amount,
454 : reason);
455 8 : switch (qs)
456 : {
457 0 : case GNUNET_DB_STATUS_SOFT_ERROR:
458 0 : TALER_MERCHANTDB_rollback (TMH_db);
459 0 : continue;
460 0 : case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS:
461 0 : TALER_MERCHANTDB_rollback (TMH_db);
462 0 : continue;
463 0 : case GNUNET_DB_STATUS_HARD_ERROR:
464 0 : TALER_MERCHANTDB_rollback (TMH_db);
465 0 : GNUNET_break (0);
466 0 : 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 8 : case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT:
471 8 : break;
472 : }
473 8 : qs = TALER_MERCHANTDB_commit (TMH_db);
474 8 : if (0 > qs)
475 : {
476 0 : if (GNUNET_DB_STATUS_SOFT_ERROR == qs)
477 0 : continue;
478 0 : 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 8 : struct TMH_OrderPayEventP pay_eh = {
485 8 : .header.size = htons (sizeof (pay_eh)),
486 8 : .header.type = htons (TALER_DBEVENT_MERCHANT_ORDER_STATUS_CHANGED),
487 8 : .merchant_pub = hc->instance->merchant_pub
488 : };
489 :
490 8 : GNUNET_log (GNUNET_ERROR_TYPE_INFO,
491 : "Notifying clients about status change of order %s\n",
492 : hc->infix);
493 8 : GNUNET_CRYPTO_hash (hc->infix,
494 8 : strlen (hc->infix),
495 : &pay_eh.h_order_id);
496 8 : TALER_MERCHANTDB_event_notify (TMH_db,
497 : &pay_eh.header,
498 : NULL,
499 : 0);
500 : }
501 8 : 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 0 : 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 */
|