commit e70c18b550f91edc46ce2aa91ab1ff50e4644329
parent 2e886faf4a5a0ac225b4becf90ec1330cf9bb680
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Fri, 17 Jul 2026 23:02:32 +0200
dd96 changes
Diffstat:
1 file changed, 144 insertions(+), 40 deletions(-)
diff --git a/design-documents/096-partial-payments.rst b/design-documents/096-partial-payments.rst
@@ -32,14 +32,16 @@ already been completed outside of Taler.
Requirements
============
-* Orders and choices must be able to express mixed payment amounts.
+* Orders must be able to express mixed payment amounts, for both v0
+ orders and v1 orders with choices.
* The existing plain :ts:type:`Amount` form must remain valid for backwards
compatibility.
* The type and meaning of existing ``amount`` fields must not change.
* The existing ``amount`` field remains the amount paid with Taler.
* The optional external payment field must not include Taler entries.
* The total order amount is the sum of the existing ``amount`` field and all
- entries in ``amount_external``.
+ entries in ``amount_external``. This total must be well-defined, so all
+ amounts involved must use a single currency.
* The wallet must only pay the existing ``amount`` field.
* The POS or other accommodating application must execute all non-Taler
payments before the Taler payment.
@@ -47,9 +49,9 @@ Requirements
* If the Taler payment fails after other payments succeeded, the POS must
either modify the order and retry the Taler step or refund the already
completed non-Taler payments.
-* Orders with settled external payments and a failed Taler payment must remain
- visible in a manual-resolution status. They must not be deleted by normal
- order cleanup or by accident.
+* Orders with settled external payments and a failed Taler payment must
+ remain visible to merchant-facing applications. They must not be deleted
+ by normal order deletion or by accident.
* The merchant backend must preserve enough information for receipts,
reporting and order inspection to show how the total was split.
* Per-method payment information must be stored in a flat structure that the
@@ -69,8 +71,9 @@ Keep all existing :ts:type:`Amount` fields unchanged. In particular,
:ts:type:`ContractChoice`.``amount`` remain plain :ts:type:`Amount` values and
represent the amount the wallet pays with Taler.
-Add a new optional ``amount_external`` field next to these existing ``amount``
-fields:
+Add a new optional ``amount_external`` field to the *common* part of orders
+and contract terms (like ``products`` or the deadlines in
+:ts:type:`OrderCommon`):
.. ts:def:: ExternalPaymentInfo
@@ -99,22 +102,35 @@ fields:
amount_external?: ExternalPaymentInfo[];
}
-The proposed extension applies to the amount-bearing order and contract
-objects:
+The proposed extension applies to the common part shared by all order and
+contract terms versions:
::
- type OrderV0 = ExistingOrderV0 & PartialPaymentFields;
- type OrderChoice = ExistingOrderChoice & PartialPaymentFields;
- type ContractTermsV0 = ExistingContractTermsV0 & PartialPaymentFields;
- type ContractChoice = ExistingContractChoice & PartialPaymentFields;
+ type OrderCommon = ExistingOrderCommon & PartialPaymentFields;
+ type ContractTermsCommon = ExistingContractTermsCommon & PartialPaymentFields;
+
+The field is deliberately *not* attached to the individual v1 choices:
+external payments are settled *before* the wallet ever sees the contract,
+so they are facts about the order that cannot vary with the choice the
+wallet later selects. A per-choice field would force the POS to duplicate
+identical entries into every choice and would even allow choices to state
+contradictory external payments, which the backend could not police.
If ``amount_external`` is absent, the order is a regular pure Taler order and
the existing ``amount`` field is the total amount. If ``amount_external`` is
present, the existing ``amount`` field remains the Taler amount. The full
-order or choice total is the sum of the existing ``amount`` field and all
+order total is the sum of the selected ``amount`` field (the top-level
+``amount`` for v0, the selected choice's ``amount`` for v1) and all
entries in ``amount_external``.
+For this total to be well-defined, the backend rejects orders where
+``amount_external`` is present and any amount uses a different currency: all
+entries in ``amount_external`` must use the currency of the Taler ``amount``
+and, for v1 orders, *every* choice must be denominated in that same currency.
+Multi-currency choices remain possible for pure Taler orders without
+``amount_external``.
+
For example, an order where the customer pays CHF 30 in cash and CHF 20 in
Taler keeps ``amount`` as ``CHF:20`` and adds ``amount_external``:
@@ -132,6 +148,22 @@ Taler keeps ``amount`` as ``CHF:20`` and adds ``amount_external``:
]
}
+For a v1 order the field stays at the top level next to ``choices``; the
+CHF 30 cash payment applies regardless of which choice the wallet selects:
+
+::
+
+ {
+ "version": 1,
+ "amount_external": [
+ { "method": "cash", "id": "cash1", "amount": "CHF:30" }
+ ],
+ "choices": [
+ { "amount": "CHF:20", ... },
+ { "amount": "CHF:15", "inputs": [ ... ], ... }
+ ]
+ }
+
This is backwards compatible for old wallets because they continue to see a
plain :ts:type:`Amount` in ``amount``. Such wallets may not render the full
mixed-payment total, but they can still pay the Taler portion. Updated wallets
@@ -219,10 +251,18 @@ 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 backend derives the claim nonce deterministically from the instance key
+and the order identifier. This makes the collect operation idempotent
+(repeating it after a success or a crash between the claim and the payment
+step completes or replays harmlessly) and lets the backend distinguish
+orders it collected itself from orders claimed by a customer wallet.
+
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; the collect operation fails and reports that the order was
-claimed by a wallet. This preserves the customer's ability to obtain a wallet
+free payment itself; the collect operation fails and reports that the order
+was claimed by a wallet
+(``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_COLLECT_ALREADY_CLAIMED``).
+This preserves the customer's ability to obtain a wallet
receipt: a POS should offer the order QR code first and only collect after the
customer declined or a timeout passed.
@@ -230,11 +270,14 @@ 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``. Choices that redeem or issue tokens, such as
subscription tokens or donation receipts, require a customer wallet and cannot
-be collected.
+be collected
+(``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_COLLECT_NOT_FREE``).
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.
+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.
Failure Handling
----------------
@@ -252,11 +295,16 @@ paths:
* proceed with different payment method, and make Taler part lower or zero.
Until one of these recovery paths is completed, the order must remain visible
-to merchant-facing applications in a state that clearly requires manual
-resolution. Such orders must not be deleted through normal order deletion or
-cleanup flows. Deletion should only be possible through an explicit force
-operation that makes it clear that externally handled payments may already have
-settled.
+to merchant-facing applications. No dedicated order status value is
+introduced for this: the order simply remains in its regular claimed or
+unpaid state, and merchant-facing applications can recognize the situation
+from the presence of ``amount_external`` in an unpaid order. Such orders
+must not be deleted through normal order deletion. Any order carrying
+``amount_external`` can only be deleted by explicitly passing ``force=yes``
+to ``DELETE /private/orders/$ORDER_ID``; a normal deletion attempt fails
+with ``TALER_EC_MERCHANT_PRIVATE_DELETE_ORDERS_EXTERNALLY_PAID``. The force
+requirement makes it clear that externally handled payments may already
+have settled.
Receipt Handling
----------------
@@ -330,10 +378,25 @@ or with Taler. This is particularly relevant because Taler refunds are
constrained by the contract's refund deadline. Once it has passed, the Taler
portion can only be returned through an external method.
-The backend therefore validates external refunds against the order total rather
+For this reason external refunds are available for *all paid orders*, not
+only for orders that carry ``amount_external``: a pure Taler order whose
+refund deadline has passed can still be settled with the customer in cash,
+and the merchant backend must be able to record that for proper bookkeeping.
+
+External refunds require the order to be paid; recording a refund for an
+unpaid order fails with
+``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_ORDER_UNPAID``. An unpaid
+order has no settled Taler payment to reverse; if its external payments need
+to be undone, the POS deletes the order (with force) and, if needed, creates
+a new one with an updated payment split.
+
+The backend validates external refunds against the order total rather
than against individual payment entries: the cumulative externally refunded
amount must not exceed the full order total minus the amount already refunded
-through Taler, and must use the same currency as the order.
+through Taler
+(``TALER_EC_MERCHANT_PRIVATE_POST_ORDERS_ID_REFUND_EXTERNAL_INCONSISTENT_AMOUNT``),
+and must use the same currency as the order. There is no override for these
+checks; they protect the refund ledger against recording errors.
Unlike Taler refunds, external refund entries are bookkeeping only. The POS or
external payment integration performs the actual return of funds, and no wallet
@@ -360,16 +423,17 @@ Test Plan
* Merchant backend tests for accepting existing plain :ts:type:`Amount` fields
unchanged.
-* Merchant backend tests accepting optional ``amount_external`` in v0 orders
- and v1 choices.
+* Merchant backend tests accepting optional ``amount_external`` in the common
+ part of v0 and v1 orders.
* Merchant backend tests rejecting ``amount_external`` with ``taler`` entries,
- mixed currencies or invalid method names.
+ mixed currencies, invalid method names, duplicate entry ids or nested
+ method-specific fields.
+* Merchant backend tests rejecting v1 orders with ``amount_external`` where
+ any choice uses a different currency than the external entries.
* Merchant backend tests preserving ``amount_external`` entries with flat
method-specific fields.
-* Merchant backend tests keeping orders with settled external payments and a
- failed Taler payment in a manual-resolution status.
-* Merchant backend tests rejecting normal deletion of such orders unless an
- explicit force operation is used.
+* Merchant backend tests rejecting normal deletion of orders carrying
+ ``amount_external`` unless an explicit force operation is used.
* Merchant backend tests collecting an unclaimed zero-Taler v0 order and a
zero-amount v1 choice via the private collect endpoint.
* Merchant backend tests rejecting collect for orders with nonzero Taler
@@ -377,10 +441,11 @@ Test Plan
claimed by a wallet.
* Merchant backend tests for collect idempotency.
* Merchant backend tests recording external refunds, including refunds through
- a method different from the original payment methods.
+ 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, or that use a different
- currency or the method name ``taler``.
+ 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.
* Wallet core tests for paying the existing ``amount`` field and rendering the
@@ -395,8 +460,9 @@ Definition of Done
order creation, contract terms, order status and history.
* Merchant backend keeps all existing ``amount`` fields as plain
:ts:type:`Amount` values.
-* Merchant backend validates that ``amount_external`` has no ``taler`` entries
- and that all entries use the same currency as ``amount``.
+* Merchant backend validates that ``amount_external`` has no ``taler`` entries,
+ that all entries use the same currency as ``amount`` and that, for v1
+ orders, every choice uses that currency as well.
* Merchant backend preserves per-method payment details in ``amount_external``.
* Merchant backend keeps orders with settled external payments and a failed
Taler payment visible for manual resolution.
@@ -404,9 +470,11 @@ Definition of Done
explicit force operation to remove them.
* Merchant backend provides the private collect operation for zero-Taler
orders, restricted to unclaimed orders and genuinely free choices, with
- payment notifications identical to a wallet payment.
-* Merchant backend records external refund entries, validates them against the
- combined refund cap, and exposes them through order status APIs.
+ payment notifications identical to a wallet payment, implemented
+ idempotently.
+* Merchant backend records external refund entries for any paid order,
+ validates them against the combined refund cap, and exposes them through
+ order status APIs.
* Wallet core pays the existing ``amount`` field and does not require
``amount_external`` to complete the Taler payment.
* Wallet UIs can display the total and the selected Taler amount clearly.
@@ -432,6 +500,21 @@ would be a destructive protocol change: every component that currently parses
``amount`` unchanged and adding ``amount_external`` preserves backwards
compatibility.
+Attach amount_external to Individual Choices
+--------------------------------------------
+
+An earlier revision of this design attached ``amount_external`` to each
+amount-bearing object, i.e. also to the individual v1 ``choices``
+(``type OrderChoice = ExistingOrderChoice & PartialPaymentFields``). This
+was rejected: external payments are settled before the contract is shown to
+the wallet, so they cannot differ between choices. Per-choice fields would
+have forced the POS to duplicate identical entries into every choice and
+would have allowed choices to state contradictory external payments without
+the backend being able to reject them. It also left the external refund cap
+ill-defined for unpaid multi-choice orders. Placing the field in the common
+part avoids all of this at the cost of requiring a single currency across
+all choices whenever ``amount_external`` is present.
+
Store Payment Details in Extra
------------------------------
@@ -507,3 +590,24 @@ Discussion / Q&A
The design was updated accordingly: the existing ``amount`` remains the
Taler amount, while a new additive ``amount_external`` field carries the
externally handled amounts and reconciliation metadata.
+
+* Revisions from the implementation review of the merchant backend
+ (2026-07): ``amount_external`` was moved from the per-choice objects to
+ the common order/contract terms part, since settled external payments are
+ facts that cannot vary with the wallet's selection (see `Attach
+ amount_external to Individual Choices`_). As a consequence, orders with
+ ``amount_external`` are restricted to a single currency across all
+ choices, so that the order total and the external refund cap are always
+ well-defined. External refunds were clarified to require a *paid* order
+ and to be available for all paid orders, including pure Taler orders,
+ so that returns after the Taler refund deadline can be recorded for
+ 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
+ introduced; blocking non-forced deletion was deemed sufficient. Error
+ codes 2535-2538 (``..._COLLECT_NOT_FREE``, ``..._COLLECT_ALREADY_CLAIMED``,
+ ``..._DELETE_ORDERS_EXTERNALLY_PAID``,
+ ``..._REFUND_EXTERNAL_INCONSISTENT_AMOUNT``) were registered in GANA for
+ the new failure modes.