commit 755bb130613fb4dc5116ff9f26b88bde2b61614e
parent 88bf1eef50f5b7137618c4d8a94aadd89bdf31ac
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 22:34:51 +0100
merchant web UI: build and validate inventory template contracts
Diffstat:
3 files changed, 300 insertions(+), 13 deletions(-)
diff --git a/packages/taler-merchant-webui/src/utils/inventoryTemplates.test.ts b/packages/taler-merchant-webui/src/utils/inventoryTemplates.test.ts
@@ -0,0 +1,151 @@
+/*
+ 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 assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ TalerMerchantApi,
+ TemplateType,
+ TalerMerchantInstanceHttpClient,
+ type AccessToken,
+} from "@gnu-taler/taler-util";
+import {
+ inventoryTemplateDraft,
+ validateInventoryTemplate,
+ applyInventoryTemplate,
+} from "./inventoryTemplates.js";
+import { FakeHttpLib, ok, noContent } from "../testing/fake-http.js";
+import { unwrap } from "../api/failure.js";
+import type { TranslateFn } from "../context/translation.js";
+const t = ((s: TemplateStringsArray) => s.join("")) as TranslateFn;
+
+const full = {
+ template_type: TemplateType.INVENTORY_CART,
+ selected_products: ["coffee", "missing"],
+ selected_categories: [0, 7],
+ choose_one: true,
+ request_tip: true,
+ currency: "CHF",
+ min_amount: "CHF:0",
+ max_amount: "CHF:20",
+ max_pickup_duration: { d_us: 0 },
+} satisfies TalerMerchantApi.TemplateContractInventoryCart;
+
+test("all inventory options survive creation, client decoding and an edited PATCH", async () => {
+ let saved: TalerMerchantApi.TemplateAddDetails | undefined;
+ const http = new FakeHttpLib().handle((r) => {
+ if (r.method === "GET")
+ return ok({
+ template_description: saved?.template_description,
+ template_contract: saved?.template_contract,
+ });
+ saved = r.body as TalerMerchantApi.TemplateAddDetails;
+ return noContent();
+ });
+ const client = new TalerMerchantInstanceHttpClient(
+ "https://example.test/",
+ http,
+ );
+ const token = "secret-token:t" as AccessToken;
+ unwrap(
+ await client.addTemplate(token, {
+ template_id: "drinks",
+ template_description: "Drinks",
+ template_contract: full,
+ }),
+ );
+ const detail = unwrap(await client.getTemplateDetails(token, "drinks"));
+ assert.deepEqual(JSON.parse(JSON.stringify(detail.template_contract)), full);
+ const contract = { ...detail.template_contract } as Record<string, unknown>;
+ applyInventoryTemplate(contract, inventoryTemplateDraft(contract));
+ unwrap(
+ await client.updateTemplate(token, "drinks", {
+ template_description: "New name",
+ template_contract:
+ contract as unknown as TalerMerchantApi.TemplateContractDetails,
+ }),
+ );
+ assert.deepEqual(JSON.parse(JSON.stringify(saved?.template_contract)), full);
+});
+
+for (const [products, categories] of [
+ [["coffee"], []],
+ [[], [7]],
+ [["coffee"], [7]],
+] as const) {
+ test(`filtered selection ${JSON.stringify([products, categories])}`, () => {
+ const d = {
+ ...inventoryTemplateDraft(),
+ all: false,
+ products: [...products],
+ categories: [...categories],
+ };
+ assert.equal(validateInventoryTemplate(d, t), undefined);
+ const c: Record<string, unknown> = {
+ selected_all: true,
+ amount: "CHF:2",
+ inventory_payload: {},
+ };
+ applyInventoryTemplate(c, d);
+ assert.deepEqual(c, {
+ selected_products: [...products],
+ selected_categories: [...categories],
+ });
+ });
+}
+
+test("all inventory clears filters and permits an empty catalogue", () => {
+ const d = {
+ ...inventoryTemplateDraft(full),
+ all: true,
+ minAmount: "",
+ maxAmount: "",
+ currency: "",
+ pickupDuration: undefined,
+ };
+ const c: Record<string, unknown> = { ...full };
+ applyInventoryTemplate(c, d);
+ assert.equal(validateInventoryTemplate(d, t), undefined);
+ assert.deepEqual(c, {
+ template_type: TemplateType.INVENTORY_CART,
+ selected_all: true,
+ choose_one: true,
+ request_tip: true,
+ });
+});
+
+test("validate empty filters, currency and inclusive bounds, and pickup durations", () => {
+ const d = inventoryTemplateDraft(full);
+ for (const change of [
+ { products: [], categories: [] },
+ { currency: "" },
+ { currency: "EUR" },
+ { minAmount: "CHF:-1" },
+ { maxAmount: "garbage" },
+ { minAmount: "CHF:21" },
+ { pickupDuration: -1 },
+ { pickupDuration: 1.5 },
+ { pickupDuration: Infinity },
+ ])
+ assert.ok(validateInventoryTemplate({ ...d, ...change }, t));
+ for (const pickupDuration of [undefined, 0, 60_000_000, "forever"] as const)
+ assert.equal(
+ validateInventoryTemplate(
+ { ...d, minAmount: "CHF:20", pickupDuration },
+ t,
+ ),
+ undefined,
+ );
+});
diff --git a/packages/taler-merchant-webui/src/utils/inventoryTemplates.ts b/packages/taler-merchant-webui/src/utils/inventoryTemplates.ts
@@ -0,0 +1,118 @@
+/*
+ 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 } from "@gnu-taler/taler-util";
+import type { TalerMerchantApi } from "@gnu-taler/taler-util";
+import type { DurationValue } from "../ui/DurationInput.js";
+import type { TranslateFn } from "../context/translation.js";
+
+export interface InventoryTemplateDraft {
+ all: boolean;
+ products: string[];
+ categories: number[];
+ chooseOne: boolean;
+ requestTip: boolean;
+ currency: string;
+ minAmount: string;
+ maxAmount: string;
+ pickupDuration?: DurationValue;
+}
+
+export function inventoryTemplateDraft(
+ contract?: Record<string, unknown>,
+ currency = "",
+): InventoryTemplateDraft {
+ const c = contract as
+ | Partial<TalerMerchantApi.TemplateContractInventoryCart>
+ | undefined;
+ return {
+ all: c ? c.selected_all === true : true,
+ products: c?.selected_products ?? [],
+ categories: c?.selected_categories ?? [],
+ chooseOne: c?.choose_one ?? false,
+ requestTip: c?.request_tip ?? false,
+ currency: c ? (c.currency ?? "") : currency,
+ minAmount: c?.min_amount ?? "",
+ maxAmount: c?.max_amount ?? "",
+ pickupDuration: c?.max_pickup_duration?.d_us,
+ };
+}
+
+export function validateInventoryTemplate(
+ d: InventoryTemplateDraft,
+ t: TranslateFn,
+): string | undefined {
+ if (!d.all && !d.products.length && !d.categories.length)
+ return t`Select at least one product or category.`;
+ const bounds = [d.minAmount, d.maxAmount].filter(Boolean);
+ if (bounds.length && !d.currency)
+ return t`Choose a currency before setting cart limits.`;
+ if (
+ bounds.some(
+ (v) => !Amounts.checkString(v) || Amounts.currencyOf(v) !== d.currency,
+ )
+ )
+ return t`Enter valid cart limits in the selected currency.`;
+ if (
+ d.minAmount &&
+ d.maxAmount &&
+ Amounts.cmp(
+ Amounts.parseOrThrow(d.minAmount),
+ Amounts.parseOrThrow(d.maxAmount),
+ ) > 0
+ )
+ return t`The minimum cart total must not exceed the maximum.`;
+ if (
+ d.pickupDuration !== undefined &&
+ d.pickupDuration !== "forever" &&
+ (!Number.isSafeInteger(d.pickupDuration) || d.pickupDuration < 0)
+ )
+ return t`Enter a valid pickup or access duration.`;
+ return undefined;
+}
+
+/** Apply only fields owned by the inventory editor; keep unrelated contract data. */
+export function applyInventoryTemplate(
+ contract: Record<string, unknown>,
+ d: InventoryTemplateDraft,
+): void {
+ for (const key of [
+ "amount",
+ "inventory_payload",
+ "selected_all",
+ "selected_products",
+ "selected_categories",
+ "currency",
+ "min_amount",
+ "max_amount",
+ "max_pickup_duration",
+ ])
+ delete contract[key];
+ if (d.all) contract.selected_all = true;
+ else {
+ contract.selected_products = [...new Set(d.products)];
+ contract.selected_categories = [...new Set(d.categories)];
+ }
+ // Omit false flags unless already stored, keeping unrelated edits stable.
+ if (d.chooseOne || "choose_one" in contract)
+ contract.choose_one = d.chooseOne;
+ if (d.requestTip || "request_tip" in contract)
+ contract.request_tip = d.requestTip;
+ if (d.currency) contract.currency = d.currency;
+ if (d.minAmount) contract.min_amount = d.minAmount;
+ if (d.maxAmount) contract.max_amount = d.maxAmount;
+ if (d.pickupDuration !== undefined)
+ contract.max_pickup_duration = { d_us: d.pickupDuration };
+}
diff --git a/packages/taler-merchant-webui/src/utils/templates.ts b/packages/taler-merchant-webui/src/utils/templates.ts
@@ -14,9 +14,18 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { Amounts, TalerUriAction, TalerUris } from "@gnu-taler/taler-util";
+import {
+ Amounts,
+ TalerUriAction,
+ TalerUris,
+ TemplateType,
+} from "@gnu-taler/taler-util";
import type { HostPortPath, TalerMerchantApi } from "@gnu-taler/taler-util";
import type { TranslateFn } from "../context/translation.js";
+import {
+ applyInventoryTemplate,
+ type InventoryTemplateDraft,
+} from "./inventoryTemplates.js";
import type { DurationValue } from "../ui/DurationInput.js";
/** Keep the spelling of existing name-derived template IDs. */
@@ -106,7 +115,7 @@ export function templatePayUri(
export interface TemplateContractEdits {
/** The contract as stored, if this is an edit. Everything else is kept. */
storedContract?: Record<string, unknown>;
- /** Type of the stored template; a new one is a fixed order. */
+ /** Type of the stored template, before applying the selling choice. */
templateType: TalerMerchantApi.TemplateType;
/** Whether the form may set the amount and currency for this type. */
canChooseWhatItSells: boolean;
@@ -122,6 +131,7 @@ export interface TemplateContractEdits {
payDurationUs?: DurationValue;
/** Minimum age in years; 0 for no restriction. */
minimumAge: number;
+ inventory?: InventoryTemplateDraft;
}
/**
@@ -129,10 +139,9 @@ export interface TemplateContractEdits {
*
* A PATCH replaces the contract rather than merging into it, so this starts
* from the stored one and overwrites only the fields the form has: everything
- * else — a website template's choices and address pattern, a catalogue's
- * product selection, a pickup deadline or tip request the portal has no field
- * for — is written back as it was. A field the form owns and the merchant left
- * empty is removed rather than left stale.
+ * else, such as a website template's choices and address pattern, is kept.
+ * Inventory fields are applied only when the inventory editor supplies them.
+ * A field the form owns and the merchant left empty is removed.
*/
export function buildTemplateContract(
edits: TemplateContractEdits,
@@ -145,6 +154,15 @@ export function buildTemplateContract(
else delete contract.summary;
if (edits.canChooseWhatItSells) {
+ contract.template_type =
+ edits.selType === "catalogue"
+ ? TemplateType.INVENTORY_CART
+ : TemplateType.FIXED_ORDER;
+ if (edits.selType === "catalogue") {
+ // Default for callers without inventory edits; the full editor applies
+ // its selection below.
+ contract.selected_all = true;
+ }
if (edits.selType === "fixed") {
if (!edits.amount || !Amounts.checkString(edits.amount)) {
throw new Error("A fixed template requires a valid amount");
@@ -156,7 +174,7 @@ export function buildTemplateContract(
}
contract.amount = edits.amount;
delete contract.currency;
- } else {
+ } else if (!edits.inventory) {
if (!edits.currency.trim())
throw new Error("Template currency is required");
// No fixed amount, so the template has to name the currency it charges
@@ -168,15 +186,13 @@ export function buildTemplateContract(
if (
edits.payDurationUs !== undefined &&
- edits.payDurationUs !== "forever" &&
- (!Number.isSafeInteger(edits.payDurationUs) || edits.payDurationUs <= 0)
+ (edits.payDurationUs === "forever" ||
+ !Number.isSafeInteger(edits.payDurationUs) ||
+ edits.payDurationUs <= 0)
) {
throw new Error("Payment duration must be a positive finite integer");
}
- if (
- edits.payDurationUs === "forever" ||
- (edits.payDurationUs !== undefined && edits.payDurationUs > 0)
- ) {
+ if (edits.payDurationUs !== undefined && edits.payDurationUs > 0) {
contract.pay_duration = { d_us: edits.payDurationUs };
} else {
delete contract.pay_duration;
@@ -192,6 +208,8 @@ export function buildTemplateContract(
if (edits.minimumAge > 0) contract.minimum_age = edits.minimumAge;
else delete contract.minimum_age;
+ if (edits.inventory) applyInventoryTemplate(contract, edits.inventory);
+ delete contract.inventory_payload;
return contract;
}