20.96. DD 96: Partial Payments#

20.96.1. Summary#

This document proposes support for orders where only part of the total amount is paid with Taler and the remaining amount is paid with other payment methods, such as cash, card, vouchers or others.

The protocol change must be additive. The existing Amount field of an order or choice continues to represent the amount paid with Taler. A new optional amount_external field carries externally handled payment amounts and the reconciliation metadata needed by POS applications and merchant back-office users.

20.96.2. Motivation#

In person purchases might involve mixed payments. A customer may pay part of an order in cash and the rest with Taler, or a cashier may need to combine Taler with a card terminal, voucher system or other local payment method. Today, the merchant backend and wallet assume that the amount in the contract is the amount the wallet pays with Taler. This model cannot represent a single receipt and order that is settled by multiple methods.

The goal is not to make the merchant backend process card or cash payments. The goal is to let the merchant backend, wallet core and POS applications agree on the order total, the Taler portion and the non-Taler portions that must have already been completed outside of Taler.

20.96.3. Requirements#

  • Orders must be able to express mixed payment amounts, for both v0 orders and v1 orders with choices.

  • The existing plain 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. 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.

  • The Taler payment is always the last payment step.

  • 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 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 merchant portal can render as a generic table.

  • The design must not require the wallet to validate that cash, card or other non-Taler payments actually happened.

20.96.4. Proposed Solution#

20.96.4.1. Additive Payment Field#

Keep all existing Amount fields unchanged. In particular, OrderV0.``amount``, OrderChoice.``amount`, ContractTermsV0.``amount`` and ContractChoice.``amount`` remain plain Amount values and represent the amount the wallet pays with Taler.

Add a new optional amount_external field to the common part of orders and contract terms (like products or the deadlines in OrderCommon):

interface ExternalPaymentInfo {
  // External payment method, for example "cash" or "card".
  // Must never be "taler".
  method: string;

  // Identifier of the payment action within the order.
  // Examples: "cash1", "sumup1", "sumup2".
  id: string;

  // Amount covered by this payment action.
  // Must always be present
  amount: Amount;

  // Additional method-specific fields. These fields must be
  // stored only at this level.
  [field: string]: string | Amount | Integer | boolean | null;
}
interface PartialPaymentFields {
  // Payments handled outside of Taler.
  amount_external?: ExternalPaymentInfo[];
}

The proposed extension applies to the common part shared by all order and contract terms versions:

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 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:

{
  "amount": "CHF:20",
  "amount_external": [
    {
      "method": "cash",
      "id": "cash1",
      "amount": "CHF:30",
      "cashier_number": "7"
    }
  ]
}

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 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.

20.96.4.2. Payment Method Names#

The initial reserved method name is:

  • cash for cash accepted by the merchant or cashier

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.

20.96.4.3. Payment Details#

For cash payments, additional fields may include the cashier name, cashier number, register identifier or similar local information. For card payments, additional fields may include the terminal identifier, acquirer reference, transaction ID or authorization code. Other systems may add the fields they need for reconciliation or audit.

The additional fields must be stored only one level below the payment entry. 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.

20.96.4.4. Payment Flow#

The POS or integrating application is responsible for orchestrating mixed payments:

  1. Create or update the order with amount_external that reflects the intended externally handled payment amount.

  2. Run all non-Taler payment steps, such as cash handling or card terminal authorization.

  3. Start the Taler payment as the final step.

  4. Complete the sale only after the merchant backend confirms the Taler 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.

20.96.4.5. 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.

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 (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.

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 (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. 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.

20.96.4.6. Failure Handling#

Mixed payments introduce a failure mode where a non-Taler payment has already succeeded but the final Taler payment fails. The merchant backend cannot automatically repair this state because it does not control the external payment method.

The POS or integrating application must therefore choose one of these recovery paths:

  • modify the order payment split and retry the Taler payment;

  • 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. 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.

20.96.4.7. Receipt Handling#

For normal wallet flows, the customer can access the Taler receipt after the wallet payment. In POS deployments this may not be enough. Some jurisdictions require a printed or otherwise directly provided receipt, and in a mixed payment flow the customer may not receive a Taler receipt if the POS application performs self-pickup or the Taler amount is zero.

POS applications and other accommodating applications must therefore support a mode where they retrieve the receipt themselves from the merchant backend and provide it to the customer through the locally required channel, such as a printer, terminal display, e-mail or another regulated receipt mechanism.

20.96.4.8. Reporting#

The merchant backend should store amount_external as part of the contract terms and expose it through order status and history APIs. Existing reporting that expects a single amount should continue to show the Taler amount from the existing amount field. Detailed views should show the externally handled amounts and the full order total.

The merchant portal should render amount_external as a table. Common columns are method, id and amount. Additional columns can be 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.

20.96.4.9. 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:

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;

  // 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.

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 (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 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.

20.96.4.10. 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.

20.96.5. Test Plan#

  • Merchant backend tests for accepting existing plain Amount fields unchanged.

  • 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, 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 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 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 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.

  • 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.

  • POS integration tests for Taler failure after a non-Taler payment succeeded.

20.96.6. Definition of Done#

  • Merchant backend supports the new additive amount_external field for order creation, contract terms, order status and history.

  • Merchant backend keeps all existing amount fields as plain Amount values.

  • 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.

  • 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, 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.

  • POS and other accommodating applications support the required orchestration: non-Taler payments first, Taler payment last.

  • Merchant portal renders amount_external as a generic table without method-specific renderers.

  • 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.

20.96.7. Alternatives#

20.96.7.1. Change the Amount Field Type#

The initial proposal changed the existing amount fields from Amount to Amount | AmountObject. This was rejected because it would be a destructive protocol change: every component that currently parses amount as a string would have to handle a new object shape. Keeping amount unchanged and adding amount_external preserves backwards compatibility.

20.96.7.2. 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.

20.96.7.3. Store Payment Details in Extra#

Another initial proposal stored the payment split under extra.payments. This was rejected because extra is intended for proprietary merchant-specific information. Official protocol fields should be explicit top-level fields, not hidden under the merchant extension area.

20.96.7.4. Create Separate Orders#

The POS could create one Taler order only for the Taler amount and track cash or card payments in its own system. This avoids changing the contract amount type, but it loses the single-order receipt and reporting model. It also makes customer-facing order totals harder to verify. As well it looses the backup and synchronisation between device possibilities.

20.96.7.5. Let Taler Run Before Other Methods#

Running Taler before cash or card would make the Taler part successful while the external payment can still fail. That leaves the merchant with a paid Taler contract for an order that may not be otherwise settled. Requiring Taler to be last gives the POS a clearer recovery path because external payments can 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.

20.96.7.6. 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.

20.96.7.7. 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.

20.96.8. Drawbacks#

  • POS implementations must handle partial failure and external refunds 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. They must also distinguish Taler refunds, which require wallet pickup, from external refunds, which are bookkeeping entries only.

20.96.9. 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?

20.96.10. Discussion / Q&A#

  • Feedback from Florian Dold: extra must remain reserved for proprietary merchant fields and must not carry official protocol data. Protocol changes should be additive, so the existing amount field should not change type. 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.