23.102. DD 101: Semantic Token Families MVP#

Design status:

Experimental

Implementation status:

Prototype

DD shepherd:

Florian Dold

Historical contributors:

Florian Dold

First published:

2026-08-13

Last substantive change:

2026-08-19

Implementation evidence:

taler-typescript-core (2026-08-13, not merged into the reviewed HEAD)

Normative references:

core/merchant/post-private-tokenfamilies.rst currently specifies only opaque extra_data; the semantic schemas remain experimental

23.102.1. 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.

The idea for the semantic token MVP is to implement them client-side, so we can evaluate them against the need of actual merchants and then iterate.

23.102.2. 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.

23.102.3. Requirements#

  • easy to use for merchants

  • easy to prototype for further evaluation

  • applies both to earning and redeeming tokens

23.102.4. Proposed Solution#

23.102.4.1. Schema#

The semantic object is stored under an experimental top-level key corresponding to the token family’s kind (experimental_subscription or experimental_discount). These interfaces show the semantic members of extra_data; the object may also contain unrelated top-level members.

interface SubscriptionExtraData {
  experimental_subscription: ExperimentalSubscription;
}
interface DiscountExtraData {
  experimental_discount: ExperimentalDiscount;
}

A subscription supports percentage and capped-flat benefits. A discount supports both of those and a free-item benefit. Either family can omit an automatic redemption benefit while a discount continues to issue tokens.

type ExperimentalSubscription = RedemptionProducts &
  (PercentageBenefit | FlatBenefit | NoRedemptionBenefit);
type ExperimentalDiscount =
  | (RedemptionProducts &
      (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;
      })
  | (RedemptionProducts & NoRedemptionBenefit & {
      issuance: DiscountIssuance;
    });
interface RedemptionProducts {
  // A non-empty selector array restricts redemption to matching line items.
  // "*" uses the whole-order amount and also supports amount-only orders.
  product_selectors: ProductSelector[] | "*";
}
type ProductSelector = CategorySelector | InventoryProductSelector;
interface CategorySelector {
  type: "category";

  // Positive safe-integer inventory category ID.
  id: Integer;

  // Non-empty display-name snapshot.  It is not used for matching.
  name: string;
}
interface InventoryProductSelector {
  type: "product";

  // Non-empty inventory product ID.
  id: string;

  // Non-empty display-name snapshot.  It is not used for matching.
  name: string;
}
interface PercentageBenefit {
  type: "percentage";

  // Canonical decimal string in the interval (0, 100], with no more than
  // eight fractional digits.
  percentage: string;

  rounding?: 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;
}
// Canonical Taler amount in CURRENCY:VALUE form.
type AmountString = string;
interface FlatBenefit {
  type: "flat";

  // One positive amount, or a non-empty list containing at most one positive
  // amount for each currency.  The reduction is capped at the eligible
  // subtotal and applies only when a cap matches the order currency.
  amount: AmountString | AmountString[];
}
interface FreeItemBenefit {
  // Valid only in ExperimentalDiscount.  One eligible unit is deducted.
  type: "free_item";

  // Defaults to "cheapest" when omitted.
  price_selection?: "cheapest" | "most_expensive";
}
interface NoRedemptionBenefit {
  // No token-consuming order choice is generated.
  type: "none";
}
interface DiscountIssuance {
  // A non-empty selector array restricts issuance to the matching subtotal.
  // "*" uses the whole-order amount and also supports amount-only orders.
  product_selectors: ProductSelector[] | "*";

  // 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. Selector arrays must be non-empty; repeated pairs of selector type and ID are coalesced. The WebUI writes only the semantic key matching the family kind, does not read the earlier unprefixed prototype keys or the product_categories selector format, 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.

23.102.4.2. Behavior#

Categories and inventory products are snapshots. Matching uses only the ID; the name is display metadata refreshed when an existing family is saved. A line item is eligible when its inventory product ID matches a product selector or any of its category IDs matches a category selector. Mixed selectors use OR semantics. Ad-hoc line items have no inventory product ID and can only match the wildcard.

A discount additionally has required_tokens and an issuance object. Its product_selectors is either product/category selectors 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_selectors": [
      {"type": "category", "id": 1, "name": "Coffee"},
      {"type": "product", "id": "espresso", "name": "Espresso"}
    ],
    "issuance": {
      "product_selectors": "*",
      "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; selector rules require trustworthy line items, while wildcard rules also support amount-only orders.

23.102.5. Test Plan#

Test metadata parsing and writing, exact benefit arithmetic, mixed selectors and wildcard eligibility, cheapest and most-expensive 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.

23.102.6. Definition of Done#

  • [x] Prototype editor, order and PoS flows exist on a feature branch.

  • [ ] Prototype merged into the main branch.

  • [ ] Documentation and translations updated.

  • [ ] Unit, UI, catalog, type, lint, and harness integration checks pass.

Until all items are complete, the metadata remains explicitly experimental.

23.102.7. 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.

23.102.8. Drawbacks#

  • Interpretation is client-only, different implementations might diverge

  • Performance isn’t great, as client needs to download all tokenfamilies to evaluate their rules.

23.102.9. Discussion / Q&A#

No unresolved questions.