merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

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