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.