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.