LCOV - code coverage report
Current view: top level - backend - taler-merchant-httpd_post-private-orders-ORDER_ID-refund-external.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 65.9 % 179 118
Test Date: 2026-09-04 23:42:01 Functions: 100.0 % 3 3

            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 */
        

Generated by: LCOV version 2.0-1