commit 73947d753087c015ceaaf953799617acfe730f5e
parent c83403f7040742b4e625613dc7b6670fb38563da
Author: Florian Dold <dold@taler.net>
Date: Thu, 13 Aug 2026 18:40:06 +0200
design-documents: describe semantic token families
Diffstat:
2 files changed, 226 insertions(+), 0 deletions(-)
diff --git a/design-documents/101-semantic-token-families.rst b/design-documents/101-semantic-token-families.rst
@@ -0,0 +1,225 @@
+DD 101: Semantic Token Families
+###############################
+
+:Status: Experimental
+:DD shepherd: Florian Dold
+:First published: 2026-08-13
+
+Summary
+=======
+
+Semantic token families let merchant backend clients (initially only
+``merchant-webui-ng`` and the Web PoS) describe subscription and discount
+behavior in a token family's ``extra_data``. The WebUI translates the
+description into v1 order choices and token outputs. The metadata is
+experimental and uses the top-level keys ``experimental_subscription`` and
+``experimental_discount``.
+
+Motivation
+==========
+
+Merchants ned to be able to define the effect of a subscription token,
+they aren't expected to manually apply the token effect every sale.
+
+Requirements
+============
+
+* Both family kinds support exact percentage and capped-flat benefits;
+ discounts additionally support one free item from selected categories.
+* Benefits and issuance can be limited to product categories.
+* A subscription consumes and replaces one token.
+* A discount consumes a configured positive number of tokens and earns one
+ token for each qualifying paid order.
+* Automatic issuance must work for full-price and alternative choices without
+ stacking benefits.
+
+Proposed Solution
+=================
+
+Schema
+------
+
+The semantic object is stored under the key matching the token family's kind.
+These interfaces show the semantic members of ``extra_data``; the object may
+also contain unrelated top-level members.
+
+.. ts:def:: SubscriptionExtraData
+
+ interface SubscriptionExtraData {
+ experimental_subscription: ExperimentalSubscription;
+ }
+
+.. ts:def:: DiscountExtraData
+
+ interface DiscountExtraData {
+ experimental_discount: ExperimentalDiscount;
+ }
+
+A subscription supports percentage and capped-flat benefits. A discount
+supports both of those and a free-item benefit.
+
+.. ts:def:: ExperimentalSubscription
+
+ type ExperimentalSubscription = RedemptionCategories &
+ (PercentageBenefit | FlatBenefit);
+
+.. ts:def:: ExperimentalDiscount
+
+ type ExperimentalDiscount = RedemptionCategories &
+ (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;
+ };
+
+.. ts:def:: RedemptionCategories
+
+ interface RedemptionCategories {
+ // Non-empty array. A line item is eligible when it has any listed ID.
+ product_categories: CategorySnapshot[];
+ }
+
+.. ts:def:: CategorySnapshot
+
+ interface CategorySnapshot {
+ // Positive safe integer inventory category ID.
+ id: Integer;
+
+ // Non-empty display-name snapshot. It is not used for matching.
+ name: string;
+ }
+
+.. ts:def:: PercentageBenefit
+
+ interface PercentageBenefit {
+ type: "percentage";
+
+ // Canonical decimal string in the interval (0, 100], with no more than
+ // eight fractional digits.
+ percentage: string;
+
+ rounding?: PercentageRounding;
+ }
+
+.. ts:def:: PercentageRounding
+
+ interface PercentageRounding {
+ mode: "down" | "nearest" | "up";
+
+ // Positive canonical decimal increment in currency units, with no more
+ // than eight fractional digits. For example, "0.05" rounds to a
+ // five-cent increment.
+ precision: 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;
+ }
+
+.. ts:def:: FreeItemBenefit
+
+ interface FreeItemBenefit {
+ // Valid only in ExperimentalDiscount. One unit of the lowest-priced
+ // eligible item is deducted.
+ type: "free_item";
+ }
+
+.. ts:def:: DiscountIssuance
+
+ interface DiscountIssuance {
+ // A non-empty category array restricts issuance to the matching subtotal.
+ // "*" uses the whole-order amount and also supports amount-only orders.
+ product_categories: CategorySnapshot[] | "*";
+
+ // Optional positive Taler amount. The threshold is inclusive and applies
+ // only when its currency matches the order currency.
+ minimum_purchase?: AmountString;
+
+ // Whether the order may earn this family's token while redeeming the same
+ // family. Defaults to false when omitted.
+ issue_on_redemption?: boolean;
+ }
+
+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
+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.
+
+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
+``issue_on_redemption`` defaults to false. For example::
+
+ {
+ "experimental_discount": {
+ "type": "flat",
+ "amount": "CHF:10",
+ "required_tokens": 5,
+ "product_categories": [{"id": 1, "name": "Coffee"}],
+ "issuance": {
+ "product_categories": "*",
+ "minimum_purchase": "CHF:5",
+ "issue_on_redemption": false
+ }
+ }
+ }
+
+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,
+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.
+
+Definition of Done
+==================
+
+The editor, order and PoS flows implement the rules above; documentation and
+translations are updated; unit, UI, catalog, type, lint, and harness integration
+checks pass. Until then, the metadata remains explicitly experimental.
+
+Alternatives
+============
+
+Encoding these rules in the backend would provide centralized enforcement but
+requires backend and protocol changes. Explicitly configuring every order
+output cannot provide automatic loyalty issuance.
+
+Drawbacks
+=========
+
+The WebUI must have trustworthy line items for category rules. Category names
+are snapshots and are not localized. The experimental schema may change
+without migration.
+
+Discussion / Q&A
+================
+
+No unresolved questions.
diff --git a/design-documents/index.rst b/design-documents/index.rst
@@ -111,4 +111,5 @@ Design documents that start with "XX" are considered deprecated.
097-challenge-confirmations
098-token-fountains
099-programmable-templates
+ 101-semantic-token-families
999-template