taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Apackages/taler-merchant-webui/src/screens/token-setup/copy.ts | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/screens/token-setup/model.ts | 504+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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, + }; +}