commit 87e2fd4694fdcd01749400700b747151a327543d
parent 698ea99f429c2397418549e1527d2c6a073b3bf2
Author: Christian Grothoff <christian@grothoff.org>
Date: Sat, 21 Feb 2026 22:58:13 +0100
implement #11126
Diffstat:
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