taler-typescript-core

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

commit 407e080c9c7de7008ec96c0970be378fff5bf86b
parent ac60a4467b5eaa8f8c431ee9c32b29a4bb290db2
Author: Florian Dold <florian@dold.me>
Date:   Fri, 26 Jun 2026 02:12:48 +0200

harness: test for merchant /kycauth endpoint in TOPS-style environment

Diffstat:
Dpackages/taler-harness/src/integrationtests/test-tops-merchant-kycauths.ts | 165-------------------------------------------------------------------------------
Apackages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 4++--
Mpackages/taler-util/src/http-client/merchant.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/types-taler-merchant.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 277 insertions(+), 167 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-tops-merchant-kycauths.ts b/packages/taler-harness/src/integrationtests/test-tops-merchant-kycauths.ts @@ -1,165 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2025 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/> - */ - -/** - * Imports. - */ -import { - IbanString, - j2s, - Logger, - MerchantAccountKycStatus, - Paytos, - TalerMerchantInstanceHttpClient, - TalerProtocolDuration, -} from "@gnu-taler/taler-util"; -import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; -import { startFakeChallenger } from "../harness/fake-challenger.js"; -import { - ExchangeService, - GlobalTestState, - LibeufinNexusService, - MerchantService, - NexusBankAccountInfo, - setupDb, -} from "../harness/harness.js"; -import { topsKycRulesConf, topsProvidersTestConf } from "../harness/tops.js"; - -const logger = new Logger("test-tops-merchant-kycauths.ts"); - -/** - * Test short wire transfers in an exchange setup that simulates - * the Taler Operations CH exchange, using libeufin-nexus - * as the taler wire gateway API. - */ -export async function runTopsMerchantKycauthsTest(t: GlobalTestState) { - const db = await setupDb(t); - - const challenger = await startFakeChallenger({ - port: 6001, - addressType: "postal-ch", - }); - - let coinConfig: CoinConfig[]; - coinConfig = defaultCoinConfig.map((x) => x("CHF")); - - const bankAccountInfo: NexusBankAccountInfo = { - // Random IBAN generated via "libeufin-nexus testing iban gen --country CH" - iban: "CH7347363QVFHHFR8BWWB", - // PostFinance BIC - bic: "POFICHBEXXX", - name: "Harness Test Exchange", - qrIban: "CH1130000001166556117", - }; - - const nexus = await LibeufinNexusService.create(t, { - currency: "CHF", - database: db.connStr, - httpPort: 8085, - bankAccountInfo, - }); - - const exchangePayto = Paytos.toFullString( - Paytos.createIban(bankAccountInfo.qrIban as IbanString, undefined, { - "receiver-name": bankAccountInfo.name, - }), - ); - - await nexus.dbinit(); - - await nexus.start(); - - const exchange = ExchangeService.create(t, { - name: "testexchange-1", - currency: "CHF", - httpPort: 8081, - database: db.connStr, - // FIXME: This is a terrible way to configure the exchange, should be moved into config. - extraProcEnv: { - EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "v1", - EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_THRESHOLD: "CHF:0", - EXCHANGE_AML_PROGRAM_TOPS_POSTAL_CHECK_COUNTRY_REGEX: "ch|CH|Ch", - }, - }); - - exchange.addBankAccount("nexusacct", { - accountPaytoUri: exchangePayto, - wireGatewayApiBaseUrl: nexus.wireGatewayApiBaseUrl, - wireGatewayAuth: { - type: "basic", - username: "exchange-test", - password: "exchange-test", - }, - preparedTransferUrl: nexus.preparedTransferApiBaseUrl, - }); - - exchange.addCoinConfigList(coinConfig); - - await exchange.modifyConfig(async (config) => { - config.loadFromString(topsKycRulesConf); - config.loadFromString(topsProvidersTestConf); - }); - - await exchange.start(); - - const merchantIban = "CH4810303KL83V1QZ05T7"; - - const merchant = await MerchantService.create(t, { - name: "testmerchant-1", - httpPort: 8083, - database: db.connStr, - }); - - merchant.addExchange(exchange); - - await merchant.start(); - - const merchantPayto = Paytos.toFullString( - Paytos.createIban(merchantIban as IbanString, undefined, { - "receiver-name": "Merchant Test", - }), - ); - - const { accessToken: merchantAdminAccessToken } = - await merchant.addInstanceWithWireAccount({ - id: "admin", - name: "Default Instance", - paytoUris: [merchantPayto], - defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }), - }); - - const merchantClient = new TalerMerchantInstanceHttpClient( - merchant.makeInstanceBaseUrl(), - ); - // Do KYC auth transfer - - const kycStatus = await merchantClient.getCurrentInstanceKycStatus( - merchantAdminAccessToken, - { - longpoll: { - type: "state-enter", - status: MerchantAccountKycStatus.KYC_WIRE_REQUIRED, - timeout: 10000, - }, - }, - ); - t.assertTrue(kycStatus.type === "ok"); - console.log(`kyc status: ${j2s(kycStatus)}`); - - t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-wire-required"); -} - -runTopsMerchantKycauthsTest.suites = ["tops", "libeufin"]; diff --git a/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts b/packages/taler-harness/src/integrationtests/test-tops-merchant-swt-kycauth.ts @@ -0,0 +1,187 @@ +/* + This file is part of GNU Taler + (C) 2025 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/> + */ + +/** + * Imports. + */ +import { + IbanString, + j2s, + Logger, + MerchantAccountKycStatus, + Paytos, + succeedOrThrow, + TalerMerchantInstanceHttpClient, + TalerProtocolDuration, +} from "@gnu-taler/taler-util"; +import { CoinConfig, defaultCoinConfig } from "../harness/denomStructures.js"; +import { startFakeChallenger } from "../harness/fake-challenger.js"; +import { + ExchangeService, + GlobalTestState, + LibeufinNexusService, + MerchantService, + NexusBankAccountInfo, + setupDb, +} from "../harness/harness.js"; +import { topsKycRulesConf, topsProvidersTestConf } from "../harness/tops.js"; + +const logger = new Logger("test-tops-merchant-kycauths.ts"); + +/** + * Test short wire transfers in an exchange setup that simulates + * the Taler Operations CH exchange, using libeufin-nexus + * as the taler wire gateway API. + * + * This test exercises the .../kycauths endpoint of the merchant backend. + */ +export async function runTopsMerchantSwtKycauthTest(t: GlobalTestState) { + const db = await setupDb(t); + + const challenger = await startFakeChallenger({ + port: 6001, + addressType: "postal-ch", + }); + + let coinConfig: CoinConfig[]; + coinConfig = defaultCoinConfig.map((x) => x("CHF")); + + const bankAccountInfo: NexusBankAccountInfo = { + // Random IBAN generated via "libeufin-nexus testing iban gen --country CH" + iban: "CH7347363QVFHHFR8BWWB", + // PostFinance BIC + bic: "POFICHBEXXX", + name: "Harness Test Exchange", + qrIban: "CH1130000001166556117", + }; + + const nexus = await LibeufinNexusService.create(t, { + currency: "CHF", + database: db.connStr, + httpPort: 8085, + bankAccountInfo, + }); + + const exchangePayto = Paytos.toFullString( + Paytos.createIban(bankAccountInfo.qrIban as IbanString, undefined, { + "receiver-name": bankAccountInfo.name, + }), + ); + + await nexus.dbinit(); + + await nexus.start(); + + const exchange = ExchangeService.create(t, { + name: "testexchange-1", + currency: "CHF", + httpPort: 8081, + database: db.connStr, + // FIXME: This is a terrible way to configure the exchange, should be moved into config. + extraProcEnv: { + EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_TOS_NAME: "v1", + EXCHANGE_AML_PROGRAM_TOPS_ENABLE_DEPOSITS_THRESHOLD: "CHF:0", + EXCHANGE_AML_PROGRAM_TOPS_POSTAL_CHECK_COUNTRY_REGEX: "ch|CH|Ch", + }, + }); + + exchange.addBankAccount("nexusacct", { + accountPaytoUri: exchangePayto, + wireGatewayApiBaseUrl: nexus.wireGatewayApiBaseUrl, + wireGatewayAuth: { + type: "basic", + username: "exchange-test", + password: "exchange-test", + }, + preparedTransferUrl: nexus.preparedTransferApiBaseUrl, + }); + + exchange.addCoinConfigList(coinConfig); + + await exchange.modifyConfig(async (config) => { + config.loadFromString(topsKycRulesConf); + config.loadFromString(topsProvidersTestConf); + }); + + await exchange.start(); + + const merchantIban = "CH4810303KL83V1QZ05T7"; + + const merchant = await MerchantService.create(t, { + name: "testmerchant-1", + httpPort: 8083, + database: db.connStr, + }); + + merchant.addExchange(exchange); + + await merchant.start(); + + const merchantPayto = Paytos.toFullString( + Paytos.createIban(merchantIban as IbanString, undefined, { + "receiver-name": "Merchant Test", + }), + ); + + const { accessToken: merchantAdminAccessToken } = + await merchant.addInstanceWithWireAccount({ + id: "admin", + name: "Default Instance", + paytoUris: [merchantPayto], + defaultWireTransferDelay: TalerProtocolDuration.fromSpec({ minutes: 1 }), + }); + + const merchantClient = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + // Do KYC auth transfer + + const kycStatus = await merchantClient.getCurrentInstanceKycStatus( + merchantAdminAccessToken, + { + longpoll: { + type: "state-enter", + status: MerchantAccountKycStatus.KYC_WIRE_REQUIRED, + timeout: 10000, + }, + }, + ); + t.assertTrue(kycStatus.type === "ok"); + console.log(`kyc status: ${j2s(kycStatus)}`); + + t.assertDeepEqual(kycStatus.body.kyc_data[0].status, "kyc-wire-required"); + + const kycData = kycStatus.body.kyc_data[0]; + + const kycResp = succeedOrThrow( + await merchantClient.getInstancePrivateAccountKycauth( + merchantAdminAccessToken, + { + exchangeBaseUrl: kycData.exchange_url, + wireAccountHash: kycData.h_wire, + }, + ), + ); + + console.log(j2s(kycResp)); + + t.assertTrue( + kycResp.wire_instructions.length > 0, + "expected at least one wire instruction", + ); +} + +runTopsMerchantSwtKycauthTest.suites = ["tops", "libeufin"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -173,7 +173,7 @@ import { runTopsAmlLegiTest } from "./test-tops-aml-legi.js"; import { runTopsAmlMeasuresTest } from "./test-tops-aml-measures.js"; import { runTopsAmlPdfTest } from "./test-tops-aml-pdf.js"; import { runTopsChallengerTwiceTest } from "./test-tops-challenger-twice.js"; -import { runTopsMerchantKycauthsTest } from "./test-tops-merchant-kycauths.js"; +import { runTopsMerchantSwtKycauthTest } from "./test-tops-merchant-swt-kycauth.js"; import { runTopsMerchantTosTest } from "./test-tops-merchant-tos.js"; import { runTopsNexusBasicTest } from "./test-tops-nexus-basic.js"; import { runTopsNexusSwtTest } from "./test-tops-nexus-swt.js"; @@ -443,7 +443,7 @@ const allTests: TestMainFunction[] = [ runMerchantTemplatesTest, runTopsNexusBasicTest, runTopsNexusSwtTest, - runTopsMerchantKycauthsTest, + runTopsMerchantSwtKycauthTest, ]; export interface TestRunSpec { diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -55,6 +55,7 @@ import { codecForGroupsSummaryResponse, codecForInstancesResponse, codecForInventorySummaryResponse, + codecForKycAuthWireTransferInstructionResponse, codecForLoginTokenSuccessResponse, codecForMerchantOrderPrivateStatusResponse, codecForMerchantRefundResponse, @@ -872,6 +873,48 @@ export class TalerMerchantInstanceHttpClient { } /** + * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-private-accounts-$H_WIRE-kycauth + */ + async getInstancePrivateAccountKycauth( + token: AccessToken, + params: { + wireAccountHash: string; + exchangeBaseUrl: string; + }, + ) { + const url = new URL( + `private/accounts/${params.wireAccountHash}/kycauth`, + this.baseUrl, + ); + + const headers: Record<string, string> = {}; + headers.Authorization = makeBearerTokenAuthHeader(token); + + const cancellationToken = this.cancellationToken; + const resp = await this.httpLib.fetch(url.href, { + method: "POST", + headers, + cancellationToken, + body: { + exchange_url: params.exchangeBaseUrl, + }, + }); + + switch (resp.status) { + case HttpStatusCode.Ok: { + return await opSuccessFromHttp( + resp, + codecForKycAuthWireTransferInstructionResponse(), + ); + } + case HttpStatusCode.NotFound: // FIXME: missing in docs + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** * https://docs.taler.net/core/api-merchant.html#get-[-instances-$INSTANCE]-private-kyc */ async getCurrentInstanceKycStatus( diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -87,6 +87,10 @@ import { codecForInternationalizedString, codecForURLString, } from "./types-taler-common.js"; +import { + TransferSubject, + codecForTransferSubject, +} from "./types-taler-prepared-transfer.js"; import { PayWalletData, codecForCanonBaseUrl } from "./types-taler-wallet.js"; /** @@ -6004,3 +6008,44 @@ export const codecForPotDetailResponse = (): Codec<PotDetailResponse> => .property("pot_name", codecForString()) .property("pot_totals", codecForList(codecForAmountString())) .build("TalerMerchantApi.PotDetailResponse"); + +export interface KycAuthWireTransferInstructionResponse { + // Array of possible wire transfers to do. There might + // be more than one possibility, for example if the + // exchange supports multiple bank accounts. + wire_instructions: WireTransferInstructionDetail[]; +} + +export interface WireTransferInstructionDetail { + // Amount to wire (minimum amount, as per tiny_amount + // of the /keys of the exchange). + amount: AmountString; + + // Full payto:// URL of the (exchange) bank account to which the + // money should be sent, excludes the wire subject and the amount. + target_payto: string; + + // Subject to use. See also DD80! + subject: TransferSubject; + + // Expiration date after which this subject might be reused + expiration: Timestamp; +} + +export const codecForKycAuthWireTransferInstructionResponse = + (): Codec<KycAuthWireTransferInstructionResponse> => + buildCodecForObject<KycAuthWireTransferInstructionResponse>() + .property( + "wire_instructions", + codecForList(codecForWireTransferInstructionDetail()), + ) + .build("WireTransferInstructionDetail"); + +export const codecForWireTransferInstructionDetail = + (): Codec<WireTransferInstructionDetail> => + buildCodecForObject<WireTransferInstructionDetail>() + .property("amount", codecForAmountString()) + .property("target_payto", codecForString()) + .property("subject", codecForTransferSubject()) + .property("expiration", codecForTimestamp) + .build("WireTransferInstructionDetail");