commit d27e6cbaeb5c23abd58165dbf594863f6d3d3749
parent 15ec20dde06ff7df065e29e78d3e6bfa724a6b61
Author: Florian Dold <dold@taler.net>
Date: Wed, 4 Nov 2026 00:16:47 +0100
merchant API: add inventory fields and unit endpoints
Diffstat:
3 files changed, 489 insertions(+), 8 deletions(-)
diff --git a/packages/taler-util/src/http-client/merchant-inventory.test.ts b/packages/taler-util/src/http-client/merchant-inventory.test.ts
@@ -0,0 +1,223 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024, 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 { FakeHttpLib, noContent, ok, talerError } from "../http-fake.js";
+import { TalerMerchantInstanceHttpClient } from "./merchant.js";
+import {
+ codecForProductDetailResponse,
+ codecForMerchantPosProductDetail,
+ type ProductAddDetailRequest,
+} from "../types-taler-merchant.js";
+import { TalerErrorCode } from "../taler-error-codes.js";
+import type { AccessToken } from "../types-taler-common.js";
+const token = "secret-token:test" as AccessToken;
+const unit = {
+ unit_serial: 1,
+ unit: "bag",
+ unit_name_long: "Bag",
+ unit_name_short: "bag",
+ unit_name_long_i18n: { de: "Beutel" },
+ unit_allow_fraction: false,
+ unit_precision_level: 0,
+ unit_active: true,
+ unit_builtin: false,
+};
+const product = {
+ description: "Tea",
+ description_i18n: {},
+ unit: "Piece",
+ price: "EUR:2",
+ image: "",
+ categories: [],
+ total_stock: 10,
+ total_sold: 2,
+ total_lost: 1,
+};
+
+test("inventory codecs accept legacy responses and retain new fields", () => {
+ assert.equal(
+ codecForProductDetailResponse().decode(product).unit_price,
+ undefined,
+ );
+ const modern = {
+ ...product,
+ unit_price: ["EUR:2", "CHF:3"],
+ unit_total_stock: "10.125",
+ unit_allow_fraction: true,
+ unit_precision_level: 3,
+ price_is_net: false,
+ };
+ assert.equal(
+ codecForProductDetailResponse().decode(modern).unit_total_stock,
+ "10.125",
+ );
+ assert.deepEqual(
+ codecForProductDetailResponse().decode(modern).unit_price,
+ modern.unit_price,
+ );
+ assert.equal(
+ codecForMerchantPosProductDetail().decode({
+ ...modern,
+ product_id: "tea",
+ product_serial: 1,
+ }).unit_precision_level,
+ 3,
+ );
+ assert.throws(() =>
+ codecForProductDetailResponse().decode({
+ ...modern,
+ unit_total_stock: 10.125,
+ }),
+ );
+});
+
+test("unit client supports private and public reads and encoded unit identifiers", async () => {
+ const http = new FakeHttpLib()
+ .on("GET", "/private/units", ok({ units: [unit] }))
+ .on("GET", "/private/units/bag%2Fbox", ok(unit))
+ .on("GET", "/units", ok({ units: [unit] }));
+ const client = new TalerMerchantInstanceHttpClient(
+ "https://merchant.test/instances/shop/",
+ http,
+ );
+ const listed = await client.listUnits(token);
+ assert.equal(listed.type, "ok");
+ if (listed.type === "ok")
+ assert.deepEqual(listed.body.units, [
+ { ...unit, unit_name_short_i18n: undefined },
+ ]);
+ assert.equal(
+ http.lastRequest?.headers?.Authorization,
+ "Bearer secret-token:test",
+ );
+ assert.equal((await client.getUnitDetails(token, "bag/box")).type, "ok");
+ assert.equal((await client.getPublicUnits()).type, "ok");
+ assert.equal(http.lastRequest?.headers?.Authorization, undefined);
+ assert.equal(
+ new URL(http.lastRequest!.url).pathname,
+ "/instances/shop/units",
+ );
+});
+
+test("unit mutations send bodies and report built-in/unknown unit errors", async () => {
+ const http = new FakeHttpLib()
+ .on("POST", "/private/units", noContent())
+ .on("PATCH", "/private/units/bag", noContent())
+ .on("DELETE", "/private/units/bag", noContent())
+ .on("DELETE", "/private/units/Piece", {
+ status: 409,
+ body: talerError(TalerErrorCode.MERCHANT_GENERIC_UNIT_BUILTIN),
+ })
+ .on("GET", "/private/units/missing", {
+ status: 404,
+ body: talerError(TalerErrorCode.MERCHANT_GENERIC_UNIT_UNKNOWN),
+ });
+ const client = new TalerMerchantInstanceHttpClient(
+ "https://merchant.test/",
+ http,
+ );
+ assert.equal((await client.addUnit(token, unit)).type, "ok");
+ assert.deepEqual(http.lastRequest?.body, unit);
+ assert.equal(
+ (await client.updateUnit(token, "bag", { unit_active: false })).type,
+ "ok",
+ );
+ assert.deepEqual(http.lastRequest?.body, { unit_active: false });
+ assert.equal((await client.deleteUnit(token, "bag")).type, "ok");
+ assert.equal((await client.deleteUnit(token, "Piece")).type, "fail");
+ assert.equal((await client.getUnitDetails(token, "missing")).type, "fail");
+});
+
+test("inventory client supports modern creation, decimal locks, filters and public images", async () => {
+ const http = new FakeHttpLib()
+ .on("POST", "/private/products", noContent())
+ .on("POST", "/private/products/tea/lock", noContent())
+ .on("GET", "/private/products", ok({ products: [] }))
+ .on(
+ "GET",
+ "/products/abc/image",
+ ok({ image: "data:image/png;base64,abc" }),
+ );
+ const client = new TalerMerchantInstanceHttpClient(
+ "https://merchant.test/instances/shop/",
+ http,
+ );
+ const body: ProductAddDetailRequest = {
+ product_id: "tea",
+ description: "Tea",
+ unit: "WeightUnitKg",
+ unit_price: ["EUR:3"],
+ unit_total_stock: "2.5",
+ };
+ assert.equal((await client.addProduct(token, body)).type, "ok");
+ assert.deepEqual(http.lastRequest?.body, body);
+ await client.lockProduct(token, "tea", {
+ lock_uuid: "test",
+ duration: { d_us: 1000000 },
+ unit_quantity: "0.5",
+ });
+ await client.listProducts(token, {
+ limit: 100,
+ order: "asc",
+ offset: "7",
+ groupId: 2,
+ });
+ const url = new URL(http.lastRequest!.url);
+ assert.equal(url.searchParams.get("product_group_filter"), "2");
+ assert.equal(url.searchParams.get("offset"), "7");
+ assert.equal(url.searchParams.get("limit"), "100");
+ const image = await client.getProductImage("abc");
+ assert.equal(image.type, "ok");
+ assert.equal(new URL(http.lastRequest!.url).pathname, "/products/abc/image");
+ assert.equal(http.lastRequest?.headers?.Authorization, undefined);
+});
+
+for (const [baseUrl, expected] of [
+ ["https://merchant.test/", "https://merchant.test/products/abc/image"],
+ [
+ "https://merchant.test/merchant/",
+ "https://merchant.test/merchant/products/abc/image",
+ ],
+ [
+ "https://merchant.test/merchant",
+ "https://merchant.test/merchant/products/abc/image",
+ ],
+ [
+ "https://merchant.test/instances/shop/",
+ "https://merchant.test/products/abc/image",
+ ],
+ [
+ "https://merchant.test/merchant/instances/shop/",
+ "https://merchant.test/merchant/products/abc/image",
+ ],
+ [
+ "https://merchant.test/proxy/merchant/instances/a%2Fb/",
+ "https://merchant.test/proxy/merchant/products/abc/image",
+ ],
+])
+ test(`product image preserves the deployment prefix of ${baseUrl}`, async () => {
+ const http = new FakeHttpLib().on(
+ "GET",
+ new URL(expected!).pathname,
+ ok({ image: "data:image/png;base64,abc" }),
+ );
+ const client = new TalerMerchantInstanceHttpClient(baseUrl!, http);
+ assert.equal((await client.getProductImage("abc")).type, "ok");
+ assert.equal(http.lastRequest?.url, expected);
+ assert.equal(http.lastRequest?.headers?.Authorization, undefined);
+ });
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -70,6 +70,9 @@ import {
codecForPotDetailResponse,
codecForPotsSummaryResponse,
codecForProductDetailResponse,
+ codecForMerchantUnit,
+ codecForUnitListResponse,
+ codecForProductImageResponse,
codecForQueryInstancesResponse,
codecForReportAddedResponse,
codecForReportDetailResponse,
@@ -159,6 +162,7 @@ export enum TalerMerchantInstanceCacheEviction {
CREATE_GROUPS,
UPDATE_GROUPS,
DELETE_GROUPS,
+ UPDATE_UNIT,
LAST,
}
@@ -1618,6 +1622,158 @@ export class TalerMerchantInstanceHttpClient {
}
}
+ async listUnits(token: AccessToken) {
+ const url = new URL(`private/units`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: { Authorization: makeBearerTokenAuthHeader(token) },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForUnitListResponse());
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ async getPublicUnits() {
+ const url = new URL(`units`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {},
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForUnitListResponse());
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ async getUnitDetails(token: AccessToken, unit: string) {
+ const url = new URL(`private/units/${pathSegment(unit)}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: { Authorization: makeBearerTokenAuthHeader(token) },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForMerchantUnit());
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ async getProductImage(imageHash: string) {
+ // Images are shared across instances, but remain beneath the deployment
+ // prefix when the backend is served through a reverse proxy.
+ const root = new URL(this.baseUrl);
+ root.pathname = root.pathname.replace(/\/instances\/[^/]+\/?$/, "/");
+ if (!root.pathname.endsWith("/")) root.pathname += "/";
+ const url = new URL(`products/${pathSegment(imageHash)}/image`, root);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "GET",
+ headers: {},
+ });
+ switch (resp.status) {
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForProductImageResponse());
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ async addUnit(token: AccessToken, body: TalerMerchantApi.UnitAddRequest) {
+ const url = new URL(`private/units`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "POST",
+ headers: { Authorization: makeBearerTokenAuthHeader(token) },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_UNIT,
+ );
+ return opEmptySuccess(resp);
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.PayloadTooLarge:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ async updateUnit(
+ token: AccessToken,
+ unit: string,
+ body: TalerMerchantApi.UnitPatchRequest,
+ ) {
+ const url = new URL(`private/units/${pathSegment(unit)}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "PATCH",
+ headers: { Authorization: makeBearerTokenAuthHeader(token) },
+ body,
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_UNIT,
+ );
+ return opEmptySuccess(resp);
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.PayloadTooLarge:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
+ async deleteUnit(token: AccessToken, unit: string) {
+ const url = new URL(`private/units/${pathSegment(unit)}`, this.baseUrl);
+ const resp = await this.httpLib.fetch(url.href, {
+ method: "DELETE",
+ headers: { Authorization: makeBearerTokenAuthHeader(token) },
+ });
+ switch (resp.status) {
+ case HttpStatusCode.NoContent:
+ this.cacheEvictor.notifySuccess(
+ TalerMerchantInstanceCacheEviction.UPDATE_UNIT,
+ );
+ return opEmptySuccess(resp);
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ case HttpStatusCode.PayloadTooLarge:
+ return opKnownHttpFailure(resp.status, resp);
+ default:
+ return opUnknownHttpFailure(resp);
+ }
+ }
+
/**
* https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-products
*/
@@ -1643,7 +1799,7 @@ export class TalerMerchantInstanceHttpClient {
url.searchParams.set("description_filter", params.description);
}
if (params.groupId !== undefined) {
- url.searchParams.set("product_group_serial", String(params.groupId));
+ url.searchParams.set("product_group_filter", String(params.groupId));
}
const headers: Record<string, string> = {};
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -2398,6 +2398,7 @@ export interface CategoryCreateRequest {
}
export interface ProductAddDetailRequest {
+ // Supply price or unit_price, and total_stock or unit_total_stock.
// Product ID to use.
product_id: string;
@@ -2424,7 +2425,7 @@ export interface ProductAddDetailRequest {
// Legacy price field.
// Deprecated since **v25**;
// when present it must match the first element of unit_price.
- price: AmountString;
+ price?: AmountString;
// An optional base64-encoded product image.
image?: ImageDataUrl;
@@ -2436,7 +2437,7 @@ export interface ProductAddDetailRequest {
// including all existing sales ever. Given in product-specific
// units.
// A value of -1 indicates "infinite" (i.e. for "electronic" books).
- total_stock: Integer;
+ total_stock?: Integer;
// Preferred way to express the per-unit price of the product. Supply at least one entry;
// the first entry must match price.
@@ -2469,6 +2470,12 @@ export interface ProductAddDetailRequest {
// in money pot of the overall order or not at all).
// Since **v25**.
money_pot_id?: Integer;
+ // Decimal stock and per-product unit policy, since v25.
+ unit_allow_fraction?: boolean;
+ unit_precision_level?: Integer;
+ unit_total_stock?: DecimalQuantity;
+ // Upcoming vTAXES field; preserve when supplied by the backend.
+ price_is_net?: boolean;
}
export interface ProductPatchDetailRequest {
@@ -2511,9 +2518,6 @@ export interface ProductPatchDetailRequest {
// A value of -1 indicates "infinite" (i.e. for "electronic" books).
total_stock?: Integer;
- // Number of units of the product that have already been sold.
- total_sold?: Integer;
-
// Number of units of the product that were lost (spoiled, stolen, etc.).
total_lost?: Integer;
@@ -2536,6 +2540,13 @@ export interface ProductPatchDetailRequest {
// in money pot of the overall order or not at all).
// Since **v25**.
money_pot_id?: Integer;
+ // Decimal stock and per-product unit policy, since v25.
+ unit_allow_fraction?: boolean;
+ unit_precision_level?: Integer;
+ unit_total_stock?: DecimalQuantity;
+ // Upcoming vTAXES field; preserve when supplied by the backend.
+ price_is_net?: boolean;
+ unit_price?: AmountString[];
}
export interface InventorySummaryResponse {
@@ -2603,6 +2614,13 @@ export interface MerchantPosProductDetail {
// Minimum age buyer must have (in years).
minimum_age?: Integer;
+ // Since v25. Optional on responses for compatibility with older backends.
+ unit_allow_fraction?: boolean;
+ unit_precision_level?: Integer;
+ unit_total_stock?: DecimalQuantity;
+ // Upcoming vTAXES field; preserve when supplied by the backend.
+ price_is_net?: boolean;
+ unit_price?: AmountString[];
}
export interface MerchantCategory {
@@ -2677,6 +2695,13 @@ export interface ProductDetailResponse {
// in money pot of the overall order or not at all).
// Since **v25**.
money_pot_id?: Integer;
+ // Since v25. Optional on responses for compatibility with older backends.
+ unit_allow_fraction?: boolean;
+ unit_precision_level?: Integer;
+ unit_total_stock?: DecimalQuantity;
+ // Upcoming vTAXES field; preserve when supplied by the backend.
+ price_is_net?: boolean;
+ unit_price?: AmountString[];
}
export interface LockRequest {
// UUID that identifies the frontend performing the lock
@@ -2687,7 +2712,10 @@ export interface LockRequest {
duration: RelativeTime;
// How many units should be locked?
- quantity: Integer;
+ quantity?: Integer;
+
+ // Preferred decimal quantity, since v25.
+ unit_quantity?: DecimalQuantity;
}
export interface PostOrderRequest {
@@ -2738,7 +2766,10 @@ export interface MinimalInventoryProduct {
product_id: string;
// How many units of the product are requested.
- quantity: Integer;
+ quantity?: Integer;
+
+ // Preferred decimal quantity, since v25.
+ unit_quantity?: DecimalQuantity;
// Money pot to use for this product, overrides value from
// the inventory if given.
@@ -4785,6 +4816,14 @@ export const codecForMerchantPosProductDetail =
.property("taxes", codecOptional(codecForList(codecForTax())))
.property("total_stock", codecOptional(codecForNumber()))
.property("minimum_age", codecOptional(codecForNumber()))
+ .property("unit_allow_fraction", codecOptional(codecForBoolean()))
+ .property("unit_precision_level", codecOptional(codecForNumber()))
+ .property("unit_total_stock", codecOptional(codecForString()))
+ .property(
+ "unit_price",
+ codecOptional(codecForList(codecForAmountString())),
+ )
+ .property("price_is_net", codecOptional(codecForBoolean()))
.build("TalerMerchantApi.MerchantPosProductDetail");
export const codecForMerchantCategory = (): Codec<MerchantCategory> =>
@@ -4819,6 +4858,11 @@ export const codecForProductDetailResponse = (): Codec<ProductDetailResponse> =>
.property("minimum_age", codecOptional(codecForNumber()))
.property("money_pot_id", codecOptional(codecForNumber()))
.property("product_group_id", codecOptional(codecForNumber()))
+ .property("unit_allow_fraction", codecOptional(codecForBoolean()))
+ .property("unit_precision_level", codecOptional(codecForNumber()))
+ .property("unit_total_stock", codecOptional(codecForString()))
+ .property("unit_price", codecOptional(codecForList(codecForAmountString())))
+ .property("price_is_net", codecOptional(codecForBoolean()))
.build("TalerMerchantApi.ProductDetailResponse");
export const codecForTax = (): Codec<Tax> =>
@@ -5866,3 +5910,61 @@ export const codecForWireTransferInstructionDetail =
.property("subject", codecForTransferSubject())
.property("expiration", codecForTimestamp)
.build("WireTransferInstructionDetail");
+
+/** Measurement-unit catalogue, since protocol v25. */
+export interface MerchantUnit {
+ unit_serial: Integer;
+ unit: string;
+ unit_name_long: string;
+ unit_name_short: string;
+ unit_name_long_i18n?: InternationalizedString;
+ unit_name_short_i18n?: InternationalizedString;
+ unit_allow_fraction: boolean;
+ unit_precision_level: Integer;
+ unit_active: boolean;
+ unit_builtin: boolean;
+}
+export interface UnitListResponse {
+ units: MerchantUnit[];
+}
+export interface UnitAddRequest {
+ unit: string;
+ unit_name_long: string;
+ unit_name_short: string;
+ unit_name_long_i18n?: InternationalizedString;
+ unit_name_short_i18n?: InternationalizedString;
+ unit_allow_fraction?: boolean;
+ unit_precision_level?: Integer;
+ unit_active?: boolean;
+}
+export type UnitPatchRequest = Partial<Omit<UnitAddRequest, "unit">>;
+export interface ProductImageResponse {
+ image: ImageDataUrl;
+}
+export const codecForMerchantUnit = (): Codec<MerchantUnit> =>
+ buildCodecForObject<MerchantUnit>()
+ .property("unit_serial", codecForNumber())
+ .property("unit", codecForString())
+ .property("unit_name_long", codecForString())
+ .property("unit_name_short", codecForString())
+ .property(
+ "unit_name_long_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .property(
+ "unit_name_short_i18n",
+ codecOptional(codecForInternationalizedString()),
+ )
+ .property("unit_allow_fraction", codecForBoolean())
+ .property("unit_precision_level", codecForNumber())
+ .property("unit_active", codecForBoolean())
+ .property("unit_builtin", codecForBoolean())
+ .build("MerchantUnit");
+export const codecForUnitListResponse = (): Codec<UnitListResponse> =>
+ buildCodecForObject<UnitListResponse>()
+ .property("units", codecForList(codecForMerchantUnit()))
+ .build("UnitListResponse");
+export const codecForProductImageResponse = (): Codec<ProductImageResponse> =>
+ buildCodecForObject<ProductImageResponse>()
+ .property("image", codecForString())
+ .build("ProductImageResponse");