commit a1dcec86e89f9acd7d3b6a118f67d9a60d78555a
parent 3c6d0b8536e21287d1333986743e282277a66995
Author: Florian Dold <dold@taler.net>
Date: Wed, 19 Aug 2026 12:34:23 +0200
DD 101: add product selectors and free-item policy
Diffstat:
1 file changed, 86 insertions(+), 39 deletions(-)
diff --git a/design-documents/101-semantic-token-families.rst b/design-documents/101-semantic-token-families.rst
@@ -54,41 +54,65 @@ also contain unrelated top-level members.
}
A subscription supports percentage and capped-flat benefits. A discount
-supports both of those and a free-item benefit.
+supports both of those and a free-item benefit. Either family can omit an
+automatic redemption benefit while a discount continues to issue tokens.
.. ts:def:: ExperimentalSubscription
- type ExperimentalSubscription = RedemptionCategories &
- (PercentageBenefit | FlatBenefit);
+ type ExperimentalSubscription = RedemptionProducts &
+ (PercentageBenefit | FlatBenefit | NoRedemptionBenefit);
.. ts:def:: ExperimentalDiscount
- type ExperimentalDiscount = RedemptionCategories &
- (PercentageBenefit | FlatBenefit | FreeItemBenefit) & {
- // Positive safe integer. This many tokens are consumed on redemption.
- required_tokens: Integer;
+ type ExperimentalDiscount =
+ | (RedemptionProducts &
+ (PercentageBenefit | FlatBenefit | FreeItemBenefit) & {
+ // Positive safe integer. This many tokens are consumed on redemption.
+ required_tokens: Integer;
- // Rule for earning one token from a paid order.
- issuance: DiscountIssuance;
- };
+ // Rule for earning one token from a paid order.
+ issuance: DiscountIssuance;
+ })
+ | (RedemptionProducts & NoRedemptionBenefit & {
+ issuance: DiscountIssuance;
+ });
-.. ts:def:: RedemptionCategories
+.. ts:def:: RedemptionProducts
- interface RedemptionCategories {
- // Non-empty array. A line item is eligible when it has any listed ID.
- product_categories: CategorySnapshot[];
+ interface RedemptionProducts {
+ // A non-empty selector array restricts redemption to matching line items.
+ // "*" uses the whole-order amount and also supports amount-only orders.
+ product_selectors: ProductSelector[] | "*";
}
-.. ts:def:: CategorySnapshot
+.. ts:def:: ProductSelector
+
+ type ProductSelector = CategorySelector | InventoryProductSelector;
+
+.. ts:def:: CategorySelector
- interface CategorySnapshot {
- // Positive safe integer inventory category ID.
+ interface CategorySelector {
+ type: "category";
+
+ // Positive safe-integer inventory category ID.
id: Integer;
// Non-empty display-name snapshot. It is not used for matching.
name: string;
}
+.. ts:def:: InventoryProductSelector
+
+ interface InventoryProductSelector {
+ type: "product";
+
+ // Non-empty inventory product ID.
+ id: string;
+
+ // Non-empty display-name snapshot. It is not used for matching.
+ name: string;
+ }
+
.. ts:def:: PercentageBenefit
interface PercentageBenefit {
@@ -112,30 +136,45 @@ supports both of those and a free-item benefit.
precision: string;
}
+.. ts:def:: AmountString
+
+ // Canonical Taler amount in ``CURRENCY:VALUE`` form.
+ type AmountString = string;
+
.. ts:def:: FlatBenefit
interface FlatBenefit {
type: "flat";
- // Positive Taler amount. The reduction is capped at the eligible
- // subtotal and applies only when its currency matches the order currency.
- amount: AmountString;
+ // One positive amount, or a non-empty list containing at most one positive
+ // amount for each currency. The reduction is capped at the eligible
+ // subtotal and applies only when a cap matches the order currency.
+ amount: AmountString | AmountString[];
}
.. ts:def:: FreeItemBenefit
interface FreeItemBenefit {
- // Valid only in ExperimentalDiscount. One unit of the lowest-priced
- // eligible item is deducted.
+ // Valid only in ExperimentalDiscount. One eligible unit is deducted.
type: "free_item";
+
+ // Defaults to "cheapest" when omitted.
+ price_selection?: "cheapest" | "most_expensive";
+ }
+
+.. ts:def:: NoRedemptionBenefit
+
+ interface NoRedemptionBenefit {
+ // No token-consuming order choice is generated.
+ type: "none";
}
.. ts:def:: DiscountIssuance
interface DiscountIssuance {
- // A non-empty category array restricts issuance to the matching subtotal.
+ // A non-empty selector array restricts issuance to the matching subtotal.
// "*" uses the whole-order amount and also supports amount-only orders.
- product_categories: CategorySnapshot[] | "*";
+ product_selectors: ProductSelector[] | "*";
// Optional positive Taler amount. The threshold is inclusive and applies
// only when its currency matches the order currency.
@@ -149,21 +188,26 @@ supports both of those and a free-item benefit.
The WebUI accepts only canonical percentage and precision strings: leading
zeroes, trailing fractional zeroes, signs and exponent notation are invalid.
When ``rounding`` is absent, percentage reductions round down to Taler's
-smallest amount fraction. Category arrays must be non-empty; repeated IDs are
-coalesced. The WebUI writes only the semantic key matching the family kind,
-does not read the earlier unprefixed prototype keys, and preserves unrelated
+smallest amount fraction. Selector arrays must be non-empty; repeated pairs
+of selector type and ID are coalesced. The WebUI writes only the semantic key
+matching the family kind, does not read the earlier unprefixed prototype keys
+or the ``product_categories`` selector format, and preserves unrelated
top-level ``extra_data`` members. Additional members inside a semantic object
are not part of the schema and may be discarded when the family is edited.
Behavior
--------
-Categories are snapshots. Matching uses only the ID; the name is display
-metadata refreshed when an existing family is saved.
+Categories and inventory products are snapshots. Matching uses only the ID;
+the name is display metadata refreshed when an existing family is saved. A
+line item is eligible when its inventory product ID matches a product selector
+or any of its category IDs matches a category selector. Mixed selectors use
+OR semantics. Ad-hoc line items have no inventory product ID and can only
+match the wildcard.
A discount additionally has ``required_tokens`` and an ``issuance`` object.
-Its ``product_categories`` is either category snapshots or ``"*"`` for every
-paid merchant order. ``minimum_purchase`` is optional and inclusive, and
+Its ``product_selectors`` is either product/category selectors or ``"*"`` for
+every paid merchant order. ``minimum_purchase`` is optional and inclusive, and
``issue_on_redemption`` defaults to false. For example::
{
@@ -171,9 +215,12 @@ paid merchant order. ``minimum_purchase`` is optional and inclusive, and
"type": "flat",
"amount": "CHF:10",
"required_tokens": 5,
- "product_categories": [{"id": 1, "name": "Coffee"}],
+ "product_selectors": [
+ {"type": "category", "id": 1, "name": "Coffee"},
+ {"type": "product", "id": "espresso", "name": "Espresso"}
+ ],
"issuance": {
- "product_categories": "*",
+ "product_selectors": "*",
"minimum_purchase": "CHF:5",
"issue_on_redemption": false
}
@@ -184,17 +231,17 @@ The WebUI calculates redemption choices separately from earned-token outputs.
It adds qualifying outputs to the full-price choice and every alternative,
except that same-family discount redemption suppresses issuance by default.
Outputs for the same family are coalesced. Eligibility and thresholds use the
-exact pre-benefit subtotal; category rules require trustworthy line items,
+exact pre-benefit subtotal; selector rules require trustworthy line items,
while wildcard rules also support amount-only orders.
Test Plan
=========
-Test metadata parsing and writing, exact benefit arithmetic, category and
-wildcard eligibility, free-item selection, issuance thresholds, same-family
-suppression, and output coalescing. WebUI tests cover editing, order creation,
-and PoS previews. The harness earns tokens on paid orders and later redeems
-the configured threshold.
+Test metadata parsing and writing, exact benefit arithmetic, mixed selectors
+and wildcard eligibility, cheapest and most-expensive free-item selection,
+issuance thresholds, same-family suppression, and output coalescing. WebUI
+tests cover editing, order creation, and PoS previews. The harness earns
+tokens on paid orders and later redeems the configured threshold.
Definition of Done
==================