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:
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;