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