commit 37605197af72b4afa5fc41cfd2db1bc319a2ff19
parent 20a9728339141d4eb1daf23e3c7dde3c0c60cbe0
Author: Florian Dold <dold@taler.net>
Date: Wed, 4 Nov 2026 23:52:32 +0100
merchant web UI: extract token setup validation and merchant-facing copy
Prepare token-family payloads from a typed draft and report errors with
wizard step and field targets. Share discount stamp and pass
descriptions.
Diffstat:
2 files changed, 625 insertions(+), 0 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/token-setup/copy.ts b/packages/taler-merchant-webui/src/screens/token-setup/copy.ts
@@ -0,0 +1,121 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+ SPDX-License-Identifier: GPL-3.0-or-later
+ */
+import type { TokenFamilyItem } from "../../api/hooks/useTokenFamilies.js";
+import type { SubscriptionItem } from "../SubscriptionsScreen.js";
+import { parseTokenSemantics } from "../../utils/tokenSemantics.js";
+import { Amounts } from "@gnu-taler/taler-util";
+import type { TranslateFn } from "../../context/translation.js";
+
+/** Draft fields can be incomplete; only format valid protocol amounts. */
+export function displayRewardAmount(value: string): string {
+ return Amounts.checkString(value) ? Amounts.formatAmount(value) : value;
+}
+
+export function rewardBenefit(
+ t: TranslateFn,
+ value: {
+ kind: "discount" | "subscription";
+ benefitType: "none" | "percentage" | "flat" | "free_item";
+ percentage?: string;
+ amounts?: readonly string[];
+ priceSelection?: "cheapest" | "most_expensive";
+ },
+): string {
+ switch (value.benefitType) {
+ case "none":
+ return value.kind === "discount"
+ ? t`Collect discount stamps only—no reward yet`
+ : t`Access only (no checkout discount)`;
+ case "percentage":
+ return value.percentage ? t`${value.percentage}% off` : t`Not set`;
+ case "flat":
+ if (
+ !value.amounts?.length ||
+ value.amounts.some((amount) => !Amounts.checkString(amount))
+ )
+ return t`Not set`;
+ return t`Up to ${value.amounts?.map(displayRewardAmount).join(", ") ?? ""} off`;
+ case "free_item":
+ return value.priceSelection === "most_expensive"
+ ? t`Highest-priced eligible item free`
+ : t`Lowest-priced eligible item free`;
+ }
+}
+
+export function stampsForReward(
+ t: TranslateFn,
+ count: string | number,
+): string {
+ return Number(count) === 1
+ ? t`Use 1 discount stamp to claim the reward`
+ : t`Use ${count} discount stamps to claim the reward`;
+}
+
+export function minimumSpendLabel(
+ t: TranslateFn,
+ allPurchases: boolean,
+): string {
+ return allPurchases
+ ? t`Minimum purchase total (optional)`
+ : t`Minimum spend on the selected products (optional)`;
+}
+
+export function validityPeriodLabel(t: TranslateFn, value: string): string {
+ const labels: Record<string, string> = {
+ "1m": t`1 minute`,
+ "1h": t`1 hour`,
+ "1d": t`1 day`,
+ "7d": t`7 days (1 week)`,
+ "30d": t`30 days`,
+ "90d": t`90 days`,
+ "365d": t`365 days`,
+ };
+ return labels[value] ?? value;
+}
+
+export function subscriptionItem(
+ t: TranslateFn,
+ tf: TokenFamilyItem,
+): SubscriptionItem {
+ const parsed = parseTokenSemantics(tf.kind, tf.extraData);
+ const semantic = parsed.status === "valid" ? parsed.semantics : undefined;
+ const benefitSummary = semantic ? rewardBenefit(t, semantic) : undefined;
+ const ruleSummary =
+ semantic?.kind === "discount"
+ ? [
+ benefitSummary,
+ ...(semantic.benefitType === "none"
+ ? []
+ : [stampsForReward(t, semantic.requiredTokens)]),
+ t`Customers earn one discount stamp per qualifying paid purchase`,
+ ].join(". ")
+ : (benefitSummary ??
+ (parsed.status === "invalid"
+ ? t`Checkout settings could not be read`
+ : undefined));
+ const selectorSummary =
+ semantic?.benefitType === "none"
+ ? undefined
+ : semantic?.productSelectors === "*"
+ ? t`All purchases`
+ : semantic?.productSelectors
+ .map((selector) => selector.name)
+ .join(", ");
+ return {
+ id: tf.slug,
+ name: tf.name,
+ description: tf.description,
+ kind: tf.kind,
+ canBeUsed: tf.validBefore
+ ? t`Ends at 00:00 UTC on ${tf.validBefore}`
+ : t`No end date`,
+ validUntil: tf.validBefore,
+ duration: tf.duration || "30d",
+ extraData: tf.extraData,
+ ruleSummary,
+ selectorSummary,
+ };
+}
diff --git a/packages/taler-merchant-webui/src/screens/token-setup/model.ts b/packages/taler-merchant-webui/src/screens/token-setup/model.ts
@@ -0,0 +1,504 @@
+/*
+ This file is part of GNU Taler
+ (C) 2026 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ type AmountString,
+ type CurrencySpecification,
+} from "@gnu-taler/taler-util";
+import type { useTranslation } from "../../context/translation.js";
+import { parseStrictDate } from "../../utils/runtimeValidation.js";
+import {
+ normalizePercentage,
+ normalizeDecimal,
+ writeTokenSemantics,
+ type CategorySnapshot,
+ type ProductSnapshot,
+ type ProductSelector,
+ type TokenSemantics,
+} from "../../utils/tokenSemantics.js";
+
+export function isFutureDateInput(value: string, nowMs = Date.now()): boolean {
+ try {
+ return parseStrictDate(value, "date").getTime() > nowMs;
+ } catch {
+ return false;
+ }
+}
+
+export function currencyFractionalStep(
+ currency: string,
+ specifications: Record<string, CurrencySpecification> = {},
+): string {
+ const entry = Object.entries(specifications).find(
+ ([candidate]) => candidate.toUpperCase() === currency.toUpperCase(),
+ )?.[1];
+ const digits = entry?.num_fractional_input_digits;
+ if (
+ !Number.isSafeInteger(digits) ||
+ digits === undefined ||
+ digits < 0 ||
+ digits > 8
+ ) {
+ return "0.01";
+ }
+ return digits === 0 ? "1" : `0.${"0".repeat(digits - 1)}1`;
+}
+
+export interface SubscriptionDraft {
+ name: string;
+ slugId: string;
+ description: string;
+ kind: "subscription" | "discount";
+ validFrom: string;
+ validUntil: string;
+ useValidFrom: boolean;
+ useValidUntil: boolean;
+ duration: string;
+ stepGranularity: string;
+ selectedCategories: CategorySnapshot[];
+ selectedProducts: ProductSnapshot[];
+ redemptionWildcard: boolean;
+ benefitType: "none" | "percentage" | "flat" | "free_item";
+ freeItemPriceSelection: "cheapest" | "most_expensive";
+ percentage: string;
+ roundingMode: "down" | "nearest" | "up";
+ roundingPrecision: string;
+ flatAmounts: string[];
+ requiredTokens: string;
+ issuanceWildcard: boolean;
+ issuanceCategories: CategorySnapshot[];
+ issuanceProducts: ProductSnapshot[];
+ minimumPurchase: string;
+ issueOnRedemption: boolean;
+ showAdvanced: boolean;
+ manualId: boolean;
+ roundingPrecisionIsDefault: boolean;
+ flatAmountsAreDefault: boolean;
+}
+
+export type TokenSetupStep =
+ | "basics"
+ | "earning"
+ | "benefit"
+ | "timing"
+ | "review";
+export interface DraftError {
+ message: string;
+ field: string;
+ step: TokenSetupStep;
+}
+export function stepForField(field: string): TokenSetupStep {
+ if (["sub_name_input", "sub_desc_input", "sub_slug_input"].includes(field))
+ return "basics";
+ if (["issuance-products", "sub_minimum_purchase"].includes(field))
+ return "earning";
+ if (["sub_valid_from", "sub_valid_until", "sub_granularity"].includes(field))
+ return "timing";
+ return "benefit";
+}
+export const slugify = (text: string) =>
+ text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "");
+export interface DraftContext {
+ t: ReturnType<typeof useTranslation>["t"];
+ primaryCurrency: string;
+ supportedCurrencies: string[];
+ displayCategories: CategorySnapshot[];
+ displayProducts: ProductSnapshot[];
+ isEditing: boolean;
+ authoritativeValidUntil?: string;
+ existingExtraData?: Record<string, unknown>;
+}
+/** Pure validation and serialization shared by the classic editor and wizard. */
+export function prepareSubscription(
+ draft: SubscriptionDraft,
+ context: DraftContext,
+) {
+ const {
+ name,
+ slugId,
+ description,
+ kind,
+ validFrom,
+ validUntil,
+ useValidFrom,
+ useValidUntil,
+ duration,
+ stepGranularity,
+ selectedCategories,
+ selectedProducts,
+ redemptionWildcard,
+ benefitType,
+ freeItemPriceSelection,
+ percentage,
+ roundingMode,
+ roundingPrecision,
+ flatAmounts,
+ requiredTokens,
+ issuanceWildcard,
+ issuanceCategories,
+ issuanceProducts,
+ minimumPurchase,
+ issueOnRedemption,
+ } = draft;
+ const {
+ t,
+ primaryCurrency,
+ supportedCurrencies,
+ displayCategories,
+ displayProducts,
+ isEditing,
+ existingExtraData,
+ } = context;
+ const errors: DraftError[] = [];
+ const setErrorMsg = (message: string, field: string) =>
+ errors.push({ message, field, step: stepForField(field) });
+ const trimmedName = name.trim();
+ const trimmedDesc = description.trim();
+ const slug = (slugId || `token_${slugify(trimmedName)}`).trim();
+
+ if (!trimmedName) {
+ setErrorMsg(t`Enter a name customers will recognize.`, "sub_name_input");
+ }
+
+ if (!trimmedDesc) {
+ setErrorMsg(
+ t`Describe these discount stamps or this pass for your customers.`,
+ "sub_desc_input",
+ );
+ }
+
+ if (!/^[a-zA-Z0-9_-]+$/.test(slug)) {
+ setErrorMsg(
+ t`The identifier can only contain letters, numbers, underscores, and hyphens (no spaces or special characters).`,
+ "sub_slug_input",
+ );
+ }
+
+ if (useValidFrom && !validFrom) {
+ setErrorMsg(t`Choose a start date.`, "sub_valid_from");
+ }
+
+ if (useValidUntil && !validUntil) {
+ setErrorMsg(t`Choose an end date.`, "sub_valid_until");
+ }
+
+ if (useValidFrom && useValidUntil) {
+ let fromMs = NaN;
+ let untilMs = NaN;
+ try {
+ fromMs = parseStrictDate(validFrom, "validFrom").getTime();
+ untilMs = parseStrictDate(validUntil, "validUntil").getTime();
+ } catch {
+ setErrorMsg(t`Enter valid calendar dates.`, "sub_valid_until");
+ }
+ if (untilMs <= fromMs) {
+ setErrorMsg(
+ t`The end date must be after the start date.`,
+ "sub_valid_until",
+ );
+ }
+ }
+
+ const authoritativeValidUntil = context.authoritativeValidUntil;
+ if (useValidUntil && (!isEditing || validUntil !== authoritativeValidUntil)) {
+ if (!isFutureDateInput(validUntil)) {
+ setErrorMsg(t`Choose a future end date.`, "sub_valid_until");
+ }
+ }
+
+ const allowedGranularities = ["1m", "1h", "1d", "7d", "30d", "90d", "365d"];
+ if (!allowedGranularities.includes(stepGranularity)) {
+ setErrorMsg(
+ t`Choose one of the available shared expiry intervals.`,
+ "sub_granularity",
+ );
+ }
+
+ if (
+ benefitType !== "none" &&
+ !redemptionWildcard &&
+ selectedCategories.length === 0 &&
+ selectedProducts.length === 0
+ ) {
+ setErrorMsg(
+ t`Select at least one product category or inventory product.`,
+ "redemption-products",
+ );
+ }
+ if (
+ benefitType !== "none" &&
+ !redemptionWildcard &&
+ selectedCategories.some(
+ ({ id }) => !displayCategories.some((category) => category.id === id),
+ )
+ ) {
+ setErrorMsg(
+ t`Remove unavailable categories before saving.`,
+ "redemption-products",
+ );
+ }
+ if (
+ benefitType !== "none" &&
+ !redemptionWildcard &&
+ selectedProducts.some(
+ ({ id }) => !displayProducts.some((product) => product.id === id),
+ )
+ ) {
+ setErrorMsg(
+ t`Remove unavailable products before saving.`,
+ "redemption-products",
+ );
+ }
+
+ const refreshCategoryNames = (
+ selected: CategorySnapshot[],
+ ): CategorySnapshot[] =>
+ selected.map(({ id }) => {
+ const category = displayCategories.find(
+ (candidate) => candidate.id === id,
+ );
+ return { id, name: category?.name ?? "" };
+ });
+ const refreshProductNames = (
+ selected: ProductSnapshot[],
+ ): ProductSnapshot[] =>
+ selected.map(({ id }) => {
+ const product = displayProducts.find((candidate) => candidate.id === id);
+ return { id, name: product?.name ?? "" };
+ });
+ const productSelectors = (
+ selectedCategorySnapshots: CategorySnapshot[],
+ selectedProductSnapshots: ProductSnapshot[],
+ ): ProductSelector[] => [
+ ...refreshCategoryNames(selectedCategorySnapshots).map((snapshot) => ({
+ type: "category" as const,
+ ...snapshot,
+ })),
+ ...refreshProductNames(selectedProductSnapshots).map((snapshot) => ({
+ type: "product" as const,
+ ...snapshot,
+ })),
+ ];
+
+ const parsedBenefit = (() => {
+ if (benefitType === "none") {
+ return { benefitType } as const;
+ }
+ if (benefitType === "free_item") {
+ return { benefitType, priceSelection: freeItemPriceSelection } as const;
+ }
+ if (benefitType === "percentage") {
+ const normalized = normalizePercentage(percentage);
+ const normalizedPrecision = normalizeDecimal(roundingPrecision);
+ if (!normalized) {
+ setErrorMsg(
+ t`Enter a percentage greater than 0 and no more than 100, with up to eight decimal places.`,
+ "sub_discount_percentage",
+ );
+ }
+ if (!normalizedPrecision || normalizedPrecision === "0") {
+ setErrorMsg(
+ t`Enter a rounding increment greater than zero, for example 0.01.`,
+ "sub_discount_rounding_precision",
+ );
+ }
+ if (!normalized || !normalizedPrecision || normalizedPrecision === "0")
+ return undefined;
+ return {
+ benefitType,
+ percentage: normalized,
+ rounding: { mode: roundingMode, precision: normalizedPrecision },
+ } as const;
+ }
+ if (flatAmounts.length === 0) {
+ setErrorMsg(t`Add a discount amount.`, "currency-caps");
+ return undefined;
+ }
+ const normalizedAmounts: AmountString[] = [];
+ const seenCurrencies = new Set<string>();
+ for (const rawAmount of flatAmounts) {
+ const normalizedAmount = rawAmount.trim();
+ if (
+ !Amounts.checkString(normalizedAmount) ||
+ !Amounts.isNonZero(normalizedAmount)
+ ) {
+ setErrorMsg(
+ t`Enter a discount amount greater than zero for each currency.`,
+ "currency-caps",
+ );
+ return undefined;
+ }
+ const currency = Amounts.currencyOf(normalizedAmount).toUpperCase();
+ if (!supportedCurrencies.includes(currency)) {
+ setErrorMsg(
+ t`Remove or change discount amounts in currencies your business no longer accepts.`,
+ "currency-caps",
+ );
+ return undefined;
+ }
+ if (seenCurrencies.has(currency)) {
+ setErrorMsg(t`Use each currency only once.`, "currency-caps");
+ return undefined;
+ }
+ seenCurrencies.add(currency);
+ normalizedAmounts.push(normalizedAmount as AmountString);
+ }
+ return { benefitType, amounts: normalizedAmounts } as const;
+ })();
+ const benefit = parsedBenefit ?? { benefitType: "none" as const };
+
+ let semantics: TokenSemantics;
+ if (kind === "subscription") {
+ if (benefit.benefitType === "free_item") {
+ setErrorMsg(
+ t`Free-item rewards are not available for passes.`,
+ "benefit-type",
+ );
+ return { errors };
+ }
+ semantics = {
+ kind,
+ ...benefit,
+ productSelectors:
+ benefit.benefitType === "none" || redemptionWildcard
+ ? "*"
+ : productSelectors(selectedCategories, selectedProducts),
+ };
+ } else {
+ const required = benefitType === "none" ? 1 : Number(requiredTokens);
+ if (
+ benefitType !== "none" &&
+ (!/^\d+$/.test(requiredTokens.trim()) ||
+ !Number.isSafeInteger(required) ||
+ required <= 0)
+ ) {
+ setErrorMsg(
+ t`Enter how many discount stamps the customer needs, starting at 1.`,
+ "sub_required_tokens",
+ );
+ }
+ if (
+ !issuanceWildcard &&
+ issuanceCategories.length === 0 &&
+ issuanceProducts.length === 0
+ ) {
+ setErrorMsg(
+ t`Select at least one product category or inventory product.`,
+ "issuance-products",
+ );
+ }
+ if (
+ !issuanceWildcard &&
+ issuanceCategories.some(
+ ({ id }) => !displayCategories.some((category) => category.id === id),
+ )
+ ) {
+ setErrorMsg(
+ t`Remove unavailable categories before saving.`,
+ "issuance-products",
+ );
+ }
+ if (
+ !issuanceWildcard &&
+ issuanceProducts.some(
+ ({ id }) => !displayProducts.some((product) => product.id === id),
+ )
+ ) {
+ setErrorMsg(
+ t`Remove unavailable products before saving.`,
+ "issuance-products",
+ );
+ }
+ let normalizedMinimum: AmountString | undefined;
+ if (minimumPurchase.trim()) {
+ const value = minimumPurchase.includes(":")
+ ? minimumPurchase.trim()
+ : `${primaryCurrency}:${minimumPurchase.trim()}`;
+ if (
+ !Amounts.checkString(value) ||
+ !Amounts.isNonZero(value) ||
+ !supportedCurrencies.includes(Amounts.currencyOf(value).toUpperCase())
+ ) {
+ setErrorMsg(
+ t`Enter a minimum spend greater than zero in a currency your business accepts.`,
+ "sub_minimum_purchase",
+ );
+ }
+ normalizedMinimum = value as AmountString;
+ }
+ semantics = {
+ kind,
+ ...benefit,
+ requiredTokens: required,
+ productSelectors:
+ benefit.benefitType === "none" || redemptionWildcard
+ ? "*"
+ : productSelectors(selectedCategories, selectedProducts),
+ issuance: {
+ productSelectors: issuanceWildcard
+ ? "*"
+ : productSelectors(issuanceCategories, issuanceProducts),
+ minimumPurchase: normalizedMinimum,
+ issueOnRedemption,
+ },
+ };
+ }
+ if (errors.length > 0) return { errors };
+ const extraData = writeTokenSemantics(existingExtraData, semantics);
+
+ return {
+ errors,
+ semantics,
+ payload: {
+ slug,
+ name,
+ description,
+ kind,
+ duration,
+ stepGranularity,
+ validFrom: useValidFrom ? validFrom : undefined,
+ validUntil: useValidUntil ? validUntil : undefined,
+ extraData,
+ },
+ };
+}
+
+/** Restore only offer-specific benefit fields; Details and earning fields stay shared. */
+export function restoreTokenKind(
+ current: SubscriptionDraft,
+ saved: SubscriptionDraft,
+ kind: SubscriptionDraft["kind"],
+): SubscriptionDraft {
+ return {
+ ...current,
+ kind,
+ benefitType: saved.benefitType,
+ percentage: saved.percentage,
+ flatAmounts: saved.flatAmounts,
+ freeItemPriceSelection: saved.freeItemPriceSelection,
+ selectedCategories: saved.selectedCategories,
+ selectedProducts: saved.selectedProducts,
+ redemptionWildcard: saved.redemptionWildcard,
+ roundingMode: saved.roundingMode,
+ roundingPrecision: saved.roundingPrecision,
+ roundingPrecisionIsDefault: saved.roundingPrecisionIsDefault,
+ flatAmountsAreDefault: saved.flatAmountsAreDefault,
+ };
+}