taler-typescript-core

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

commit 86a836eebeb95f230925185b794b2e0ad988af74
parent 232c449c5686799b56df7363d8276384be523402
Author: Florian Dold <dold@taler.net>
Date:   Wed, 19 Aug 2026 18:16:03 +0200

taler-util: complete merchant management APIs

Diffstat:
Apackages/taler-util/src/http-client/merchant-management.test.ts | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 54+++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-util/src/types-taler-merchant.test.ts | 22++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 25+++++++++++++++++++++++++
4 files changed, 240 insertions(+), 11 deletions(-)

diff --git a/packages/taler-util/src/http-client/merchant-management.test.ts b/packages/taler-util/src/http-client/merchant-management.test.ts @@ -0,0 +1,150 @@ +/* + 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. + */ + +import assert from "node:assert"; +import { test } from "node:test"; +import { HttpStatusCode } from "../http-status-codes.js"; +import { FakeHttpLib, noContent, ok } from "../http-fake.js"; +import { TalerMerchantManagementHttpClient } from "./merchant.js"; +import type { AccessToken } from "../types-taler-common.js"; +import { MerchantAuthMethod } from "../types-taler-merchant.js"; + +const token = "secret-token:admin" as AccessToken; +const baseUrl = "https://merchant.example.com/"; + +const instance = { + id: "shop", + name: "Example Shop", + merchant_pub: "M".repeat(52), + payment_targets: ["iban"], + deleted: false, +}; + +const configuration = { + id: "shop", + name: "Example Shop", + auth: { method: MerchantAuthMethod.TOKEN, password: "correct horse" }, + address: {}, + jurisdiction: {}, + use_stefan: true, +}; + +test("management client lists instances and decodes complete details", async () => { + const http = new FakeHttpLib() + .on("GET", "/management/instances", ok({ instances: [instance] })) + .on("GET", "/management/instances/shop", ok({ + name: instance.name, + merchant_pub: instance.merchant_pub, + address: {}, + jurisdiction: {}, + use_stefan: true, + default_pay_delay: { d_us: 1 }, + default_refund_delay: { d_us: 2 }, + default_wire_transfer_delay: { d_us: 3 }, + accounts: [{ + payto_uri: "payto://iban/DE89370400440532013000", + h_wire: "H".repeat(52), + salt: "S".repeat(52), + active: true, + }], + auth: { method: "token" }, + })); + const client = new TalerMerchantManagementHttpClient(baseUrl, http); + + const listed = await client.listInstances(token); + assert.strictEqual(listed.type, "ok"); + assert.strictEqual(listed.body.instances[0]?.id, "shop"); + const details = await client.getInstanceDetails(token, "shop"); + assert.strictEqual(details.type, "ok"); + assert.strictEqual(details.body.accounts[0]?.active, true); + assert.strictEqual(http.lastRequest?.headers?.Authorization, "Bearer secret-token:admin"); +}); + +test("management writes support login-token and challenge responses", async () => { + const challenge = { + challenges: [{ challenge_id: "c1", tan_channel: "email", tan_info: "a***@example.com" }], + combi_and: false, + }; + const http = new FakeHttpLib() + .on("POST", "/management/instances", ok({ + access_token: "secret-token:shop", + scope: "all", + expiration: { t_s: 10 }, + refreshable: false, + })) + .on("PATCH", "/management/instances/shop", { + status: HttpStatusCode.Accepted, + body: challenge, + }) + .on("POST", "/management/instances/shop/auth", noContent()); + const client = new TalerMerchantManagementHttpClient(baseUrl, http); + + const created = await client.createInstance(token, configuration); + assert.strictEqual(created.type, "ok"); + assert.strictEqual(created.body?.access_token, "secret-token:shop"); + + const updated = await client.updateInstance(token, "shop", configuration, { + challengeIds: ["c1", "c2"], + }); + assert.strictEqual(updated.type, "fail"); + assert.strictEqual(updated.case, HttpStatusCode.Accepted); + assert.deepStrictEqual(updated.body, challenge); + assert.strictEqual(http.lastRequest?.headers?.["Taler-Challenge-Ids"], "c1, c2"); + + const auth = await client.updateInstanceAuthentication( + token, + "shop", + { method: MerchantAuthMethod.TOKEN, password: "new password" }, + { challengeIds: ["c3"] }, + ); + assert.strictEqual(auth.type, "ok"); + assert.strictEqual(http.lastRequest?.headers?.["Taler-Challenge-Ids"], "c3"); +}); + +test("management deletion and KYC use the documented wire format", async () => { + const http = new FakeHttpLib() + .on("DELETE", "/management/instances/shop", noContent()) + .on("GET", "/management/instances/shop/kyc", ok({ + kyc_data: [{ + status: "ready", + h_wire: "H".repeat(52), + payto_uri: "payto://iban/DE89370400440532013000", + exchange_url: "https://exchange.example.com/", + exchange_currency: "EUR", + exchange_http_status: 200, + no_keys: false, + auth_conflict: false, + }], + })); + const client = new TalerMerchantManagementHttpClient(baseUrl, http); + + const removed = await client.deleteInstance(token, "shop", { purge: true }); + assert.strictEqual(removed.type, "ok"); + assert.strictEqual(new URL(http.lastRequest!.url).searchParams.get("purge"), "YES"); + + const kyc = await client.getInstanceKycStatus(token, "shop", { + wireHash: "H".repeat(52), + exchangeURL: "https://exchange.example.com/", + }); + assert.strictEqual(kyc.type, "ok"); + assert.strictEqual(kyc.body?.kyc_data[0]?.status, "ready"); + const url = new URL(http.lastRequest!.url); + assert.strictEqual(url.searchParams.get("h_wire"), "H".repeat(52)); + assert.strictEqual(url.searchParams.get("exchange_url"), "https://exchange.example.com/"); +}); + +test("management KYC accepts an empty no-content response", async () => { + const http = new FakeHttpLib() + .on("GET", "/management/instances/shop/kyc", noContent()); + const client = new TalerMerchantManagementHttpClient(baseUrl, http); + + const kyc = await client.getInstanceKycStatus(token, "shop"); + assert.strictEqual(kyc.type, "ok"); + assert.strictEqual(kyc.body, undefined); +}); diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -1010,7 +1010,6 @@ export class TalerMerchantInstanceHttpClient { params: TalerMerchantApi.GetKycStatusRequestParams = {}, ): Promise< | OperationFail<HttpStatusCode.NotFound> - | OperationFail<HttpStatusCode.NoContent> | OperationOk<{ kyc_data: TalerMerchantApi.MerchantAccountKycRedirect[]; etag: string | undefined; @@ -1077,9 +1076,7 @@ export class TalerMerchantInstanceHttpClient { return opFixedSuccess(resp, { etag, ...f.body }); } case HttpStatusCode.NoContent: - // FIXME: using opKnownHttpFailure is wrong here - // we expect to read a body with the error description - return opKnownFailure(resp, resp.status); + return opFixedSuccess(resp, { kyc_data: [], etag }); case HttpStatusCode.NotModified: return opKnownFailureWithBody(resp, resp.status, { etag }); case HttpStatusCode.Unauthorized: // FIXME: missing in docs @@ -1212,6 +1209,10 @@ export class TalerMerchantInstanceHttpClient { switch (resp.status) { case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForAccountsSummaryResponse()); + // Deployed merchant backends historically used both spellings for an + // empty collection. A 204 is an empty list, not a failed read. + case HttpStatusCode.NoContent: + return opFixedSuccess(resp, { accounts: [] }); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: @@ -3909,6 +3910,12 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp }); switch (resp.status) { + case HttpStatusCode.Ok: { + this.cacheManagementEvictor.notifySuccess( + TalerMerchantManagementCacheEviction.CREATE_INSTANCE, + ); + return opSuccessFromHttp(resp, codecForLoginTokenSuccessResponse()); + } case HttpStatusCode.NoContent: { this.cacheManagementEvictor.notifySuccess( TalerMerchantManagementCacheEviction.CREATE_INSTANCE, @@ -3923,6 +3930,9 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp ); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.PayloadTooLarge: case HttpStatusCode.Conflict: return opKnownHttpFailure(resp.status, resp); default: @@ -3970,6 +3980,8 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp return opEmptySuccess(resp); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Forbidden: + case HttpStatusCode.PayloadTooLarge: case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); default: @@ -3984,6 +3996,7 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp token: AccessToken, instanceId: string, body: TalerMerchantApi.InstanceReconfigurationMessage, + params: { challengeIds?: string[] } = {}, ) { const url = new URL(`management/instances/${pathSegment(instanceId)}`, this.baseUrl); @@ -3991,6 +4004,9 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp if (token) { headers.Authorization = makeBearerTokenAuthHeader(token); } + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "PATCH", body, @@ -4003,9 +4019,17 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp ); return opEmptySuccess(resp); } + case HttpStatusCode.Accepted: + return opKnownAlternativeHttpFailure( + resp, + resp.status, + codecForChallengeResponse(), + ); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.BadRequest: case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); @@ -4118,10 +4142,10 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp /** * https://docs.taler.net/core/api-merchant.html#get--management-instances-$INSTANCE-kyc */ - async getIntanceKycStatus( + async getInstanceKycStatus( token: AccessToken, instanceId: string, - params: TalerMerchantApi.GetKycStatusRequestParams, + params: TalerMerchantApi.GetKycStatusRequestParams = {}, ) { const url = new URL(`management/instances/${pathSegment(instanceId)}/kyc`, this.baseUrl); @@ -4141,18 +4165,17 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp headers, }); switch (resp.status) { - case HttpStatusCode.Accepted: + case HttpStatusCode.Ok: return opSuccessFromHttp(resp, codecForAccountKycRedirects()); case HttpStatusCode.NoContent: return opEmptySuccess(resp); case HttpStatusCode.NotFound: - return opEmptySuccess(resp); + case HttpStatusCode.BadRequest: case HttpStatusCode.Unauthorized: // FIXME: missing in docs - return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.NotAcceptable: + case HttpStatusCode.InternalServerError: case HttpStatusCode.BadGateway: - return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.ServiceUnavailable: - return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Conflict: return opKnownHttpFailure(resp.status, resp); default: @@ -4160,6 +4183,15 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp } } + /** @deprecated Use getInstanceKycStatus(). */ + async getIntanceKycStatus( + token: AccessToken, + instanceId: string, + params: TalerMerchantApi.GetKycStatusRequestParams = {}, + ) { + return this.getInstanceKycStatus(token, instanceId, params); + } + /** * https://docs.taler.net/core/api-merchant.html#get--management-instances-$INSTANCE-statistics-counter-$SLUG */ diff --git a/packages/taler-util/src/types-taler-merchant.test.ts b/packages/taler-util/src/types-taler-merchant.test.ts @@ -32,10 +32,32 @@ function instancesResponse(method: string): any { default_wire_transfer_delay: { d_us: 1000 }, default_pay_delay: { d_us: 1000 }, default_refund_delay: { d_us: 1000 }, + accounts: [], auth: { method }, }; } +test("management instance details decode bank accounts", () => { + const response = instancesResponse("token"); + response.accounts = [ + { + payto_uri: "payto://iban/DE89370400440532013000", + credit_facade_url: "https://bank.example.com/facade/", + h_wire: "H".repeat(52), + salt: "S".repeat(52), + active: true, + }, + ]; + + const decoded = codecForQueryInstancesResponse().decode(response); + assert.strictEqual(decoded.accounts.length, 1); + assert.strictEqual(decoded.accounts[0]?.active, true); + assert.strictEqual( + decoded.accounts[0]?.credit_facade_url, + "https://bank.example.com/facade/", + ); +}); + test("an instance without an auth token decodes", (t) => { // The backend reports "external" whenever the instance's auth hash is // unset, which is every deployment that authenticates at an API gateway. diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -2095,6 +2095,11 @@ export interface QueryInstancesResponse { // @since **v23** default_wire_transfer_rounding_interval?: RoundingInterval; + // Bank accounts configured for this merchant instance. Management reads + // include the salt because an administrator needs the complete instance + // configuration, not only the public account summary. + accounts: ManagementAccountEntry[]; + // Authentication configuration. // Does not contain the token when token auth is configured. auth: { @@ -2102,6 +2107,14 @@ export interface QueryInstancesResponse { }; } +export interface ManagementAccountEntry { + payto_uri: PaytoString; + credit_facade_url?: string; + h_wire: HashCode; + salt: HashCode; + active: boolean; +} + // Type of authentication. // "external": The mechant backend does not do // any authentication checks. Instead an API @@ -4777,6 +4790,18 @@ export const codecForQueryInstancesResponse = codecOptional(codecForRoundingInterval), ) .property( + "accounts", + codecForList( + buildCodecForObject<ManagementAccountEntry>() + .property("payto_uri", codecForPaytoString()) + .property("credit_facade_url", codecOptional(codecForURLString())) + .property("h_wire", codecForString()) + .property("salt", codecForString()) + .property("active", codecForBoolean()) + .build("TalerMerchantApi.ManagementAccountEntry"), + ), + ) + .property( "auth", buildCodecForObject<{ method: MerchantAuthMethod;