merchant

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

commit 87e2fd4694fdcd01749400700b747151a327543d
parent 698ea99f429c2397418549e1527d2c6a073b3bf2
Author: Christian Grothoff <christian@grothoff.org>
Date:   Sat, 21 Feb 2026 22:58:13 +0100

implement #11126

Diffstat:
Mcontrib/typst/orders.typ | 77+++++++++++++++++++++++++++++++++++++++++++----------------------------------
Msrc/backend/taler-merchant-httpd.c | 1+
Msrc/backend/taler-merchant-httpd_private-get-orders.c | 820++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Msrc/backend/taler-merchant-httpd_private-get-orders.h | 8++++++++
Msrc/backend/taler-merchant-httpd_private-patch-accounts-ID.c | 11++++++++++-
Msrc/backend/taler-merchant-httpd_private-post-account.c | 12+++++++++++-
Msrc/testing/test_merchant_transfer_tracking.sh | 50++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 770 insertions(+), 209 deletions(-)

diff --git a/contrib/typst/orders.typ b/contrib/typst/orders.typ @@ -59,6 +59,8 @@ } } +#let breakable_hyp(s) = s.replace("-", "-#zwsp") + // Format a Taler amount. // Taler serialises amounts as a plain string "CURRENCY:VALUE.FRACTION", // e.g. "EUR:5.50". If the value is null / none we render a dash. @@ -70,7 +72,7 @@ // Note: eventually we want it formatted *nicely*, but this // needs the currency rendering data, so probably better done // on the C side (where we don't yet have an amount renderer). - str(a) + str(a).replace(":", " ") } } @@ -95,47 +97,54 @@ v(0.5cm) table( - columns: (auto, auto, auto, auto, auto, auto, auto), - align: (left, left, left, right, right, center, center), + columns: (auto, auto, auto), + align: (left, right, right), // Header row table.header( [*Order ID*], - // [*Row*], - [*Summary*], [*Timestamp*], - [*Amount*], - [*Refund*], - // [*Pending refund*], - [*Refundable*], - [*Paid*], + [*Price* #text(fill: red)[$-$ *Refund*]], ), // Data rows ..data.orders.map(o => ( - o.order_id, - // str(o.row_id), - o.summary, - format_timestamp(o.timestamp), - format_amount(o.amount), - format_amount(o.at("refund_amount", default: none)), - // format_amount(o.at("pending_refund_amount", default: none)), - if o.refundable { "✓" } else { "✗" }, - if o.paid { "✓" } else { "✗" }, + table.cell()[#text(8pt, o.order_id)], + table.cell(stroke: (bottom: none))[#format_timestamp(o.timestamp)], + table.cell(stroke: (bottom: none))[#format_amount(o.amount)], + table.cell(colspan:2, x: 0, stroke: (top: none))[ + #grid( + columns: (1fr, auto), + o.summary, + if o.paid { + text(fill: green)[✓ (paid)] + } else { + text(fill: red)[✗ (unpaid)] + }, + ) + ], + table.cell(stroke: (top: none))[#{ + let r = o.at("refund_amount", default: none) + if (r != none) { + text(fill: red)[$-$ #format_amount(r)] + } else { + "-" + } + }], )).flatten(), - // Bold totals row spanning the label across the first 4 columns, - // then the three amount totals, then two empty trailing cells. + // Footer row table.footer( - table.cell(colspan: 3)[*Totals*], - table.cell(align: right)[ + table.cell(colspan: 2, stroke: (bottom: none))[*Total (paid only)*], + table.cell(stroke: (bottom: none))[ *#format_amount(data.total_amount)* ], - table.cell(align: right)[ - *#format_amount(data.total_refund_amount)* - ], - // table.cell(align: right)[ - // *#format_amount(data.total_pending_refund_amount)* - // ], - [], - [], + table.cell(colspan: 2, x: 0, stroke: (top: none))[*Total (refunds)*], + table.cell(stroke: (top: none))[#{ + let r = data.total_refund_amount + if r != none { + text(fill: red)[$-$ *#format_amount(data.total_refund_amount)*] + } else { + ["-"] + } + }], ), ) } @@ -145,7 +154,7 @@ business_name: "example.com", orders: ( ( - order_id: "2025.001", + order_id: "2025.001-5asdasfa", row_id: 1, summary: "Some purchase", timestamp: (t_s: 1764967786), @@ -154,7 +163,7 @@ refundable: false, ), ( - order_id: "2025.002", + order_id: "2025.002-5asdasfa", row_id: 2, summary: "Refunded order", timestamp: (t_s: 1764970000), @@ -165,7 +174,7 @@ refundable: true, ), ( - order_id: "2025.003", + order_id: "2025.003-5asdasfa", row_id: 3, summary: "Another order", timestamp: (t_s: 1764975000), diff --git a/src/backend/taler-merchant-httpd.c b/src/backend/taler-merchant-httpd.c @@ -217,6 +217,7 @@ do_shutdown (void *cls) TMH_handler_statistic_report_transactions_cleanup (); TMH_force_orders_resume (); TMH_force_get_sessions_ID_resume (); + TMH_force_get_orders_resume_typst (); TMH_force_ac_resume (); TMH_force_pc_resume (); TMH_force_kyc_resume (); diff --git a/src/backend/taler-merchant-httpd_private-get-orders.c b/src/backend/taler-merchant-httpd_private-get-orders.c @@ -32,6 +32,34 @@ */ #define MAX_DELTA 1024 +#define CSV_HEADER \ + "Order ID,Row,Timestamp,Amount,Refund amount,Pending refund amount,Summary,Refundable,Paid\r\n" +#define CSV_FOOTER "\r\n" + +#define XML_HEADER "<?xml version=\"1.0\"?>" \ + "<Workbook xmlns=\"urn:schemas-microsoft-com:office:spreadsheet\"" \ + " xmlns:c=\"urn:schemas-microsoft-com:office:component:spreadsheet\"" \ + " xmlns:html=\"http://www.w3.org/TR/REC-html40\"" \ + " xmlns:x2=\"http://schemas.microsoft.com/office/excel/2003/xml\"" \ + " xmlns:o=\"urn:schemas-microsoft-com:office:office\"" \ + " xmlns:x=\"urn:schemas-microsoft-com:office:excel\"" \ + " xmlns:ss=\"urn:schemas-microsoft-com:office:spreadsheet\">" \ + "<Styles>" \ + "<Style ss:ID=\"DateFormat\"><NumberFormat ss:Format=\"yyyy-mm-dd hh:mm:ss\"/></Style>" \ + "<Style ss:ID=\"Total\"><Font ss:Bold=\"1\"/></Style>" \ + "</Styles>\n" \ + "<Worksheet ss:Name=\"Orders\">\n" \ + "<Table>\n" \ + "<Row>\n" \ + "<Cell ss:StyleID=\"Header\"><Data ss:Type=\"String\">Order ID</Data></Cell>\n" \ + "<Cell ss:StyleID=\"Header\"><Data ss:Type=\"String\">Timestamp</Data></Cell>\n" \ + "<Cell ss:StyleID=\"Header\"><Data ss:Type=\"String\">Price</Data></Cell>\n" \ + "<Cell ss:StyleID=\"Header\"><Data ss:Type=\"String\">Refunded</Data></Cell>\n" \ + "<Cell ss:StyleID=\"Header\"><Data ss:Type=\"String\">Summary</Data></Cell>\n" \ + "<Cell ss:StyleID=\"Header\"><Data ss:Type=\"String\">Paid</Data></Cell>\n" \ + "</Row>\n" +#define XML_FOOTER "</Table></Worksheet></Workbook>" + /** * A pending GET /orders request. @@ -55,11 +83,6 @@ struct TMH_PendingOrder struct MHD_Connection *con; /** - * Associated heap node. - */ - struct GNUNET_CONTAINER_HeapNode *hn; - - /** * Which instance is this client polling? This also defines * which DLL this struct is part of. */ @@ -77,11 +100,40 @@ struct TMH_PendingOrder struct TALER_MERCHANTDB_OrderFilter of; /** - * The array of orders. + * The array of orders (used for JSON and PDF/Typst). */ json_t *pa; /** + * Running total of order amounts, for totals row in CSV/XML/PDF. + * Initialised to zero on first order seen. + */ + struct TALER_Amount total_amount; + + /** + * Running total of granted refund amounts. + * Initialised to zero on first paid order seen. + */ + struct TALER_Amount total_refund_amount; + + /** + * Running total of pending refund amounts. + * Initialised to zero on first paid order seen. + */ + struct TALER_Amount total_pending_refund_amount; + + /** + * True once @e total_amount has been initialised with a currency. + */ + bool total_amount_initialized; + + /** + * True once @e total_refund_amount / @e total_pending_refund_amount + * have been initialised with a currency. + */ + bool total_refund_initialized; + + /** * The name of the instance we are querying for. */ const char *instance_id; @@ -100,30 +152,61 @@ struct TMH_PendingOrder * Is the structure in the DLL */ bool in_dll; -}; + /** + * Output format requested by the client. + */ + enum + { + POF_JSON, + POF_CSV, + POF_XML, + POF_PDF + } format; + + /** + * Buffer used when format is #POF_CSV. + */ + struct GNUNET_Buffer csv; + + /** + * Buffer used when format is #POF_XML. + */ + struct GNUNET_Buffer xml; + + /** + * Async context used to run Typst (for #POF_PDF). + */ + struct TALER_MHD_TypstContext *tc; + + /** + * Pre-built MHD response (used when #POF_PDF Typst is done). + */ + struct MHD_Response *response; + + /** + * Task to timeout pending order. + */ + struct GNUNET_SCHEDULER_Task *order_timeout_task; + + /** + * HTTP status to return with @e response. + */ + unsigned int http_status; +}; -// FIXME-cleanup: should probably replace the heap with -// a DLL and keep a per-entry task as we do elsewhere. -// The heap does not help, the scheduler should have one already. /** - * Task to timeout pending orders. + * DLL head for requests suspended waiting for Typst. */ -static struct GNUNET_SCHEDULER_Task *order_timeout_task; +static struct TMH_PendingOrder *pdf_head; /** - * Heap for orders in long polling awaiting timeout. + * DLL tail for requests suspended waiting for Typst. */ -static struct GNUNET_CONTAINER_Heap *order_timeout_heap; +static struct TMH_PendingOrder *pdf_tail; -/** - * We are shutting down (or an instance is being deleted), force resume of all - * GET /orders requests. - * - * @param mi instance to force resuming for - */ void TMH_force_get_orders_resume (struct TMH_MerchantInstance *mi) { @@ -135,8 +218,6 @@ TMH_force_get_orders_resume (struct TMH_MerchantInstance *mi) GNUNET_CONTAINER_DLL_remove (mi->po_head, mi->po_tail, po); - GNUNET_assert (po == - GNUNET_CONTAINER_heap_remove_root (order_timeout_heap)); MHD_resume_connection (po->con); po->in_dll = false; } @@ -145,15 +226,20 @@ TMH_force_get_orders_resume (struct TMH_MerchantInstance *mi) TMH_db->event_listen_cancel (mi->po_eh); mi->po_eh = NULL; } - if (NULL != order_timeout_task) - { - GNUNET_SCHEDULER_cancel (order_timeout_task); - order_timeout_task = NULL; - } - if (NULL != order_timeout_heap) +} + + +void +TMH_force_get_orders_resume_typst () +{ + struct TMH_PendingOrder *po; + + while (NULL != (po = pdf_head)) { - GNUNET_CONTAINER_heap_destroy (order_timeout_heap); - order_timeout_heap = NULL; + GNUNET_CONTAINER_DLL_remove (pdf_head, + pdf_tail, + po); + MHD_resume_connection (po->con); } } @@ -161,59 +247,32 @@ TMH_force_get_orders_resume (struct TMH_MerchantInstance *mi) /** * Task run to trigger timeouts on GET /orders requests with long polling. * - * @param cls unused + * @param cls a `struct TMH_PendingOrder *` */ static void order_timeout (void *cls) { - struct TMH_PendingOrder *po; - struct TMH_MerchantInstance *mi; + struct TMH_PendingOrder *po = cls; + struct TMH_MerchantInstance *mi = po->mi; - (void) cls; - order_timeout_task = NULL; - while (1) - { - po = GNUNET_CONTAINER_heap_peek (order_timeout_heap); - if (NULL == po) - { - /* release data structure, we don't need it right now */ - GNUNET_CONTAINER_heap_destroy (order_timeout_heap); - order_timeout_heap = NULL; - return; - } - if (GNUNET_TIME_absolute_is_future (po->long_poll_timeout)) - break; - GNUNET_assert (po == - GNUNET_CONTAINER_heap_remove_root (order_timeout_heap)); - po->hn = NULL; - GNUNET_log (GNUNET_ERROR_TYPE_INFO, - "Resuming long polled job due to timeout\n"); - mi = po->mi; - GNUNET_assert (po->in_dll); - GNUNET_CONTAINER_DLL_remove (mi->po_head, - mi->po_tail, - po); - if ( (NULL == mi->po_head) && - (NULL != mi->po_eh) ) - { - TMH_db->event_listen_cancel (mi->po_eh); - mi->po_eh = NULL; - } - po->in_dll = false; - MHD_resume_connection (po->con); - TALER_MHD_daemon_trigger (); /* we resumed, kick MHD */ - } - order_timeout_task = GNUNET_SCHEDULER_add_at (po->long_poll_timeout, - &order_timeout, - NULL); + po->order_timeout_task = NULL; + GNUNET_log (GNUNET_ERROR_TYPE_INFO, + "Resuming long polled job due to timeout\n"); + GNUNET_assert (po->in_dll); + GNUNET_CONTAINER_DLL_remove (mi->po_head, + mi->po_tail, + po); + po->in_dll = false; + MHD_resume_connection (po->con); + TALER_MHD_daemon_trigger (); /* we resumed, kick MHD */ } /** - * Cleanup our "context", where we stored the JSON array + * Cleanup our "context", where we stored the data * we are building for the response. * - * @param ctx context to clean up, must be a `struct AddOrderState *` + * @param ctx context to clean up, must be a `struct TMH_PendingOrder *` */ static void cleanup (void *ctx) @@ -227,12 +286,33 @@ cleanup (void *ctx) GNUNET_CONTAINER_DLL_remove (mi->po_head, mi->po_tail, po); + MHD_resume_connection (po->con); } - if (NULL != po->hn) - GNUNET_assert (po == - GNUNET_CONTAINER_heap_remove_node (po->hn)); json_decref (po->pa); GNUNET_free (po->summary_filter); + switch (po->format) + { + case POF_JSON: + break; + case POF_CSV: + GNUNET_buffer_clear (&po->csv); + break; + case POF_XML: + GNUNET_buffer_clear (&po->xml); + break; + case POF_PDF: + if (NULL != po->tc) + { + TALER_MHD_typst_cancel (po->tc); + po->tc = NULL; + } + break; + } + if (NULL != po->response) + { + MHD_destroy_response (po->response); + po->response = NULL; + } GNUNET_free (po); } @@ -308,7 +388,79 @@ process_refunds_cb (void *cls, /** - * Add order details to our JSON array. + * Add one order entry to the running order-amount total in @a po. + * Sets po->result to #TALER_EC_GENERIC_FAILED_COMPUTE_AMOUNT on overflow. + * + * @param[in,out] po pending order accumulator + * @param amount the order amount to add + */ +static void +accumulate_total (struct TMH_PendingOrder *po, + const struct TALER_Amount *amount) +{ + if (! po->total_amount_initialized) + { + GNUNET_assert (GNUNET_OK == + TALER_amount_set_zero (amount->currency, + &po->total_amount)); + po->total_amount_initialized = true; + } + if (0 > TALER_amount_add (&po->total_amount, + &po->total_amount, + amount)) + { + GNUNET_break (0); + po->result = TALER_EC_GENERIC_FAILED_COMPUTE_AMOUNT; + } +} + + +/** + * Add refund amounts to the running refund totals in @a po. + * Sets po->result to #TALER_EC_GENERIC_FAILED_COMPUTE_AMOUNT on overflow. + * Only called for paid orders (where refund tracking is meaningful). + * + * @param[in,out] po pending order accumulator + * @param refund granted refund amount for this order + * @param pending pending (not-yet-processed) refund amount for this order + */ +static void +accumulate_refund_totals (struct TMH_PendingOrder *po, + const struct TALER_Amount *refund, + const struct TALER_Amount *pending) +{ + if (TALER_EC_NONE != po->result) + return; + if (! po->total_refund_initialized) + { + GNUNET_assert (GNUNET_OK == + TALER_amount_set_zero (refund->currency, + &po->total_refund_amount)); + GNUNET_assert (GNUNET_OK == + TALER_amount_set_zero (pending->currency, + &po->total_pending_refund_amount)); + po->total_refund_initialized = true; + } + if (0 > TALER_amount_add (&po->total_refund_amount, + &po->total_refund_amount, + refund)) + { + GNUNET_break (0); + po->result = TALER_EC_GENERIC_FAILED_COMPUTE_AMOUNT; + return; + } + if (0 > TALER_amount_add (&po->total_pending_refund_amount, + &po->total_pending_refund_amount, + pending)) + { + GNUNET_break (0); + po->result = TALER_EC_GENERIC_FAILED_COMPUTE_AMOUNT; + } +} + + +/** + * Add order details to our response accumulator. * * @param cls some closure * @param orig_order_id the order this is about @@ -334,6 +486,14 @@ add_order (void *cls, struct ProcessRefundsClosure prc = { .ec = TALER_EC_NONE }; + const struct TALER_Amount *amount; + char amount_buf[128]; + char refund_buf[128]; + char pending_buf[128]; + + /* Bail early if we already have an error */ + if (TALER_EC_NONE != po->result) + return; GNUNET_log (GNUNET_ERROR_TYPE_INFO, "Adding order `%s' (%llu) to result set at instance `%s'\n", @@ -472,18 +632,87 @@ add_order (void *cls, refundable = true; } + /* compute amount totals */ + amount = NULL; switch (contract->version) { case TALER_MERCHANT_CONTRACT_VERSION_0: - if (TALER_amount_is_zero (&contract->details.v0.brutto) && - (po->of.wired != TALER_EXCHANGE_YNA_ALL) ) { - /* If we are actually filtering by wire status, - and the order was over an amount of zero, - do not return it as wire status is not - exactly meaningful for orders over zero. */ - goto cleanup; + amount = &contract->details.v0.brutto; + + if (TALER_amount_is_zero (amount) && + (po->of.wired != TALER_EXCHANGE_YNA_ALL) ) + { + /* If we are actually filtering by wire status, + and the order was over an amount of zero, + do not return it as wire status is not + exactly meaningful for orders over zero. */ + goto cleanup; + } + + /* Accumulate order total */ + if (paid) + accumulate_total (po, + amount); + if (TALER_EC_NONE != po->result) + goto cleanup; + /* Accumulate refund totals (only meaningful for paid orders) */ + if (paid) + { + accumulate_refund_totals (po, + &prc.total_refund_amount, + &prc.pending_refund_amount); + if (TALER_EC_NONE != po->result) + goto cleanup; + } } + break; + case TALER_MERCHANT_CONTRACT_VERSION_1: + if (-1 == choice_index) + choice_index = 0; /* default choice */ + GNUNET_assert (choice_index < contract->details.v1.choices_len); + { + struct TALER_MERCHANT_ContractChoice *choice + = &contract->details.v1.choices[choice_index]; + + amount = &choice->amount; + /* Accumulate order total */ + accumulate_total (po, + amount); + if (TALER_EC_NONE != po->result) + goto cleanup; + /* Accumulate refund totals (only meaningful for paid orders) */ + if (paid) + { + accumulate_refund_totals (po, + &prc.total_refund_amount, + &prc.pending_refund_amount); + if (TALER_EC_NONE != po->result) + goto cleanup; + } + } + default: + GNUNET_break (0); + po->result = TALER_EC_MERCHANT_GET_ORDERS_ID_INVALID_CONTRACT_VERSION; + goto cleanup; + } + + /* convert amounts to strings (needed for some formats) */ + /* FIXME: use currency formatting rules in the future + instead of TALER_amount2s for human readability... */ + strcpy (amount_buf, + TALER_amount2s (amount)); + if (paid) + strcpy (refund_buf, + TALER_amount2s (&prc.total_refund_amount)); + if (paid) + strcpy (pending_buf, + TALER_amount2s (&prc.pending_refund_amount)); + + switch (po->format) + { + case POF_JSON: + case POF_PDF: GNUNET_assert ( 0 == json_array_append_new ( @@ -495,24 +724,20 @@ add_order (void *cls, order_serial), GNUNET_JSON_pack_timestamp ("timestamp", creation_time), - TALER_JSON_pack_amount ( - "amount", - &contract->details.v0.brutto), + TALER_JSON_pack_amount ("amount", + amount), GNUNET_JSON_pack_allow_null ( TALER_JSON_pack_amount ( "refund_amount", paid - ? &prc.total_refund_amount - : NULL)), + ? &prc.total_refund_amount + : NULL)), GNUNET_JSON_pack_allow_null ( TALER_JSON_pack_amount ( "pending_refund_amount", paid - ? &prc.pending_refund_amount - : NULL)), - TALER_JSON_pack_amount ( - "amount", - &contract->details.v0.brutto), + ? &prc.pending_refund_amount + : NULL)), GNUNET_JSON_pack_string ("summary", contract->summary), GNUNET_JSON_pack_bool ("refundable", @@ -520,51 +745,54 @@ add_order (void *cls, GNUNET_JSON_pack_bool ("paid", paid)))); break; - case TALER_MERCHANT_CONTRACT_VERSION_1: - if (-1 == choice_index) - choice_index = 0; /* default choice */ - GNUNET_assert (choice_index < contract->details.v1.choices_len); + case POF_CSV: + GNUNET_buffer_write_fstr ( + &po->csv, + "%s,%llu,%llu,%s,%s,%s,\"%s\",%s,%s\r\n", + contract->order_id, + (unsigned long long) order_serial, + (unsigned long long) GNUNET_TIME_timestamp_to_s (creation_time), + amount_buf, + paid ? refund_buf : "", + paid ? pending_buf : "", + contract->summary, + refundable ? "yes" : "no", + paid ? "yes" : "no"); + break; + case POF_XML: { - struct TALER_MERCHANT_ContractChoice *choice - = &contract->details.v1.choices[choice_index]; - - GNUNET_assert ( - 0 == - json_array_append_new ( - po->pa, - GNUNET_JSON_PACK ( - GNUNET_JSON_pack_string ("order_id", - contract->order_id), - GNUNET_JSON_pack_uint64 ("row_id", - order_serial), - GNUNET_JSON_pack_timestamp ("timestamp", - creation_time), - TALER_JSON_pack_amount ("amount", - &choice->amount), - GNUNET_JSON_pack_allow_null ( - TALER_JSON_pack_amount ( - "refund_amount", - paid - ? &prc.total_refund_amount - : NULL)), - GNUNET_JSON_pack_allow_null ( - TALER_JSON_pack_amount ( - "pending_refund_amount", - paid - ? &prc.pending_refund_amount - : NULL)), - GNUNET_JSON_pack_string ("summary", - contract->summary), - GNUNET_JSON_pack_bool ("refundable", - refundable), - GNUNET_JSON_pack_bool ("paid", - paid)))); + char *esummary = TALER_escape_xml (contract->summary); + char creation_time_s[128]; + const struct tm *tm; + time_t tt; + + tt = (time_t) GNUNET_TIME_timestamp_to_s (creation_time); + tm = gmtime (&tt); + strftime (creation_time_s, + sizeof (creation_time_s), + "%Y-%m-%dT%H:%M:%S", + tm); + GNUNET_buffer_write_fstr ( + &po->xml, + "<Row>" + "<Cell><Data ss:Type=\"String\">%s</Data></Cell>" + "<Cell ss:StyleID=\"DateFormat\"><Data ss:Type=\"DateTime\">%s</Data></Cell>" + "<Cell><Data ss:Type=\"String\">%s</Data></Cell>" + "<Cell><Data ss:Type=\"String\">%s</Data></Cell>" + "<Cell><Data ss:Type=\"String\">%s</Data></Cell>" + "<Cell ss:Formula=\"=%s()\"><Data ss:Type=\"Boolean\">%s</Data></Cell>" + "</Row>\n", + contract->order_id, + creation_time_s, + amount_buf, + paid ? refund_buf : "", + NULL != esummary ? esummary : "", + paid ? "TRUE" : "FALSE", + paid ? "1" : "0"); + GNUNET_free (esummary); } break; - default: - GNUNET_break (0); - goto cleanup; - } + } /* end switch po->format */ cleanup: json_decref (contract_terms); @@ -680,14 +908,10 @@ resume_by_event (void *cls, mi->po_tail, po); po->in_dll = false; - GNUNET_assert (po == - GNUNET_CONTAINER_heap_remove_node (po->hn)); - po->hn = NULL; MHD_resume_connection (po->con); TALER_MHD_daemon_trigger (); /* we resumed, kick MHD */ } - if ( (NULL == mi->po_head) && - (NULL != mi->po_eh) ) + if (NULL == mi->po_head) { TMH_db->event_listen_cancel (mi->po_eh); mi->po_eh = NULL; @@ -769,6 +993,212 @@ tr (const char *input) /** + * Function called with the result of a #TALER_MHD_typst() operation. + * + * @param cls closure, a `struct TMH_PendingOrder *` + * @param tr result of the operation + */ +static void +pdf_cb (void *cls, + const struct TALER_MHD_TypstResponse *tr) +{ + struct TMH_PendingOrder *po = cls; + + po->tc = NULL; + GNUNET_CONTAINER_DLL_remove (pdf_head, + pdf_tail, + po); + if (TALER_EC_NONE != tr->ec) + { + po->http_status + = TALER_ErrorCode_get_http_status (tr->ec); + po->response + = TALER_MHD_make_error (tr->ec, + tr->details.hint); + } + else + { + po->http_status = MHD_HTTP_OK; + po->response = TALER_MHD_response_from_pdf_file (tr->details.filename); + } + MHD_resume_connection (po->con); + TALER_MHD_daemon_trigger (); +} + + +/** + * Build the final response for a completed (non-long-poll) request and + * queue it on @a connection. + * + * Handles all formats (JSON, CSV, XML, PDF). For PDF this may suspend + * the connection while Typst runs asynchronously; in that case the caller + * must return #MHD_YES immediately. + * + * @param po the pending order state (already fully populated) + * @param connection the MHD connection + * @param mi the merchant instance + * @return MHD result code + */ +static MHD_RESULT +reply_orders (struct TMH_PendingOrder *po, + struct MHD_Connection *connection, + struct TMH_MerchantInstance *mi) +{ + char total_buf[128]; + char refund_buf[128]; + char pending_buf[128]; + + if (po->total_amount_initialized) + strcpy (total_buf, + TALER_amount2s (&po->total_amount)); + if (po->total_refund_initialized) + strcpy (refund_buf, + TALER_amount2s (&po->total_refund_amount)); + if (po->total_refund_initialized) + strcpy (pending_buf, + TALER_amount2s (&po->total_pending_refund_amount)); + + switch (po->format) + { + case POF_JSON: + return TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_OK, + GNUNET_JSON_pack_array_incref ("orders", + po->pa)); + case POF_CSV: + { + struct MHD_Response *resp; + MHD_RESULT mret; + + GNUNET_buffer_write_fstr ( + &po->csv, + "Total (paid only),,,%s,%s,%s,,,\r\n", + po->total_amount_initialized + ? total_buf + : "-", + po->total_refund_initialized + ? refund_buf + : "-", + po->total_refund_initialized + ? pending_buf + : "-"); + GNUNET_buffer_write_str (&po->csv, + CSV_FOOTER); + resp = MHD_create_response_from_buffer (po->csv.position, + po->csv.mem, + MHD_RESPMEM_MUST_COPY); + TALER_MHD_add_global_headers (resp, + false); + GNUNET_break (MHD_YES == + MHD_add_response_header (resp, + MHD_HTTP_HEADER_CONTENT_TYPE, + "text/csv")); + mret = MHD_queue_response (connection, + MHD_HTTP_OK, + resp); + MHD_destroy_response (resp); + return mret; + } + case POF_XML: + { + struct MHD_Response *resp; + MHD_RESULT mret; + + /* Append totals row with paid and refunded amount columns */ + GNUNET_buffer_write_fstr ( + &po->xml, + "<Row>" + "<Cell ss:StyleID=\"Total\"><Data ss:Type=\"String\">Total (paid only)</Data></Cell>" + "<Cell ss:StyleID=\"Total\"><Data ss:Type=\"String\"></Data></Cell>" + "<Cell ss:StyleID=\"Total\"><Data ss:Type=\"String\">%s</Data></Cell>" + "<Cell ss:StyleID=\"Total\"><Data ss:Type=\"String\">%s</Data></Cell>" + "<Cell ss:StyleID=\"Total\"><Data ss:Type=\"String\"></Data></Cell>" + "<Cell ss:StyleID=\"Total\"><Data ss:Type=\"String\"></Data></Cell>" + "</Row>\n", + po->total_amount_initialized + ? total_buf + : "-", + po->total_refund_initialized + ? refund_buf + : "-"); + GNUNET_buffer_write_str (&po->xml, + XML_FOOTER); + resp = MHD_create_response_from_buffer (po->xml.position, + po->xml.mem, + MHD_RESPMEM_MUST_COPY); + TALER_MHD_add_global_headers (resp, + false); + GNUNET_break (MHD_YES == + MHD_add_response_header (resp, + MHD_HTTP_HEADER_CONTENT_TYPE, + "application/vnd.ms-excel")); + mret = MHD_queue_response (connection, + MHD_HTTP_OK, + resp); + MHD_destroy_response (resp); + return mret; + } + case POF_PDF: + { + /* Build the JSON document for Typst, passing all totals */ + json_t *root; + struct TALER_MHD_TypstDocument doc; + + root = GNUNET_JSON_PACK ( + GNUNET_JSON_pack_string ("business_name", + mi->settings.name), + GNUNET_JSON_pack_array_incref ("orders", + po->pa), + po->total_amount_initialized + ? TALER_JSON_pack_amount ("total_amount", + &po->total_amount) + : GNUNET_JSON_pack_string ("total_amount", + "-"), + po->total_refund_initialized + ? TALER_JSON_pack_amount ("total_refund_amount", + &po->total_refund_amount) + : GNUNET_JSON_pack_string ("total_refund_amount", + "-"), + po->total_refund_initialized + ? TALER_JSON_pack_amount ("total_pending_refund_amount", + &po->total_pending_refund_amount) + : GNUNET_JSON_pack_string ("total_pending_refund_amount", + "-")); + doc.form_name = "orders"; + doc.data = root; + + po->tc = TALER_MHD_typst (TMH_cfg, + false, /* remove on exit */ + "merchant", + 1, /* one document */ + &doc, + &pdf_cb, + po); + json_decref (root); + if (NULL == po->tc) + { + GNUNET_log (GNUNET_ERROR_TYPE_WARNING, + "Client requested PDF, but Typst is unavailable\n"); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_NOT_IMPLEMENTED, + TALER_EC_EXCHANGE_GENERIC_NO_TYPST_OR_PDFTK, + NULL); + } + GNUNET_CONTAINER_DLL_insert (pdf_head, + pdf_tail, + po); + MHD_suspend_connection (connection); + return MHD_YES; + } + } /* end switch */ + GNUNET_assert (0); + return MHD_NO; +} + + +/** * Handle a GET "/orders" request. * * @param rh context of the handler @@ -782,25 +1212,36 @@ TMH_private_get_orders (const struct TMH_RequestHandler *rh, struct TMH_HandlerContext *hc) { struct TMH_PendingOrder *po = hc->ctx; + struct TMH_MerchantInstance *mi = hc->instance; enum GNUNET_DB_QueryStatus qs; if (NULL != po) { - /* resumed from long-polling, return answer we already have - in 'hc->ctx' */ if (TALER_EC_NONE != po->result) { + /* Resumed from long-polling with error */ GNUNET_break (0); return TALER_MHD_reply_with_error (connection, MHD_HTTP_INTERNAL_SERVER_ERROR, po->result, NULL); } - return TALER_MHD_REPLY_JSON_PACK ( - connection, - MHD_HTTP_OK, - GNUNET_JSON_pack_array_incref ("orders", - po->pa)); + if (POF_PDF == po->format) + { + /* resumed from long-polling or from Typst PDF generation */ + /* We really must have a response in this case */ + if (NULL == po->response) + { + GNUNET_break (0); + return MHD_NO; + } + return MHD_queue_response (connection, + po->http_status, + po->response); + } + return reply_orders (po, + connection, + mi); } po = GNUNET_new (struct TMH_PendingOrder); hc->ctx = po; @@ -808,8 +1249,55 @@ TMH_private_get_orders (const struct TMH_RequestHandler *rh, po->con = connection; po->pa = json_array (); GNUNET_assert (NULL != po->pa); - po->instance_id = hc->instance->settings.id; - po->mi = hc->instance; + po->instance_id = mi->settings.id; + po->mi = mi; + + /* Determine desired output format from Accept header */ + { + const char *mime; + + mime = MHD_lookup_connection_value (connection, + MHD_HEADER_KIND, + MHD_HTTP_HEADER_ACCEPT); + if (NULL == mime) + mime = "application/json"; + if (0 == strcmp (mime, + "*/*")) + mime = "application/json"; + if (0 == strcmp (mime, + "application/json")) + { + po->format = POF_JSON; + } + else if (0 == strcmp (mime, + "text/csv")) + { + po->format = POF_CSV; + GNUNET_buffer_write_str (&po->csv, + CSV_HEADER); + } + else if (0 == strcmp (mime, + "application/vnd.ms-excel")) + { + po->format = POF_XML; + GNUNET_buffer_write_str (&po->xml, + XML_HEADER); + } + else if (0 == strcmp (mime, + "application/pdf")) + { + po->format = POF_PDF; + } + else + { + GNUNET_break_op (0); + return TALER_MHD_REPLY_JSON_PACK ( + connection, + MHD_HTTP_NOT_ACCEPTABLE, + GNUNET_JSON_pack_string ("hint", + mime)); + } + } if (! (TALER_MHD_arg_to_yna (connection, "paid", @@ -947,9 +1435,9 @@ TMH_private_get_orders (const struct TMH_RequestHandler *rh, TALER_EC_GENERIC_PARAMETER_MALFORMED, "timeout_ms"); } - if (GNUNET_TIME_absolute_is_future (po->long_poll_timeout)) + if ( (GNUNET_TIME_absolute_is_future (po->long_poll_timeout)) && + (NULL == mi->po_eh) ) { - struct TMH_MerchantInstance *mi = hc->instance; struct TMH_OrderChangeEventP change_eh = { .header.type = htons (TALER_DBEVENT_MERCHANT_ORDERS_CHANGE), .header.size = htons (sizeof (change_eh)), @@ -986,35 +1474,21 @@ TMH_private_get_orders (const struct TMH_RequestHandler *rh, if ( (GNUNET_DB_STATUS_SUCCESS_NO_RESULTS == qs) && (GNUNET_TIME_absolute_is_future (po->long_poll_timeout)) ) { - struct TMH_MerchantInstance *mi = hc->instance; - struct TMH_PendingOrder *pot; - - /* setup timeout heap (if not yet exists) */ - if (NULL == order_timeout_heap) - order_timeout_heap - = GNUNET_CONTAINER_heap_create (GNUNET_CONTAINER_HEAP_ORDER_MIN); - po->hn = GNUNET_CONTAINER_heap_insert (order_timeout_heap, - po, - po->long_poll_timeout.abs_value_us); + GNUNET_assert (NULL == po->order_timeout_task); + po->order_timeout_task + = GNUNET_SCHEDULER_add_at (po->long_poll_timeout, + &order_timeout, + po); GNUNET_CONTAINER_DLL_insert (mi->po_head, mi->po_tail, po); po->in_dll = true; MHD_suspend_connection (connection); - /* start timeout task */ - pot = GNUNET_CONTAINER_heap_peek (order_timeout_heap); - if (NULL != order_timeout_task) - GNUNET_SCHEDULER_cancel (order_timeout_task); - order_timeout_task = GNUNET_SCHEDULER_add_at (pot->long_poll_timeout, - &order_timeout, - NULL); return MHD_YES; } - return TALER_MHD_REPLY_JSON_PACK ( - connection, - MHD_HTTP_OK, - GNUNET_JSON_pack_array_incref ("orders", - po->pa)); + return reply_orders (po, + connection, + mi); } diff --git a/src/backend/taler-merchant-httpd_private-get-orders.h b/src/backend/taler-merchant-httpd_private-get-orders.h @@ -64,5 +64,13 @@ void TMH_force_get_orders_resume (struct TMH_MerchantInstance *mi); +/** + * We are shutting down (or an instance is being deleted), force resume of all + * GET /orders requests. + */ +void +TMH_force_get_orders_resume_typst (void); + + /* end of taler-merchant-httpd_private-get-orders.h */ #endif diff --git a/src/backend/taler-merchant-httpd_private-patch-accounts-ID.c b/src/backend/taler-merchant-httpd_private-patch-accounts-ID.c @@ -79,7 +79,16 @@ TMH_private_patch_accounts_ID (const struct TMH_RequestHandler *rh, TALER_EC_MERCHANT_GENERIC_H_WIRE_MALFORMED, h_wire_s); } - // FIXME: check extra_wire_subject_metadata string! + if (! TALER_is_valid_subject_metadata_string ( + extra_wire_subject_metadata)) + { + GNUNET_break_op (0); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "extra_wire_subject_metadata"); + } { enum GNUNET_GenericReturnValue res; diff --git a/src/backend/taler-merchant-httpd_private-post-account.c b/src/backend/taler-merchant-httpd_private-post-account.c @@ -90,7 +90,17 @@ TMH_private_post_account (const struct TMH_RequestHandler *rh, return mret; } } - // FIXME: check extra_wire_subject_metadata string! + if (! TALER_is_valid_subject_metadata_string ( + extra_wire_subject_metadata)) + { + GNUNET_break_op (0); + return TALER_MHD_reply_with_error ( + connection, + MHD_HTTP_BAD_REQUEST, + TALER_EC_GENERIC_PARAMETER_MALFORMED, + "extra_wire_subject_metadata"); + } + { char *apt = GNUNET_strdup (TMH_allowed_payment_targets); char *method = TALER_payto_get_method (uri.full_payto); diff --git a/src/testing/test_merchant_transfer_tracking.sh b/src/testing/test_merchant_transfer_tracking.sh @@ -789,5 +789,55 @@ then fi echo " OK" + +echo -n "Fetch order list as PDF..." +STATUS=$(curl 'http://localhost:9966/instances/test/private/orders' \ + -H "Accept: application/pdf" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200, PDF created. got: $STATUS" +fi +# To keep PDF +# mv $LAST_RESPONSE test.pdf +echo "OK" + +echo -n "Fetch order list as CSV..." +STATUS=$(curl 'http://localhost:9966/instances/test/private/orders' \ + -H "Accept: text/csv" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200, CSV created. got: $STATUS" +fi +# To keep CSV +# mv $LAST_RESPONSE test.csv +echo "OK" + +echo -n "Fetch order list as XLS..." +STATUS=$(curl 'http://localhost:9966/instances/test/private/orders' \ + -H "Accept: application/vnd.ms-excel" \ + -w "%{http_code}" \ + -s \ + -o "$LAST_RESPONSE") + +if [ "$STATUS" != "200" ] +then + cat "$LAST_RESPONSE" >&2 + exit_fail "Expected 200, XLS created. got: $STATUS" +fi +# To keep XLS +# mv $LAST_RESPONSE test.xls +echo "OK" + + echo "TEST PASSED" exit 0