commit f2d823a3c33b481c1b8f813d6f3caa31e1ddc926
parent 546a40d319677d0553c882605840e2e245402238
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Mon, 20 Jul 2026 11:11:34 +0200
dd96 to merchant api changes
Diffstat:
5 files changed, 268 insertions(+), 4 deletions(-)
diff --git a/core/api-merchant.rst b/core/api-merchant.rst
@@ -67,6 +67,12 @@ The currently implemented protocol version is **v32**.
challenge-signature OTP algorithms, token choices in fixed-order
templates and same-key token re-issue for anonymous access control
(see :doc:`DD 97 </design-documents/097-project-sesame>`)
+* ``vMixedPayments``: adds the additive ``amount_external`` field for orders
+ settled partly outside of Taler, the ``/private/orders/$ORDER_ID/collect``
+ endpoint for completing zero-Taler orders without a wallet, the
+ ``/private/orders/$ORDER_ID/refund-external`` endpoint for recording
+ externally settled refunds, and exposes both through order status
+ (see :doc:`DD 96 </design-documents/096-partial-payments>`)
**Ideas for future version:**
@@ -572,6 +578,18 @@ Inspecting orders
.. include:: merchant/get-private-orders-ORDER_ID.rst
+.. _collect-order:
+
+Collecting zero-Taler orders
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Orders whose Taler ``amount`` is zero, for example because the full total
+was settled through ``amount_external``, can be completed by the merchant
+backend itself instead of a customer wallet.
+
+.. include:: merchant/post-private-orders-ORDER_ID-collect.rst
+
+
.. _private-order-data-cleanup:
Private order data cleanup
@@ -596,6 +614,8 @@ Approving Refunds
.. include:: merchant/post-private-orders-ORDER_ID-refund.rst
+.. include:: merchant/post-private-orders-ORDER_ID-refund-external.rst
+
-----------------------
Tracking Wire Transfers
@@ -1473,6 +1493,41 @@ Orders
// A value of "never" is not allowed.
wire_transfer_deadline?: Timestamp;
+ // Payments for this order that were settled outside of Taler,
+ // for example in cash or by card. The ``amount`` field above (or
+ // the selected choice's ``amount`` for v1 orders) remains the
+ // amount paid with Taler; the full order total is the sum of that
+ // amount and all entries here. If present, all entries and (for
+ // v1 orders) all choices must use the currency of the Taler
+ // ``amount``. Never contains a ``taler`` entry.
+ // @since protocol **vMixedPayments**.
+ amount_external?: ExternalPaymentInfo[];
+
+ }
+
+ .. ts:def:: ExternalPaymentInfo
+
+ interface ExternalPaymentInfo {
+ // External payment method, for example "cash" or "card".
+ // Must be a stable, non-empty ASCII identifier and must
+ // never be "taler".
+ method: string;
+
+ // Identifier of the payment action within the order, unique
+ // among the entries of this ``amount_external`` array.
+ // Examples: "cash1", "card1", "card2".
+ id: string;
+
+ // Amount covered by this payment action. Must use the same
+ // currency as the Taler ``amount`` of the order.
+ amount: Amount;
+
+ // Additional method-specific fields for reconciliation or audit,
+ // for example a cashier number, terminal identifier or
+ // authorization code. These must be flat scalar values (no
+ // nested objects or arrays) so that the merchant portal can
+ // render ``amount_external`` as a generic table.
+ [field: string]: string | Amount | Integer | boolean | null;
}
.. _product-entry:
@@ -1733,6 +1788,14 @@ and is thus not yet buyer-specific.
// Exchanges that the merchant accepts even if it does not accept any auditors that audit them.
exchanges: Exchange[];
+ // Payments for this order that were settled outside of Taler,
+ // for example in cash or by card. The ``amount`` field (or the
+ // selected choice's ``amount`` for v1 contracts) remains the amount
+ // paid with Taler; the full order total is the sum of that amount
+ // and all entries here. See `ExternalPaymentInfo`.
+ // @since protocol **vMixedPayments**.
+ amount_external?: ExternalPaymentInfo[];
+
}
.. ts:def:: ContractChoice
diff --git a/core/merchant/delete-private-orders-ORDER_ID.rst b/core/merchant/delete-private-orders-ORDER_ID.rst
@@ -4,11 +4,16 @@
last 10 years (or whatever ``TAX_RECORD_EXPIRATION`` is set to) or was
claimed but is unpaid and thus still a valid offer.
+ Since protocol **vMixedPayments**, an order that carries externally
+ settled payments (``amount_external``) can only be deleted by explicitly
+ passing ``force``, as such an order may have received payments outside of
+ Taler that the merchant should resolve manually first.
+
**Request:**
:query force: *Optional*. If set to YES, the order will be deleted
- even if it was already claimed. Note that even with force, a
- paid order cannot be deleted.
+ even if it was already claimed or carries externally settled
+ payments. Note that even with force, a paid order cannot be deleted.
**Response:**
@@ -21,8 +26,11 @@
Returned with ``TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN``.
:http:statuscode:`409 Conflict`:
The backend refuses to delete the order.
- Returned with ``TALER_EC_MERCHANT_PRIVATE_DELETE_ORDERS_ALREADY_PAID`` or
- ``TALER_EC_MERCHANT_PRIVATE_DELETE_ORDERS_AWAITING_PAYMENT``.
+ Returned with ``TALER_EC_MERCHANT_PRIVATE_DELETE_ORDERS_ALREADY_PAID``,
+ ``TALER_EC_MERCHANT_PRIVATE_DELETE_ORDERS_AWAITING_PAYMENT`` or
+ ``TALER_EC_MERCHANT_PRIVATE_DELETE_ORDERS_EXTERNALLY_PAID`` (the latter
+ since protocol **vMixedPayments**, when the order carries externally
+ settled payments and ``force`` was not set).
:http:statuscode:`500 Internal Server Error`:
The server experienced an internal failure.
Returned with ``TALER_EC_GENERIC_DB_COMMIT_FAILED`` or
diff --git a/core/merchant/get-private-orders-ORDER_ID.rst b/core/merchant/get-private-orders-ORDER_ID.rst
@@ -119,6 +119,14 @@
// refunded coin; empty array if there are no refunds.
refund_details: RefundDetails[];
+ // Refunds settled outside of Taler that were recorded for this
+ // order via
+ // [/instances/$INSTANCE]/private/orders/$ORDER_ID/refund-external.
+ // These are bookkeeping entries only; empty array if there are
+ // none.
+ // @since protocol **vMixedPayments**.
+ refunds_external: ExternalRefundInfo[];
+
// Status URL, can be used as a redirect target for the browser
// to show the order QR code / trigger the wallet.
order_status_url: string;
@@ -205,6 +213,32 @@
amount: Amount;
}
+ .. ts:def:: ExternalRefundInfo
+
+ interface ExternalRefundInfo {
+ // Method by which the funds were returned to the customer,
+ // for example "cash" or "card". May differ from the methods used
+ // to pay the order. Never "taler".
+ method: string;
+
+ // The ``id`` of the ``amount_external`` entry this refund
+ // reverses, when the refund maps to a specific original payment.
+ payment_id?: string;
+
+ // Amount returned to the customer via the external method.
+ amount: Amount;
+
+ // Human-readable refund justification.
+ reason: string;
+
+ // When the external refund was recorded at the backend.
+ timestamp: Timestamp;
+
+ // Additional flat method-specific fields, same rules as in
+ // `ExternalPaymentInfo`.
+ [field: string]: string | Amount | Integer | boolean | null;
+ }
+
.. ts:def:: TransactionWireTransfer
interface TransactionWireTransfer {
diff --git a/core/merchant/post-private-orders-ORDER_ID-collect.rst b/core/merchant/post-private-orders-ORDER_ID-collect.rst
@@ -0,0 +1,70 @@
+.. http:post:: [/instances/$INSTANCE]/private/orders/$ORDER_ID/collect
+
+ Complete a zero-Taler order without a customer wallet. This is used for
+ orders whose Taler ``amount`` is zero, for example because the full total
+ was settled through ``amount_external``. The merchant backend acts like a
+ customer wallet collecting a free order: it claims the order with a
+ backend-generated nonce and then marks the contract as paid. Payment
+ notifications, webhooks, triggers and inventory updates behave exactly as
+ for a wallet payment of a free order.
+
+ The operation is idempotent: the backend derives the claim nonce
+ deterministically from the instance key and the order identifier, so
+ repeating the request after a success (or a crash between the claim and the
+ payment step) completes or replays harmlessly.
+
+ Collection is only possible while the order is unclaimed. If a customer
+ wallet has already claimed the order, the wallet owns the order and must
+ execute the free payment itself. A point-of-sale application should
+ therefore offer the order QR code first and only collect after the customer
+ declined or a timeout passed.
+
+ Collection is restricted to genuinely free Taler payments: the Taler
+ ``amount`` must be zero and, for v1 contracts, the selected choice must have
+ no ``inputs`` and no ``outputs``.
+
+ **Required permission:** ``orders-write``
+
+ Since protocol **vMixedPayments**.
+
+ **Request:**
+
+ The request body is a `CollectRequest` object.
+
+ **Response:**
+
+ :http:statuscode:`204 No content`:
+ The order was successfully collected (or had already been collected).
+ :http:statuscode:`401 Unauthorized`:
+ The request is unauthorized.
+ :http:statuscode:`404 Not found`:
+ The order is unknown to the merchant.
+ Returned with ``TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN``.
+ :http:statuscode:`409 Conflict`:
+ The order cannot be collected by the backend.
+ Returned with
+ ``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_COLLECT_ALREADY_CLAIMED``
+ if the order was already claimed by a customer wallet, or with
+ ``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_COLLECT_NOT_FREE``
+ if the Taler amount is not zero or the selected choice redeems or
+ issues tokens.
+ :http:statuscode:`500 Internal Server Error`:
+ The server experienced an internal failure.
+ Returned with ``TALER_EC_GENERIC_DB_COMMIT_FAILED`` or
+ ``TALER_EC_GENERIC_DB_SOFT_FAILURE``.
+
+ **Details:**
+
+ .. ts:def:: CollectRequest
+
+ interface CollectRequest {
+ // Session ID to store with the payment, like the ``session_id`` of
+ // a wallet payment. Allows a point-of-sale device to tag and later
+ // list the orders it collected. Absent for no session.
+ session_id?: string;
+
+ // For v1 orders with multiple choices, the index of the choice to
+ // complete within the ``choices`` array of the contract terms.
+ // Defaults to the first choice.
+ choice_index?: Integer;
+ }
diff --git a/core/merchant/post-private-orders-ORDER_ID-refund-external.rst b/core/merchant/post-private-orders-ORDER_ID-refund-external.rst
@@ -0,0 +1,89 @@
+.. http:post:: [/instances/$INSTANCE]/private/orders/$ORDER_ID/refund-external
+
+ Record a refund that was settled outside of Taler, for example a cash or
+ card return. Unlike Taler refunds, this is a bookkeeping entry only: the
+ point-of-sale or external payment integration performs the actual return of
+ funds, and no wallet pickup step exists or is needed. The recorded entries
+ are exposed through the order status as ``refunds_external``.
+
+ External refunds are available for any *paid* order, including pure Taler
+ orders without ``amount_external``. This is relevant because Taler refunds
+ are constrained by the contract's refund deadline; once it has passed, the
+ amount can only be returned through an external method, which is recorded
+ here.
+
+ The refund channel does not need to match the payment channel. The backend
+ validates external refunds against the order total: the cumulative
+ externally refunded amount must not exceed the full order total (the Taler
+ ``amount`` plus all ``amount_external`` entries) minus the amount already
+ refunded through Taler, and must use the currency of the order.
+
+ **Required permission:** ``orders-refund``
+
+ Since protocol **vMixedPayments**.
+
+ **Request:**
+
+ The request body is an `ExternalRefundRequest` object.
+
+ **Response:**
+
+ :http:statuscode:`204 No content`:
+ The external refund was recorded.
+ :http:statuscode:`400 Bad Request`:
+ The request body is malformed, for example the ``method`` is empty,
+ is ``taler``, contains non-ASCII characters, or a method-specific
+ field is not a flat scalar value.
+ Returned with ``TALER_EC_GENERIC_PARAMETER_MALFORMED``.
+ :http:statuscode:`401 Unauthorized`:
+ The request is unauthorized.
+ :http:statuscode:`404 Not found`:
+ The order is unknown to the merchant.
+ Returned with ``TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN``.
+ :http:statuscode:`409 Conflict`:
+ The external refund could not be recorded.
+ Returned with
+ ``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_ORDER_UNPAID``
+ if the order is not paid,
+ ``TALER_EC_MERCHANT_GENERIC_CURRENCY_MISMATCH``
+ if the refund uses a different currency than the order, or
+ ``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT``
+ if the cumulative external refunds would exceed the order total minus
+ the amount already refunded through Taler.
+ :http:statuscode:`500 Internal Server Error`:
+ The server experienced an internal failure.
+ Returned with ``TALER_EC_GENERIC_DB_START_FAILED``,
+ ``TALER_EC_GENERIC_DB_FETCH_FAILED``,
+ ``TALER_EC_GENERIC_DB_STORE_FAILED``,
+ ``TALER_EC_GENERIC_DB_COMMIT_FAILED``,
+ ``TALER_EC_GENERIC_DB_SOFT_FAILURE``,
+ ``TALER_EC_GENERIC_FAILED_COMPUTE_JSON_HASH`` or
+ ``TALER_EC_MERCHANT_GENERIC_DB_CONTRACT_CONTENT_INVALID``.
+
+ **Details:**
+
+ .. ts:def:: ExternalRefundRequest
+
+ interface ExternalRefundRequest {
+ // Method by which the funds were returned to the customer,
+ // for example "cash" or "card". May differ from the methods used
+ // to pay the order. Must be a stable, non-empty ASCII identifier
+ // and must never be "taler".
+ method: string;
+
+ // Optionally, the ``id`` of the ``amount_external`` entry this
+ // refund reverses, when the refund maps to a specific original
+ // payment (for example a card transaction reversal).
+ payment_id?: string;
+
+ // Amount returned to the customer via the external method.
+ // Must use the currency of the order.
+ amount: Amount;
+
+ // Human-readable refund justification, mirroring Taler refunds.
+ reason: string;
+
+ // Additional flat method-specific fields, same rules as in
+ // `ExternalPaymentInfo`. No nested objects or arrays.
+ [field: string]: string | Amount | Integer | boolean | null;
+ }