taler-typescript-core

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

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:
Apackages/taler-util/src/http-client/merchant-inventory.test.ts | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 158++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/types-taler-merchant.ts | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
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");