taler-docs

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

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


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