commit 65d12a8cff99956baf3164e225a50e5d01ba24bf
parent a85665de799643db194894b2b7a28eafc1493b00
Author: bohdan-potuzhnyi <bohdan.potuzhnyi@gmail.com>
Date: Sun, 12 Jul 2026 14:13:11 +0200
[DD96] refining the design document, with new details (collect, refund, and general updates from team discussion)
Diffstat:
1 file changed, 184 insertions(+), 19 deletions(-)
diff --git a/design-documents/096-partial-payments.rst b/design-documents/096-partial-payments.rst
@@ -47,6 +47,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.
* 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
@@ -134,6 +137,12 @@ 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
should render both the full total and the selected Taler amount clearly.
+An order may also have a Taler amount of zero. This allows a POS or merchant
+portal to use the merchant backend for product tracking, receipts and reports
+even when the customer paid the whole amount with cash, card or another
+external method. Such orders are completed using the private collect operation
+described below.
+
Payment Method Names
--------------------
@@ -141,8 +150,8 @@ The initial reserved method name is:
* ``cash`` for cash accepted by the merchant or cashier
-Other names are allowed for integrations, but they should be stable ASCII
-identifiers.
+For now, payment method names are not centrally registered. Integrations may
+use any stable ASCII identifier.
The name ``taler`` is reserved and must not be used in ``amount_external``.
Taler is represented by the existing ``amount`` field.
@@ -161,6 +170,15 @@ Nested method-specific objects should not be used. This allows the merchant
portal to render ``amount_external`` as a simple table without knowing a custom
rendering format for each payment method.
+The fields beyond ``method``, ``id`` and ``amount`` are deliberately generic in
+this design and must remain flat, with no nested method-specific objects. This
+keeps rendering simple and avoids having to standardize every card terminal,
+cash register, external voucher or future payment integration up front. The
+drawback is that these fields are mostly display and reconciliation metadata.
+If GNU Taler later needs to use method-specific fields for reports, cash
+tracking, refund UI or automated reconciliation, those fields should be
+standardized explicitly in a future design.
+
Payment Flow
------------
@@ -173,13 +191,51 @@ payments:
authorization.
3. Start the Taler payment as the final step.
4. Complete the sale only after the merchant backend confirms the Taler
- payment, unless the ``taler`` amount is zero.
+ payment. If the Taler amount is zero, use the private collect operation
+ described in `Zero-Taler Order Collection`_.
The wallet receives the contract terms and computes the payable Taler amount
from the existing ``amount`` field. It may use ``amount_external`` to render
the full total so that the customer understands why the Taler amount is lower
than the order total.
+Because the Taler payment is the last step, the wallet only sees the order and
+contract terms after the externally handled payments have been settled.
+
+If the payment split changes before the Taler payment starts, the expected
+flow is to delete or abandon the old order and create a new order with updated
+external amounts. Updating an already visible or claimed order to add or remove
+external payments is out of scope for this design.
+
+Zero-Taler Order Collection
+---------------------------
+
+Orders whose Taler ``amount`` is zero, for example because the full total was
+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.
+
+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
+receipt: a POS should 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``. Choices that redeem or issue tokens, such as
+subscription tokens or donation receipts, require a customer wallet and cannot
+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.
+
Failure Handling
----------------
@@ -195,6 +251,13 @@ paths:
* cancel the order and refund or void the completed non-Taler payments;
* 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.
+
Receipt Handling
----------------
@@ -224,14 +287,73 @@ derived from the union of the flat method-specific fields present in the
payment entries. The merchant portal should not need method-specific
rendering logic to show this information.
-Refunds
--------
+External Refunds
+----------------
+
+Taler refunds continue to use the existing refund mechanism and are capped at
+the amount actually paid with Taler. Since contract terms are hashed and signed
+at claim time, external refunds must not modify ``amount_external``. Instead,
+the merchant backend records them separately, analogous to how Taler refunds
+are stored outside the contract terms.
+
+A new private operation, for example
+``POST /private/orders/$ORDER_ID/refund-external``, records an external refund
+entry:
+
+.. 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. 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;
-Taler refunds can only refund the amount actually paid with Taler. Refunds for
-cash, card or other methods remain the responsibility of the POS or external
-payment integration. When an order has a split amount, APIs and UIs should
-avoid wording that implies the merchant backend can refund the whole order
-through Taler.
+ // Amount returned to the customer via the external method.
+ amount: Amount;
+
+ // 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;
+ }
+
+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
+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
+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.
+
+Unlike Taler refunds, external refund entries are bookkeeping only. The POS or
+external payment integration performs the actual return of funds, and no wallet
+pickup step exists or is needed. Order status APIs expose the recorded entries,
+for example as ``refunds_external``, and the merchant portal renders them as a
+generic table, like ``amount_external``.
+
+For zero-Taler orders collected without a customer wallet, no Taler refund is
+ever possible because the Taler amount paid is zero. All refunds on such orders
+are therefore external by construction.
+
+Vouchers and Tokens
+-------------------
+
+External vouchers can be represented as entries in ``amount_external``. Taler
+wallet tokens, discounts, gift vouchers and P2P transfer of such vouchers are a
+separate feature area. In particular, this design does not define how a
+wallet-held voucher can be partially spent, whether remaining value is
+re-issued as a new token, or how voucher tokens can be transferred between
+wallets.
Test Plan
=========
@@ -244,6 +366,23 @@ Test Plan
mixed currencies or invalid method names.
* 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 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
+ amounts, for v1 choices with token inputs or outputs, and for orders already
+ 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.
+* 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``.
+* Merchant backend tests exposing recorded external refunds through order
+ status APIs.
* 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.
@@ -259,6 +398,15 @@ Definition of Done
* Merchant backend validates that ``amount_external`` has no ``taler`` entries
and that all entries use the same currency as ``amount``.
* 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.
+* Merchant backend prevents normal deletion of such orders and requires an
+ 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.
* 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.
@@ -266,8 +414,10 @@ Definition of Done
non-Taler payments first, Taler payment last.
* Merchant portal renders ``amount_external`` as a generic table without
method-specific renderers.
-* Documentation explains that non-Taler refunds and failure recovery are owned
- by the integrating application.
+* Merchant portal renders external refunds as a generic table without
+ method-specific renderers.
+* Documentation explains that external refunds are bookkeeping entries and
+ failure recovery is owned by the integrating application.
Alternatives
============
@@ -310,6 +460,23 @@ still be voided, refunded or used to recompute the remaining Taler amount. As
well it can create problems when refund deadline for Taler option was set as 0
and other method of payment failed.
+Use Templates or Mutable Payment Sessions
+-----------------------------------------
+
+Templates or a new payment-session model could allow the customer to inspect an
+order before choosing how to split the payment, and could support adding,
+modifying or deleting payment parts before finalization. This would be more
+flexible than DD96, but it would require a larger design across merchant
+backend, POS apps and wallets. DD96 is limited to the current ``/orders`` flow
+where external payments are settled before the Taler order is created.
+
+Split One Order Across Multiple Taler Wallets
+---------------------------------------------
+
+Multiple customers paying one order with multiple Taler wallets is out of
+scope. The current workaround is to split the sale into multiple orders or
+sub-orders, for example based on the products consumed by each customer.
+
Drawbacks
=========
@@ -317,8 +484,12 @@ Drawbacks
carefully.
* Old wallets may only render the Taler amount and not the full mixed-payment
total until they learn the new ``amount_external`` field.
+* The customer cannot inspect the order or contract terms in the wallet before
+ externally handled payments, such as cash or card payments, have been
+ settled.
* Reporting and refund UIs must distinguish total order amount from Taler-paid
- amount.
+ amount. They must also distinguish Taler refunds, which require wallet
+ pickup, from external refunds, which are bookkeeping entries only.
Open Questions
==============
@@ -326,12 +497,6 @@ Open Questions
* Should money pots store full totals, per-method totals, or both? Should
merchant backend auto create new pots per each new payment method found in
order?
-* Should payment method names be centrally registered, or is validation of
- stable ASCII identifiers sufficient?
-* Should there be a dedicated status for orders where non-Taler payments
- succeeded but the final Taler payment failed? or we can just delete them?
-* Should the merchant backend expose explicit receipt self-pickup endpoints for
- POS devices, or are existing private order status APIs sufficient?
Discussion / Q&A
================