taler-docs

Documentation for GNU Taler components, APIs and protocols
Log | Files | Refs | README | LICENSE

101-semantic-token-families.rst (9236B)


      1 DD 101: Semantic Token Families MVP
      2 ###################################
      3 
      4 :Design status: Experimental
      5 :Implementation status: Prototype
      6 :DD shepherd: Florian Dold
      7 :Historical contributors: Florian Dold
      8 :First published: 2026-08-13
      9 :Last substantive change: 2026-08-19
     10 :Implementation evidence: ``taler-typescript-core`` (2026-08-13, not merged into the reviewed HEAD)
     11 :Normative references: ``core/merchant/post-private-tokenfamilies.rst`` currently specifies only opaque ``extra_data``; the semantic schemas remain experimental
     12 
     13 Summary
     14 =======
     15 
     16 Semantic token families let merchant backend clients (initially only
     17 ``merchant-webui-ng`` and the Web PoS) describe subscription and discount
     18 behavior in a token family's ``extra_data``.  The WebUI translates the
     19 description into v1 order choices and token outputs.  The metadata is
     20 experimental and uses the top-level keys ``experimental_subscription`` and
     21 ``experimental_discount``.
     22 
     23 The idea for the semantic token MVP is to implement them client-side, so we can
     24 evaluate them against the need of actual merchants and then iterate.
     25 
     26 Motivation
     27 ==========
     28 
     29 Merchants ned to be able to define the effect of a subscription token,
     30 they aren't expected to manually apply the token effect every sale.
     31 
     32 Requirements
     33 ============
     34 
     35 * easy to use for merchants
     36 * easy to prototype for further evaluation
     37 * applies both to earning and redeeming tokens
     38 
     39 Proposed Solution
     40 =================
     41 
     42 Schema
     43 ------
     44 
     45 The semantic object is stored under an experimental top-level key corresponding
     46 to the token family's kind (``experimental_subscription`` or
     47 ``experimental_discount``).
     48 These interfaces show the semantic members of ``extra_data``; the object may
     49 also contain unrelated top-level members.
     50 
     51 .. ts:def:: SubscriptionExtraData
     52 
     53   interface SubscriptionExtraData {
     54     experimental_subscription: ExperimentalSubscription;
     55   }
     56 
     57 .. ts:def:: DiscountExtraData
     58 
     59   interface DiscountExtraData {
     60     experimental_discount: ExperimentalDiscount;
     61   }
     62 
     63 A subscription supports percentage and capped-flat benefits.  A discount
     64 supports both of those and a free-item benefit.  Either family can omit an
     65 automatic redemption benefit while a discount continues to issue tokens.
     66 
     67 .. ts:def:: ExperimentalSubscription
     68 
     69   type ExperimentalSubscription = RedemptionProducts &
     70     (PercentageBenefit | FlatBenefit | NoRedemptionBenefit);
     71 
     72 .. ts:def:: ExperimentalDiscount
     73 
     74   type ExperimentalDiscount =
     75     | (RedemptionProducts &
     76         (PercentageBenefit | FlatBenefit | FreeItemBenefit) & {
     77           // Positive safe integer.  This many tokens are consumed on redemption.
     78           required_tokens: Integer;
     79 
     80           // Rule for earning one token from a paid order.
     81           issuance: DiscountIssuance;
     82         })
     83     | (RedemptionProducts & NoRedemptionBenefit & {
     84         issuance: DiscountIssuance;
     85       });
     86 
     87 .. ts:def:: RedemptionProducts
     88 
     89   interface RedemptionProducts {
     90     // A non-empty selector array restricts redemption to matching line items.
     91     // "*" uses the whole-order amount and also supports amount-only orders.
     92     product_selectors: ProductSelector[] | "*";
     93   }
     94 
     95 .. ts:def:: ProductSelector
     96 
     97   type ProductSelector = CategorySelector | InventoryProductSelector;
     98 
     99 .. ts:def:: CategorySelector
    100 
    101   interface CategorySelector {
    102     type: "category";
    103 
    104     // Positive safe-integer inventory category ID.
    105     id: Integer;
    106 
    107     // Non-empty display-name snapshot.  It is not used for matching.
    108     name: string;
    109   }
    110 
    111 .. ts:def:: InventoryProductSelector
    112 
    113   interface InventoryProductSelector {
    114     type: "product";
    115 
    116     // Non-empty inventory product ID.
    117     id: string;
    118 
    119     // Non-empty display-name snapshot.  It is not used for matching.
    120     name: string;
    121   }
    122 
    123 .. ts:def:: PercentageBenefit
    124 
    125   interface PercentageBenefit {
    126     type: "percentage";
    127 
    128     // Canonical decimal string in the interval (0, 100], with no more than
    129     // eight fractional digits.
    130     percentage: string;
    131 
    132     rounding?: PercentageRounding;
    133   }
    134 
    135 .. ts:def:: PercentageRounding
    136 
    137   interface PercentageRounding {
    138     mode: "down" | "nearest" | "up";
    139 
    140     // Positive canonical decimal increment in currency units, with no more
    141     // than eight fractional digits.  For example, "0.05" rounds to a
    142     // five-cent increment.
    143     precision: string;
    144   }
    145 
    146 .. ts:def:: AmountString
    147 
    148   // Canonical Taler amount in ``CURRENCY:VALUE`` form.
    149   type AmountString = string;
    150 
    151 .. ts:def:: FlatBenefit
    152 
    153   interface FlatBenefit {
    154     type: "flat";
    155 
    156     // One positive amount, or a non-empty list containing at most one positive
    157     // amount for each currency.  The reduction is capped at the eligible
    158     // subtotal and applies only when a cap matches the order currency.
    159     amount: AmountString | AmountString[];
    160   }
    161 
    162 .. ts:def:: FreeItemBenefit
    163 
    164   interface FreeItemBenefit {
    165     // Valid only in ExperimentalDiscount.  One eligible unit is deducted.
    166     type: "free_item";
    167 
    168     // Defaults to "cheapest" when omitted.
    169     price_selection?: "cheapest" | "most_expensive";
    170   }
    171 
    172 .. ts:def:: NoRedemptionBenefit
    173 
    174   interface NoRedemptionBenefit {
    175     // No token-consuming order choice is generated.
    176     type: "none";
    177   }
    178 
    179 .. ts:def:: DiscountIssuance
    180 
    181   interface DiscountIssuance {
    182     // A non-empty selector array restricts issuance to the matching subtotal.
    183     // "*" uses the whole-order amount and also supports amount-only orders.
    184     product_selectors: ProductSelector[] | "*";
    185 
    186     // Optional positive Taler amount.  The threshold is inclusive and applies
    187     // only when its currency matches the order currency.
    188     minimum_purchase?: AmountString;
    189 
    190     // Whether the order may earn this family's token while redeeming the same
    191     // family.  Defaults to false when omitted.
    192     issue_on_redemption?: boolean;
    193   }
    194 
    195 The WebUI accepts only canonical percentage and precision strings: leading
    196 zeroes, trailing fractional zeroes, signs and exponent notation are invalid.
    197 When ``rounding`` is absent, percentage reductions round down to Taler's
    198 smallest amount fraction.  Selector arrays must be non-empty; repeated pairs
    199 of selector type and ID are coalesced.  The WebUI writes only the semantic key
    200 matching the family kind, does not read the earlier unprefixed prototype keys
    201 or the ``product_categories`` selector format, and preserves unrelated
    202 top-level ``extra_data`` members.  Additional members inside a semantic object
    203 are not part of the schema and may be discarded when the family is edited.
    204 
    205 Behavior
    206 --------
    207 
    208 Categories and inventory products are snapshots.  Matching uses only the ID;
    209 the name is display metadata refreshed when an existing family is saved.  A
    210 line item is eligible when its inventory product ID matches a product selector
    211 or any of its category IDs matches a category selector.  Mixed selectors use
    212 OR semantics.  Ad-hoc line items have no inventory product ID and can only
    213 match the wildcard.
    214 
    215 A discount additionally has ``required_tokens`` and an ``issuance`` object.
    216 Its ``product_selectors`` is either product/category selectors or ``"*"`` for
    217 every paid merchant order.  ``minimum_purchase`` is optional and inclusive, and
    218 ``issue_on_redemption`` defaults to false.  For example::
    219 
    220   {
    221     "experimental_discount": {
    222       "type": "flat",
    223       "amount": "CHF:10",
    224       "required_tokens": 5,
    225       "product_selectors": [
    226         {"type": "category", "id": 1, "name": "Coffee"},
    227         {"type": "product", "id": "espresso", "name": "Espresso"}
    228       ],
    229       "issuance": {
    230         "product_selectors": "*",
    231         "minimum_purchase": "CHF:5",
    232         "issue_on_redemption": false
    233       }
    234     }
    235   }
    236 
    237 The WebUI calculates redemption choices separately from earned-token outputs.
    238 It adds qualifying outputs to the full-price choice and every alternative,
    239 except that same-family discount redemption suppresses issuance by default.
    240 Outputs for the same family are coalesced.  Eligibility and thresholds use the
    241 exact pre-benefit subtotal; selector rules require trustworthy line items,
    242 while wildcard rules also support amount-only orders.
    243 
    244 Test Plan
    245 =========
    246 
    247 Test metadata parsing and writing, exact benefit arithmetic, mixed selectors
    248 and wildcard eligibility, cheapest and most-expensive free-item selection,
    249 issuance thresholds, same-family suppression, and output coalescing.  WebUI
    250 tests cover editing, order creation, and PoS previews.  The harness earns
    251 tokens on paid orders and later redeems the configured threshold.
    252 
    253 Definition of Done
    254 ==================
    255 
    256 * [x] Prototype editor, order and PoS flows exist on a feature branch.
    257 * [ ] Prototype merged into the main branch.
    258 * [ ] Documentation and translations updated.
    259 * [ ] Unit, UI, catalog, type, lint, and harness integration checks pass.
    260 
    261 Until all items are complete, the metadata remains explicitly experimental.
    262 
    263 Alternatives
    264 ============
    265 
    266 Encoding these rules in the backend would provide centralized enforcement but
    267 requires backend and protocol changes.  Explicitly configuring every order
    268 output cannot provide automatic loyalty issuance.
    269 
    270 Drawbacks
    271 =========
    272 
    273 * Interpretation is client-only, different implementations might diverge
    274 * Performance isn't great, as client needs
    275   to download all tokenfamilies to evaluate
    276   their rules.
    277 
    278 Discussion / Q&A
    279 ================
    280 
    281 No unresolved questions.