commit 8eb0b7febe6eb248941b5d0493516782afbcc421
parent 67ef622ed4e7fae56930a065c1230294c07ec272
Author: Florian Dold <dold@taler.net>
Date: Fri, 11 Sep 2026 21:08:27 +0200
merchant web UI: allocate unused template IDs
Look up existing template IDs before creating a template and choose the
first free numeric suffix for automatic IDs. Retry only confirmed ID
conflicts, with a limit of ten POST attempts.
Keep the attempted ID after an uncertain response so an unchanged retry
does not create another template. Reject occupied manual IDs, return the
allocated ID, and preserve successful writes when list refresh fails.
Issue: https://bugs.taler.net/n/10620
Diffstat:
4 files changed, 445 insertions(+), 11 deletions(-)
diff --git a/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts b/packages/taler-merchant-webui/src/api/hooks/useTemplates.ts
@@ -15,16 +15,21 @@
*/
import { useMerchantSWR as useSWR } from "../swr.js";
+import { useRef } from "preact/hooks";
import type { AccessToken } from "@gnu-taler/taler-util";
import { TalerMerchantApi } from "@gnu-taler/taler-util";
import { merchantClient, accountBaseUrl } from "../client.js";
-import { unwrap } from "../failure.js";
-import { session } from "../../stores/session.js";
+import { configurationFailure, unwrap } from "../failure.js";
+import { captureSessionIdentity, session } from "../../stores/session.js";
import { Duration } from "@gnu-taler/taler-util";
import { templatePayUri } from "../../utils/templates.js";
import type { TemplateItem } from "../../types/domain.js";
import { getClientConfig } from "./common.js";
import { remoteResource } from "../contracts.js";
+import {
+ TemplateCreator,
+ type TemplateCreationOptions,
+} from "../templateCreation.js";
export interface TemplateWriteExtras {
editableDefaults?: TalerMerchantApi.TemplateContractDetailsDefaults;
@@ -56,6 +61,12 @@ export function useTemplatePayUri(): (templateId: string) => string {
export function useTemplates({ enabled = true }: { enabled?: boolean } = {}) {
const token = session.value.token;
const config = getClientConfig();
+ const identity = JSON.stringify(captureSessionIdentity());
+ const creation = useRef({ identity, creator: new TemplateCreator() });
+ if (creation.current.identity !== identity) {
+ creation.current = { identity, creator: new TemplateCreator() };
+ }
+ const creator = creation.current.creator;
const swr = useSWR(
token && enabled
@@ -82,17 +93,23 @@ export function useTemplates({ enabled = true }: { enabled?: boolean } = {}) {
description: string,
contract: TalerMerchantApi.TemplateContractDetails,
extra?: TemplateWriteExtras,
- ) => {
- if (!token) return;
+ options: TemplateCreationOptions = { idMode: "manual" },
+ ): Promise<string> => {
+ if (!token)
+ throw configurationFailure("No authenticated merchant session.");
const client = merchantClient(config);
- const res = await client.addTemplate(token as AccessToken, {
- template_id: templateId,
- template_description: description,
- template_contract: contract,
- ...templateWriteExtras(extra),
+ return creator.create({
+ client,
+ token: token as AccessToken,
+ body: {
+ template_id: templateId,
+ template_description: description,
+ template_contract: contract,
+ ...templateWriteExtras(extra),
+ },
+ ...options,
+ refresh: () => mutate(),
});
- unwrap(res);
- await mutate();
};
const updateTemplate = async (
diff --git a/packages/taler-merchant-webui/src/api/templateCreation.test.ts b/packages/taler-merchant-webui/src/api/templateCreation.test.ts
@@ -0,0 +1,286 @@
+/*
+ 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 {
+ TalerErrorCode,
+ TalerMerchantInstanceHttpClient,
+ TemplateType,
+ type AccessToken,
+ type TalerMerchantApi,
+} from "@gnu-taler/taler-util";
+import {
+ FakeHttpLib,
+ noContent,
+ ok,
+ type HandlerResult,
+ type Recorded,
+} from "../testing/fake-http.js";
+import { availableTemplateId, templateIdFromName } from "../utils/templates.js";
+import { TemplateCreator, isTemplateIdConflict } from "./templateCreation.js";
+
+const body: TalerMerchantApi.TemplateAddDetails = {
+ template_id: "tmpl_coffee",
+ template_description: "Coffee",
+ template_contract: {
+ template_type: TemplateType.FIXED_ORDER,
+ amount: "CHF:5",
+ },
+};
+const collision: HandlerResult = {
+ status: 409,
+ body: {
+ code: TalerErrorCode.MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS,
+ },
+};
+
+function fixture(ids: string[] = []) {
+ const state = {
+ ids,
+ post: (_req: Recorded): HandlerResult => noContent(),
+ list: undefined as HandlerResult | undefined,
+ refreshes: 0,
+ };
+ const http = new FakeHttpLib().handle((req) => {
+ assert.match(req.url, /\/private\/templates$/);
+ if (req.method === "GET") {
+ return (
+ state.list ??
+ ok({
+ templates: state.ids.map((id) => ({
+ template_id: id,
+ template_description: "Coffee",
+ })),
+ })
+ );
+ }
+ return state.post(req);
+ });
+ const creator = new TemplateCreator();
+ const args = {
+ client: new TalerMerchantInstanceHttpClient(
+ "https://backend.example.test/instances/shop/",
+ http,
+ ),
+ token: "secret-token:t" as AccessToken,
+ body,
+ idMode: "automatic" as const,
+ refresh: async () => {
+ state.refreshes++;
+ },
+ };
+ const posted = () =>
+ http.requests
+ .filter((r) => r.method === "POST")
+ .map((r) => r.body as TalerMerchantApi.TemplateAddDetails);
+ return { state, http, creator, args, posted };
+}
+
+test("template IDs retain normalization and choose the smallest unused exact suffix", () => {
+ assert.equal(templateIdFromName("Coffee!"), "tmpl_coffee");
+ assert.equal(templateIdFromName("coffee"), "tmpl_coffee");
+ assert.equal(templateIdFromName(" Tea & Cake "), "tmpl_tea_cake");
+ assert.equal(templateIdFromName("!!!"), "tmpl_tmpl_new");
+ for (const [base, occupied, expected] of [
+ ["tmpl_coffee", [], "tmpl_coffee"],
+ ["tmpl_coffee", ["tmpl_coffee_1"], "tmpl_coffee"],
+ ["tmpl_coffee", ["tmpl_coffee"], "tmpl_coffee_1"],
+ [
+ "tmpl_coffee",
+ ["tmpl_coffee", "tmpl_coffee_1", "tmpl_coffee_3"],
+ "tmpl_coffee_2",
+ ],
+ ["tmpl_coffee_1", ["tmpl_coffee_1", "tmpl_coffee_1_1"], "tmpl_coffee_1_2"],
+ ["tmpl_coffee", ["tmpl_Coffee", "tmpl_coffee_01"], "tmpl_coffee"],
+ ] as const) {
+ assert.equal(availableTemplateId(base, new Set(occupied)), expected);
+ }
+});
+
+test("creation reads all IDs and avoids reusing even an identical existing template", async () => {
+ const f = fixture(["tmpl_coffee", "tmpl_coffee_1", "tmpl_coffee_3"]);
+ assert.equal(await f.creator.create(f.args), "tmpl_coffee_2");
+ assert.deepEqual(
+ f.http.requests.map((r) => r.method),
+ ["GET", "POST"],
+ );
+ assert.deepEqual(f.posted(), [{ ...body, template_id: "tmpl_coffee_2" }]);
+ assert.equal(f.state.refreshes, 1);
+});
+
+test("a confirmed race skips occupied suffixes and returns the actual created ID", async () => {
+ const f = fixture(["tmpl_coffee_1", "tmpl_coffee_3"]);
+ let attempts = 0;
+ f.state.post = () => (++attempts <= 2 ? collision : noContent());
+ assert.equal(await f.creator.create(f.args), "tmpl_coffee_4");
+ assert.deepEqual(
+ f.posted().map((b) => b.template_id),
+ ["tmpl_coffee", "tmpl_coffee_2", "tmpl_coffee_4"],
+ );
+ for (const posted of f.posted()) {
+ assert.deepEqual({ ...posted, template_id: body.template_id }, body);
+ }
+ assert.equal(f.state.refreshes, 1);
+});
+
+test("automatic allocation stops after ten conflicting POSTs", async () => {
+ const f = fixture();
+ f.state.post = () => collision;
+ await assert.rejects(f.creator.create(f.args), isTemplateIdConflict);
+ assert.equal(f.posted().length, 10);
+ assert.equal(new Set(f.posted().map((b) => b.template_id)).size, 10);
+ assert.equal(f.state.refreshes, 0);
+});
+
+test("manual IDs reject listed collisions before POST and never suffix a raced collision", async () => {
+ const f = fixture(["tmpl_coffee"]);
+ const args = { ...f.args, idMode: "manual" as const };
+ await assert.rejects(f.creator.create(args), isTemplateIdConflict);
+ assert.equal(f.posted().length, 0);
+ f.state.ids = [];
+ f.state.post = () => collision;
+ await assert.rejects(f.creator.create(args), isTemplateIdConflict);
+ assert.deepEqual(f.posted(), [body]);
+});
+
+test("an available manual ID is sent unchanged", async () => {
+ const f = fixture();
+ const manual = { ...body, template_id: "Front-counter-QR" };
+ assert.equal(
+ await f.creator.create({ ...f.args, body: manual, idMode: "manual" }),
+ manual.template_id,
+ );
+ assert.deepEqual(f.posted(), [manual]);
+});
+
+for (const status of [401, 404, 500]) {
+ test(`a failed template list (${status}) prevents creation`, async () => {
+ const f = fixture();
+ f.state.list = {
+ status,
+ body: { code: TalerErrorCode.GENERIC_DB_FETCH_FAILED },
+ };
+ await assert.rejects(f.creator.create(f.args));
+ assert.equal(f.posted().length, 0);
+ });
+}
+
+for (const response of [
+ { status: 400, body: { code: TalerErrorCode.GENERIC_PARAMETER_MALFORMED } },
+ { status: 401, body: { code: TalerErrorCode.GENERIC_UNAUTHORIZED } },
+ {
+ status: 409,
+ body: { code: TalerErrorCode.MERCHANT_GENERIC_INSTANCE_UNKNOWN },
+ },
+ { status: 409, body: {} },
+ { status: 500, body: { code: TalerErrorCode.GENERIC_DB_STORE_FAILED } },
+]) {
+ test(`unrelated failure ${response.status}/${response.body.code} does not allocate another ID`, async () => {
+ const f = fixture();
+ f.state.post = () => response;
+ await assert.rejects(f.creator.create(f.args));
+ assert.deepEqual(f.posted(), [body]);
+ assert.equal(f.state.refreshes, 0);
+ });
+}
+
+for (const idMode of ["automatic", "manual"] as const) {
+ test(`an unchanged ${idMode} request reuses its ID after an uncertain POST`, async () => {
+ const f = fixture();
+ let attempts = 0;
+ f.state.post = (req) => {
+ if (++attempts === 1) {
+ f.state.ids.push(
+ (req.body as TalerMerchantApi.TemplateAddDetails).template_id,
+ );
+ throw new TypeError("response lost after creation");
+ }
+ return noContent();
+ };
+ const args = { ...f.args, idMode };
+ await assert.rejects(f.creator.create(args), /response lost/);
+ assert.equal(await f.creator.create(args), "tmpl_coffee");
+ assert.deepEqual(f.posted(), [body, body]);
+ assert.equal(f.state.refreshes, 1);
+ });
+}
+
+test("an uncertain suffixed request remains pinned through a failed list refresh", async () => {
+ const f = fixture(["tmpl_coffee"]);
+ f.state.post = (req) => {
+ f.state.ids.push(
+ (req.body as TalerMerchantApi.TemplateAddDetails).template_id,
+ );
+ throw new TypeError("lost response");
+ };
+ await assert.rejects(f.creator.create(f.args));
+ f.state.list = {
+ status: 500,
+ body: { code: TalerErrorCode.GENERIC_DB_FETCH_FAILED },
+ };
+ await assert.rejects(f.creator.create(f.args));
+ f.state.list = undefined;
+ f.state.post = () => noContent();
+ assert.equal(await f.creator.create(f.args), "tmpl_coffee_1");
+ assert.deepEqual(
+ f.posted().map((b) => b.template_id),
+ ["tmpl_coffee_1", "tmpl_coffee_1"],
+ );
+});
+
+for (const change of ["contents", "account", "token", "mode"] as const) {
+ test(`changing ${change} starts a new allocation after an uncertain request`, async () => {
+ const f = fixture();
+ f.state.post = () => {
+ throw new TypeError("lost response");
+ };
+ await assert.rejects(f.creator.create(f.args));
+ f.state.ids = ["tmpl_coffee"];
+ f.state.post = () => noContent();
+ const args = { ...f.args, idMode: "automatic" as "automatic" | "manual" };
+ if (change === "contents")
+ args.body = { ...body, template_description: "Coffee!" };
+ if (change === "account")
+ args.client = new TalerMerchantInstanceHttpClient(
+ "https://backend.example.test/instances/other/",
+ f.http,
+ );
+ if (change === "token") args.token = "secret-token:other" as AccessToken;
+ if (change === "mode") {
+ args.idMode = "manual";
+ await assert.rejects(f.creator.create(args), isTemplateIdConflict);
+ assert.equal(f.posted().length, 1);
+ } else {
+ assert.equal(await f.creator.create(args), "tmpl_coffee_1");
+ }
+ });
+}
+
+test("a failed refresh after creation still returns success and clears the pending attempt", async () => {
+ const f = fixture();
+ assert.equal(
+ await f.creator.create({
+ ...f.args,
+ refresh: async () => {
+ throw new Error("refresh failed");
+ },
+ }),
+ "tmpl_coffee",
+ );
+ f.state.ids = ["tmpl_coffee"];
+ assert.equal(await f.creator.create(f.args), "tmpl_coffee_1");
+});
diff --git a/packages/taler-merchant-webui/src/api/templateCreation.ts b/packages/taler-merchant-webui/src/api/templateCreation.ts
@@ -0,0 +1,110 @@
+/*
+ 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 {
+ HttpStatusCode,
+ TalerErrorCode,
+ type AccessToken,
+ type TalerMerchantApi,
+ type TalerMerchantInstanceHttpClient,
+} from "@gnu-taler/taler-util";
+import { availableTemplateId } from "../utils/templates.js";
+import { ApiFailure, normalizeApiFailure, unwrap } from "./failure.js";
+
+export interface TemplateCreationOptions {
+ idMode: "automatic" | "manual";
+}
+
+export function isTemplateIdConflict(error: unknown): boolean {
+ const failure = normalizeApiFailure(error);
+ return (
+ failure.httpStatus === HttpStatusCode.Conflict &&
+ failure.talerCode ===
+ TalerErrorCode.MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS
+ );
+}
+
+/** One form's creation attempts, including retries after a lost response. */
+export class TemplateCreator {
+ private pending?: { requestKey: string; id: string };
+
+ async create({
+ client,
+ token,
+ body,
+ idMode,
+ refresh,
+ }: TemplateCreationOptions & {
+ client: TalerMerchantInstanceHttpClient;
+ token: AccessToken;
+ body: TalerMerchantApi.TemplateAddDetails;
+ refresh: () => Promise<unknown>;
+ }): Promise<string> {
+ const requestKey = JSON.stringify([client.baseUrl, token, idMode, body]);
+ if (this.pending?.requestKey !== requestKey) this.pending = undefined;
+
+ // The backend treats identical POSTs as successful idempotent requests.
+ // Listing first avoids silently reusing an existing identical template.
+ const list = unwrap(await client.listTemplates(token));
+ const occupied = new Set(list.templates.map((t) => t.template_id));
+ const base = body.template_id;
+ if (!this.pending && idMode === "manual" && occupied.has(base)) {
+ throw new ApiFailure({
+ type: "fail",
+ case: HttpStatusCode.Conflict,
+ detail: {
+ code: TalerErrorCode.MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS,
+ detail: base,
+ },
+ });
+ }
+ let id =
+ this.pending?.id ??
+ (idMode === "automatic" ? availableTemplateId(base, occupied) : base);
+
+ for (let attempt = 0; ; attempt++) {
+ this.pending = { requestKey, id };
+ try {
+ unwrap(await client.addTemplate(token, { ...body, template_id: id }));
+ } catch (error) {
+ const failure = normalizeApiFailure(error);
+ if (isTemplateIdConflict(failure)) {
+ this.pending = undefined;
+ if (idMode === "automatic" && attempt < 9) {
+ occupied.add(id);
+ id = availableTemplateId(base, occupied);
+ continue;
+ }
+ } else if (
+ failure.outcome === "rejected" &&
+ (failure.httpStatus ?? 0) < 500
+ ) {
+ this.pending = undefined;
+ }
+ // An uncertain outcome must keep the exact attempted ID. Choosing a
+ // fresh suffix on the next submission could create a second template.
+ throw error;
+ }
+ this.pending = undefined;
+ try {
+ await refresh();
+ } catch {
+ // Creation has succeeded; a failed list refresh must not offer a retry.
+ }
+ return id;
+ }
+ }
+}
diff --git a/packages/taler-merchant-webui/src/utils/templates.ts b/packages/taler-merchant-webui/src/utils/templates.ts
@@ -19,6 +19,27 @@ import type { HostPortPath, TalerMerchantApi } from "@gnu-taler/taler-util";
import type { TranslateFn } from "../context/translation.js";
import type { DurationValue } from "../ui/DurationInput.js";
+/** Keep the spelling of existing name-derived template IDs. */
+export function templateIdFromName(name: string): string {
+ const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "");
+ return `tmpl_${slug || "tmpl_new"}`;
+}
+
+/** Numeric endings in the base belong to the name, not to this suffix. */
+export function availableTemplateId(
+ base: string,
+ occupied: ReadonlySet<string>,
+): string {
+ let candidate = base;
+ for (let suffix = 1; occupied.has(candidate); suffix++) {
+ candidate = `${base}_${suffix}`;
+ }
+ return candidate;
+}
+
export interface TemplateSellingOption {
type: "fixed" | "custom_amount" | "catalogue";
title: string;