taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

commit a75afd97bbe3ba5a93dc78f262b39c694f071805
parent e3b3d26e5acf6e43b4a65c3243fedd89e7c4f6f5
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date:   Tue, 28 Jul 2026 16:22:15 +0200

[mixed-payments] refinement after writing code

Diffstat:
Mcore/merchant/get-private-orders-ORDER_ID.rst | 8++++----
Mcore/merchant/post-private-orders-ORDER_ID-collect.rst | 28+++++++++++++++++++++-------
Mcore/merchant/post-private-orders-ORDER_ID-refund-external.rst | 43+++++++++++++++++++++++++++++++++++--------
Mdesign-documents/096-partial-payments.rst | 86++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
4 files changed, 132 insertions(+), 33 deletions(-)

diff --git a/core/merchant/get-private-orders-ORDER_ID.rst b/core/merchant/get-private-orders-ORDER_ID.rst @@ -216,6 +216,10 @@ .. ts:def:: ExternalRefundInfo interface ExternalRefundInfo { + // Identifier of this refund within the order, either chosen by + // the merchant or assigned by the backend. + id: string; + // 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". @@ -233,10 +237,6 @@ // 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 diff --git a/core/merchant/post-private-orders-ORDER_ID-collect.rst b/core/merchant/post-private-orders-ORDER_ID-collect.rst @@ -4,9 +4,11 @@ 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. + backend-generated nonce and then has the order paid. The payment itself is + not reimplemented: the request is handed to the same logic that handles a + payment by a wallet, so payment notifications, webhooks, triggers and + inventory updates behave exactly as for a wallet payment of a free order, + and the response of this endpoint is the response of that payment. The operation is idempotent: the backend derives the claim nonce deterministically from the instance public key and the order identifier, so @@ -33,10 +35,20 @@ **Response:** - :http:statuscode:`204 No content`: + :http:statuscode:`200 OK`: The order was successfully collected (or had already been collected). + The response is that of paying the order, a `PaymentResponse`. :http:statuscode:`401 Unauthorized`: The request is unauthorized. + :http:statuscode:`400 Bad Request`: + The ``choice_index`` does not fit the contract. + Returned with + ``TALER_EC_MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISSING`` + if the contract is a v1 contract and no ``choice_index`` was given, + or with + ``TALER_EC_MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_OUT_OF_BOUNDS`` + if the given ``choice_index`` does not exist in the contract, which + includes naming any choice for a v0 contract. :http:statuscode:`404 Not found`: The order is unknown to the merchant. Returned with ``TALER_EC_MERCHANT_GENERIC_ORDER_UNKNOWN``. @@ -63,8 +75,10 @@ // 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. + // For v1 contracts, the index of the choice to complete within + // the ``choices`` array of the contract terms. Mandatory for v1 + // contracts: the backend never selects a choice on its own, just + // as a wallet has to select one when paying. Must be absent for + // v0 contracts, which have no choices. 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 @@ -18,6 +18,17 @@ ``amount`` plus all ``amount_external`` entries) minus the amount already refunded through Taler, and must use the currency of the order. + Each external refund is identified by an ``id`` chosen by the merchant and + unique within the order. Recording a refund under an ``id`` that already + exists is *idempotent* if the request body is identical, which makes it safe + for a point-of-sale application to retry after a network failure without + consuming the refundable amount twice. Reusing an existing ``id`` with a + different body is refused as a conflict. + + The identifier is mandatory: a recorded external refund cannot be removed, + so a duplicate would permanently consume part of the amount that may still + be refunded for the order. + **Required permission:** ``orders-refund`` Since protocol **vMixedPayments**. @@ -28,12 +39,14 @@ **Response:** - :http:statuscode:`204 No content`: - The external refund was recorded. + :http:statuscode:`200 OK`: + The external refund was recorded, or had already been recorded with + an identical request body. The response is an + `ExternalRefundResponse` object containing the identifier of the + refund. :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. + is ``taler``, contains non-ASCII characters, or the ``id`` is empty. Returned with ``TALER_EC_GENERIC_PARAMETER_MALFORMED``. :http:statuscode:`401 Unauthorized`: The request is unauthorized. @@ -49,7 +62,10 @@ 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. + the amount already refunded through Taler, or + ``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_ALREADY_EXISTS`` + if an external refund with the same ``id`` was already recorded for + this order with different details. :http:statuscode:`500 Internal Server Error`: The server experienced an internal failure. Returned with ``TALER_EC_GENERIC_DB_START_FAILED``, @@ -71,6 +87,12 @@ // and must never be "taler". method: string; + // Identifier of this refund within the order, chosen by the + // merchant. Must be non-empty. Re-using an identifier with an + // identical request body is idempotent; re-using it with + // different details is a conflict. + id: 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). @@ -82,8 +104,13 @@ // Human-readable refund justification, mirroring Taler refunds. reason: string; + } + + .. ts:def:: ExternalRefundResponse - // Additional flat method-specific fields, same rules as in - // `ExternalPaymentInfo`. No nested objects or arrays. - [field: string]: string | Amount | Integer | boolean | null; + interface ExternalRefundResponse { + // Identifier of the recorded external refund within the order, + // either the one given in the request or the one assigned by the + // backend. Can be used to reference the refund later. + refund_id: string; } diff --git a/design-documents/096-partial-payments.rst b/design-documents/096-partial-payments.rst @@ -247,9 +247,19 @@ settled through ``amount_external``, are completed through a new private endpoint ``POST [/instances/$INSTANCE]/private/orders/$ORDER_ID/collect``. The merchant backend acts exactly like a customer wallet collecting a free order, executing the same two steps a wallet would: it first 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. +order with a backend-generated nonce, and then has the order paid. + +Neither step is reimplemented. Claiming calls the same routine the wallet +facing claim endpoint calls, and for the payment the request is turned into +the payment request a wallet would send for a free order -- no coins, and the +selected ``choice_index`` -- and handed to the payment handler itself. Payment +notifications, webhooks, triggers and inventory updates therefore do not merely +resemble those of a wallet payment, they are the same code, and the response of +the collect endpoint is the response of that payment. Adding a step to the +payment flow later does not require touching this endpoint. + +Only genuinely free orders are handed over, as the payment logic expects to be +paid with coins and reports nothing useful when it is not. The backend derives the claim nonce deterministically from the instance public key and the order identifier. This makes the collect operation idempotent @@ -275,9 +285,18 @@ be collected The optional ``session_id`` of the collect request is stored like the session identifier of a wallet payment, so a POS device can tag and later list the -orders it collected. For v1 orders with multiple choices, the optional -``choice_index`` of the collect request selects the choice to complete; -it defaults to the first choice. +orders it collected. For v1 orders, the ``choice_index`` of the collect +request selects the choice to complete and is mandatory: the backend never +picks a choice on its own, exactly as a wallet has to select one when paying. +Collecting a v1 order without a ``choice_index`` fails with +``TALER_EC_MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_MISSING``, and naming a +choice that does not exist -- including naming any choice for a v0 order, +which has none -- fails with +``TALER_EC_MERCHANT_POST_ORDERS_ID_PAY_CHOICE_INDEX_OUT_OF_BOUNDS``. +Defaulting to the first choice was rejected: which choice was completed +determines the amount, the tokens consumed and issued, and the fulfillment, +so it is a decision the client must make explicitly rather than one the +backend guesses. Failure Handling ---------------- @@ -356,6 +375,10 @@ entry: // used to pay the order. Must never be "taler". method: string; + // Identifier of this refund within the order, chosen by the + // merchant. Mandatory, see below. + id: 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. @@ -366,12 +389,41 @@ entry: // Human-readable refund justification, mirroring Taler refunds. reason: string; - - // Additional flat method-specific fields, same rules as - // ExternalPaymentInfo. - [field: string]: string | Amount | Integer | boolean | null; } +Unlike ``amount_external`` entries, external refunds are stored in dedicated +typed database columns rather than as a JSON blob, so every field the backend +reports is authoritative. Consequently an external refund carries no +method-specific extra fields for now; if a concrete integration needs them, +they can be added later without changing the fields above. + +The ``id`` makes recording an external refund idempotent. The backend stores a +hash of the request body alongside the entry, mirroring how ``h_post_data`` +makes order creation idempotent: repeating a request with the same ``id`` and +an identical body succeeds without recording a second refund, whereas reusing +an ``id`` with different details fails with +``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_ALREADY_EXISTS``. +This matters because a point-of-sale application that retries after a network +failure must not consume the refundable amount twice. + +The ``id`` is mandatory rather than generated by the backend when absent, even +though order identifiers work the other way around. The difference is what a +duplicate costs: a duplicated order is inert and eventually cleaned up, whereas +a duplicated external refund permanently consumes part of the amount that may +still be refunded for a real order. Since a recorded external refund cannot be +removed, the identifier has to come from whoever knows whether two requests +describe the same real-world refund, which is the client. + +Being unable to remove a recorded external refund is a deliberate limitation +of this design, matching Taler refunds, which can only ever be increased. It +does mean a refund recorded in error -- a wrong amount, or cash that never +actually left the till -- cannot be corrected, and deleting the order is not +an option because external refunds only exist on paid orders. Should this turn +out to be needed in practice, the natural extension is to mark such entries as +void rather than to delete them, so that the mistake stays visible for +bookkeeping, and to exclude voided entries when computing the refunded total. +That is left to a future design document. + The refund channel does not need to match the payment channel: a shop may return the whole amount in cash even when parts of the order were paid by card or with Taler. This is particularly relevant because Taler refunds are @@ -439,15 +491,21 @@ Test Plan * Merchant backend tests rejecting collect for orders with nonzero Taler amounts, for v1 choices with token inputs or outputs, and for orders already claimed by a wallet. +* Merchant backend tests rejecting collect of a v1 order that does not select + a choice or selects one that does not exist, and of a v0 order that selects + a choice although it has none. * Merchant backend tests for collect idempotency. +* Merchant backend tests rejecting normal deletion of an order claimed by a + wallet unless an explicit force operation is used. * Merchant backend tests recording external refunds, including refunds through a method different from the original payment methods and refunds on pure Taler orders without ``amount_external``. * Merchant backend tests rejecting external refunds that would exceed the order total minus the amount refunded through Taler, that use a different currency or the method name ``taler``, or that target an unpaid order. -* Merchant backend tests exposing recorded external refunds through order - status APIs. +* Merchant backend tests recording an external refund twice under the same + ``id``, once with an identical request body, which has to be idempotent, and + once with different details, which has to be refused as a conflict. * Wallet core tests for paying the existing ``amount`` field and rendering the full total from ``amount_external`` when present. * POS integration tests for a successful cash/card-first and Taler-last flow. @@ -604,8 +662,8 @@ Discussion / Q&A proper bookkeeping; a force override for the refund cap was considered and rejected, since the strict check protects the refund ledger against recording errors. Collect was specified to be idempotent via a - deterministic backend claim nonce, with an optional ``choice_index`` for - multi-choice v1 orders. No dedicated manual-resolution status value was + deterministic backend claim nonce, with a mandatory ``choice_index`` for + v1 orders. No dedicated manual-resolution status value was introduced; blocking non-forced deletion was deemed sufficient. Error codes 2535-2538 (``..._COLLECT_NOT_FREE``, ``..._COLLECT_ALREADY_CLAIMED``, ``..._DELETE_ORDERS_EXTERNALLY_PAID``,