taler-typescript-core

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

commit 89fde9bf667a23159f29483350333a4520bf5637
parent 6fe8ee28957cb1232d5c1b2c8e898013f38ae790
Author: Florian Dold <dold@taler.net>
Date:   Wed, 23 Sep 2026 17:47:47 +0200

taler-harness: reproduce settlement before all deposit checks

Exercise two orders sharing an aggregated payout while one deposit check
is delayed. Require both orders to appear settled before opening their
details, and require one settlement webhook per order.

Issue: https://bugs.taler.net/n/11813

Diffstat:
Apackages/taler-harness/src/integrationtests/test-merchant-settlement.ts | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/testrunner.ts | 2++
2 files changed, 228 insertions(+), 0 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-merchant-settlement.ts b/packages/taler-harness/src/integrationtests/test-merchant-settlement.ts @@ -0,0 +1,226 @@ +/* + 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. + + 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 { + AbsoluteTime, + Duration, + TalerMerchantInstanceHttpClient, + succeedOrThrow, +} from "@gnu-taler/taler-util"; +import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; +import { + applyTimeTravelV2, + createSimpleTestkudosEnvironmentV3, + makeTestPaymentV2, + withdrawViaBankV4, +} from "../harness/environments.js"; +import { GlobalTestState, runCommand } from "../harness/harness.js"; + +/** + * An aggregated payout must settle every order even if reconciliation runs + * before all per-deposit checks finish. Regression for issue 11813. + */ +export async function runMerchantSettlementTest(t: GlobalTestState) { + const env = await createSimpleTestkudosEnvironmentV3(t); + const { bank, exchange, merchant, walletClient, merchantAdminAccessToken } = + env; + const client = new TalerMerchantInstanceHttpClient( + merchant.makeInstanceBaseUrl(), + ); + const sql = (label: string, query: string) => + runCommand(t, label, "psql", [ + env.commonDb.connStr, + "-X", + "-A", + "-t", + "-v", + "ON_ERROR_STOP=1", + "-c", + query, + ]); + const serial = ( + await sql( + "instance-serial", + "SELECT merchant_serial FROM merchant.merchant_instances WHERE merchant_id='admin'", + ) + ).trim(); + t.assertTrue(/^[0-9]+$/.test(serial)); + const schema = `merchant_instance_${serial}`; + + succeedOrThrow( + await client.addWebhook(merchantAdminAccessToken, { + webhook_id: "settlement-test", + event_type: "order_settled", + url: "https://example.com/settlement-test", + http_method: "POST", + body_template: "{{order_id}}", + }), + ); + const pendingWebhooks = async () => + ( + await sql( + "settlement-webhooks", + ` + SELECT body FROM merchant.merchant_pending_webhooks + WHERE merchant_serial=${serial} AND url='https://example.com/settlement-test' + ORDER BY body; + `, + ) + ) + .trim() + .split("\n"); + + await exchange.stopAggregator(); + const withdrawal = await withdrawViaBankV4(t, { + walletClient, + bank, + exchange, + amount: "TESTKUDOS:20", + }); + await withdrawal.withdrawalFinishedCond; + + const orderIds = ["settlement-early", "settlement-late"]; + for (const orderId of orderIds) { + await makeTestPaymentV2(t, { + merchant, + merchantAdminAccessToken, + walletClient, + order: { + order_id: orderId, + summary: orderId, + amount: "TESTKUDOS:5", + fulfillment_url: `taler://fulfillment-success/${orderId}`, + refund_deadline: AbsoluteTime.toProtocolTimestamp(AbsoluteTime.zero()), + wire_transfer_deadline: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ minutes: 10 }), + ), + ), + }, + }); + } + const list = async () => + succeedOrThrow(await client.listOrders(merchantAdminAccessToken, {})) + .orders; + for (const order of await list()) { + t.assertDeepEqual(order.wired, false); + } + + await walletClient.call(WalletApiOperation.TestingWaitTransactionsFinal, {}); + await applyTimeTravelV2( + Duration.toMilliseconds(Duration.fromSpec({ hours: 2 })), + { exchange, merchant }, + ); + await exchange.stopAggregator(); + await exchange.runAggregatorOnce(); + + // Only postpone scheduling. Do not invent settlement evidence or change + // the flags that this test is meant to exercise. Test-mode depositcheck + // exits once there are no deposits whose retry time has arrived. + await sql( + "defer-second-order-deposit-check", + ` + UPDATE ${schema}.merchant_deposits + SET settlement_retry_time=9223372036854775807 + WHERE deposit_confirmation_serial IN ( + SELECT deposit_confirmation_serial FROM ${schema}.merchant_deposit_confirmations + JOIN ${schema}.merchant_contract_terms USING (order_serial) + WHERE order_id='settlement-late'); + `, + ); + await merchant.runDepositcheckOnce(); + const unchecked = ( + await sql( + "check-delayed-deposits", + ` + SELECT count(*) FROM ${schema}.merchant_deposits md + JOIN ${schema}.merchant_deposit_confirmations USING (deposit_confirmation_serial) + JOIN ${schema}.merchant_contract_terms USING (order_serial) + WHERE order_id='settlement-late' AND md.settlement_retry_needed + AND md.settlement_wtid IS NULL; + `, + ) + ).trim(); + t.assertTrue( + Number(unchecked) > 0, + "the second order must still await deposit checking", + ); + + t.logStep("reconcile-before-second-deposit-check"); + await merchant.runReconciliationOnce(); + const incoming = succeedOrThrow( + await client.listIncomingWireTransfers(merchantAdminAccessToken), + ); + t.assertTrue( + incoming.incoming.length === 1, + "both orders must share one payout", + ); + t.assertTrue(incoming.incoming[0].expected_credit_amount != null); + + t.assertDeepEqual( + (await list()).map((o) => [o.order_id, o.wired]).sort(), + orderIds.map((id) => [id, true]).sort(), + ); + t.assertDeepEqual(await pendingWebhooks(), [...orderIds].sort()); + + await sql( + "resume-second-order-deposit-check", + ` + UPDATE ${schema}.merchant_deposits SET settlement_retry_time=0 + WHERE settlement_retry_needed; + `, + ); + await merchant.runDepositcheckOnce(); + // Repeating reconciliation must not be needed to make the list correct, + // and no GET /private/orders/$ORDER_ID may repair the flags before this. + t.logStep("check-settled-list-before-details"); + const orders = await list(); + t.assertDeepEqual(orders.length, 2); + for (const order of orders) { + t.assertTrue(orderIds.includes(order.order_id)); + t.assertTrue( + order.wired === true, + `${order.order_id} must be settled without opening its details`, + ); + } + + // Repeated worker runs and then visiting details must preserve the status. + await merchant.runDepositcheckOnce(); + await merchant.runReconciliationOnce(); + for (const orderId of orderIds) { + const detail = succeedOrThrow( + await client.getOrderDetails(merchantAdminAccessToken, orderId), + ); + t.assertTrue(detail.order_status === "paid"); + t.assertDeepEqual(detail.wired, true); + } + t.assertDeepEqual(await list(), orders); + t.assertDeepEqual(await pendingWebhooks(), [...orderIds].sort()); + const settled = succeedOrThrow( + await client.listOrders(merchantAdminAccessToken, { wired: true }), + ); + t.assertDeepEqual(settled.orders.length, 2); + const pending = succeedOrThrow( + await client.listOrders(merchantAdminAccessToken, { + wired: false, + paid: true, + }), + ); + t.assertDeepEqual(pending.orders.length, 0); +} + +runMerchantSettlementTest.suites = ["merchant"]; diff --git a/packages/taler-harness/src/integrationtests/testrunner.ts b/packages/taler-harness/src/integrationtests/testrunner.ts @@ -143,6 +143,7 @@ import { runMerchantSelfProvisionInactiveAccountPermissionsTest } from "./test-m import { runMerchantSpecPublicOrdersTest } from "./test-merchant-spec-public-orders.js"; import { runMerchantTemplatesTest } from "./test-merchant-templates.js"; import { runMerchantTokenfamiliesTest } from "./test-merchant-tokenfamilies.js"; +import { runMerchantSettlementTest } from "./test-merchant-settlement.js"; import { runMerchantWireTest } from "./test-merchant-wire.js"; import { runMultiExchangeTest } from "./test-multiexchange.js"; import { runOtpTest } from "./test-otp.js"; @@ -538,6 +539,7 @@ const allTests: TestMainFunction[] = [ runDonauKeychangeTest, runTopsAmlPdfTest, runMerchantWireTest, + runMerchantSettlementTest, runWalletExchangeFeaturesTest, runWalletBbanTest, runCurrencyScopeSeparationTest,