taler-typescript-core

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

commit 961fe36d70e5a3d9bd873b8305c9f76015e2507e
parent 5e7c6f146a6e082dfb90e5acdaf20cb30ea11979
Author: Florian Dold <dold@taler.net>
Date:   Thu, 27 Aug 2026 15:33:05 +0200

wallet-core: show selected merchant payment amounts

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

Diffstat:
Mpackages/taler-harness/src/integrationtests/test-wallet-tokens.ts | 32+++++++++++++++++++++++++++++++-
Mpackages/taler-wallet-core/src/pay-merchant.test.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-wallet-core/src/pay-merchant.ts | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
3 files changed, 317 insertions(+), 82 deletions(-)

diff --git a/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts b/packages/taler-harness/src/integrationtests/test-wallet-tokens.ts @@ -19,6 +19,7 @@ */ import { AbsoluteTime, + Amounts, ChoiceSelectionDetailType, Duration, j2s, @@ -33,6 +34,7 @@ import { TokenFamilyKind, TransactionMajorState, TransactionMinorState, + TransactionType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { defaultCoinConfig } from "../harness/denomStructures.js"; @@ -262,6 +264,14 @@ export async function runWalletTokensTest(t: GlobalTestState) { choiceIndex, }); + const paymentTx = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: preparePayResult.transactionId }, + ); + t.assertTrue(paymentTx.type === TransactionType.Payment); + t.assertAmountEquals(paymentTx.amountRaw, "TESTKUDOS:2"); + t.assertTrue(Amounts.cmp(paymentTx.amountEffective, "TESTKUDOS:0") > 0); + orderStatus = succeedOrThrow( await merchantApi.getOrderDetails(merchantAdminAccessToken, orderId), ); @@ -417,13 +427,33 @@ export async function runWalletTokensTest(t: GlobalTestState) { } // should fail because we have no tokens left - t.assertThrowsAsync(async () => { + await t.assertThrowsAsync(async () => { await walletClient.call(WalletApiOperation.ConfirmPay, { transactionId: preparePayResult.transactionId, choiceIndex, }); }); + const paymentTx = await walletClient.call( + WalletApiOperation.GetTransactionById, + { transactionId: preparePayResult.transactionId }, + ); + t.assertTrue(paymentTx.type === TransactionType.Payment); + t.assertDeepEqual(paymentTx.choiceIndex, choiceIndex); + t.assertAmountEquals(paymentTx.amountRaw, "TESTKUDOS:1"); + t.assertAmountEquals(paymentTx.amountEffective, "TESTKUDOS:0"); + + const history = await walletClient.call( + WalletApiOperation.GetTransactionsV2, + {}, + ); + const historyTx = history.transactions.find( + (tx) => tx.transactionId === preparePayResult.transactionId, + ); + t.assertTrue(historyTx?.type === TransactionType.Payment); + t.assertAmountEquals(historyTx.amountRaw, "TESTKUDOS:1"); + t.assertAmountEquals(historyTx.amountEffective, "TESTKUDOS:0"); + orderStatus = succeedOrThrow( await merchantApi.getOrderDetails(merchantAdminAccessToken, orderId), ); diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts @@ -17,6 +17,7 @@ import { Amounts, AmountString, HttpStatusCode, + MerchantContractVersion, SelectedCoin, SignedTokenEnvelope, TransactionAction, @@ -46,6 +47,7 @@ import { getCoinsToSpendForMerchantRepair, getAlreadyPaidRefundRequests, getPayMerchantAbortTransition, + getPayMerchantTransactionAmounts, getPayRepairAcceptedExchanges, getPayMerchantResumeTransition, getPayMerchantSuspendTransition, @@ -99,6 +101,77 @@ test("unclaimed payments wait for and finish neutrally in another wallet", () => ); }); +test("payment transaction amounts follow the selected merchant choice", () => { + const contractTerms = { + version: MerchantContractVersion.V1, + choices: [{ amount: "TESTKUDOS:3" }, { amount: "TESTKUDOS:7" }], + } as any; + const purchase = { + choiceIndex: 1, + payInfo: { totalPayCost: "TESTKUDOS:7.25" }, + } as Pick<WalletPurchase, "choiceIndex" | "payInfo">; + + assert.deepStrictEqual( + getPayMerchantTransactionAmounts(contractTerms, purchase, { + major: TransactionMajorState.Pending, + }), + { + amountRaw: "TESTKUDOS:7", + amountEffective: "TESTKUDOS:7.25", + currency: "TESTKUDOS", + }, + ); + assert.deepStrictEqual( + getPayMerchantTransactionAmounts(contractTerms, purchase, { + major: TransactionMajorState.Failed, + }), + { + amountRaw: "TESTKUDOS:7", + amountEffective: "TESTKUDOS:0", + currency: "TESTKUDOS", + }, + ); +}); + +test("payment transaction amounts recover safe historical values", () => { + const singleChoice = { + version: MerchantContractVersion.V1, + choices: [{ amount: "TESTKUDOS:4" }], + } as any; + assert.deepStrictEqual( + getPayMerchantTransactionAmounts( + singleChoice, + { choiceIndex: undefined, payInfo: undefined }, + { major: TransactionMajorState.Dialog }, + ), + { + amountRaw: "TESTKUDOS:4", + amountEffective: "TESTKUDOS:0", + currency: "TESTKUDOS", + }, + ); + + const ambiguousChoices = { + version: MerchantContractVersion.V1, + choices: [{ amount: "TESTKUDOS:4" }, { amount: "TESTKUDOS:6" }], + } as any; + assert.deepStrictEqual( + getPayMerchantTransactionAmounts( + ambiguousChoices, + { + choiceIndex: undefined, + payInfo: { totalPayCost: "TESTKUDOS:6.10" }, + }, + { major: TransactionMajorState.Pending }, + ), + { + amountRaw: "UNKNOWN:0", + amountEffective: "TESTKUDOS:6.1", + currency: "TESTKUDOS", + }, + ); +}); + test("preparing a reused shared payment only wakes its transaction task", async () => { const purchase = { proposalId: "shared-proposal", diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts @@ -244,11 +244,28 @@ export class PayMerchantTransactionContext implements TransactionContext { return; } + let currency = purchaseRec.download.currency; + if (currency === "UNKNOWN") { + const contractTermsRec = await tx.getContractTerms( + purchaseRec.download.contractTermsHash, + ); + if (contractTermsRec) { + const contractTerms = codecForMerchantContractTerms().decode( + contractTermsRec.contractTermsRaw, + ); + currency = getPayMerchantTransactionAmounts( + contractTerms, + purchaseRec, + computePayMerchantTransactionState(purchaseRec), + ).currency; + } + } + await tx.upsertTransactionMeta({ transactionId: this.transactionId, status: purchaseRec.purchaseStatus, timestamp: purchaseRec.timestamp, - currency: purchaseRec.download?.currency ?? "UNKNOWN", + currency, exchanges: purchaseRec.exchanges ?? [], }); } @@ -325,23 +342,9 @@ export class PayMerchantTransactionContext implements TransactionContext { purchaseRec.proposalId, ); - let amountRaw: AmountString = "UNKNOWN:0"; - if ( - contractData.version === undefined || - contractData.version === MerchantContractVersion.V0 - ) { - amountRaw = contractData.amount; - } else if (contractData.version === MerchantContractVersion.V1) { - const index = purchaseRec.choiceIndex; - if (index !== undefined) { - if (!(index in contractData.choices)) - throw Error(`invalid choice index ${index}`); - amountRaw = contractData.choices[index].amount; - } - } - - let zero = Amounts.zeroOfAmount(amountRaw); - const refundTotals = getRefundTotals(refundsInfo, zero.currency); + const { amountRaw, amountEffective, currency } = + getPayMerchantTransactionAmounts(contractData, purchaseRec, txState); + const refundTotals = getRefundTotals(refundsInfo, currency); const info: OrderShortInfo = { merchant: { @@ -377,16 +380,6 @@ export class PayMerchantTransactionContext implements TransactionContext { tx, purchaseRec, ); - let amountEffective: AmountString; - - if (!purchaseRec.payInfo) { - amountEffective = Amounts.stringify(zero); - } else { - amountEffective = isUnsuccessfulTransaction(txState) - ? Amounts.stringify(zero) - : Amounts.stringify(purchaseRec.payInfo.totalPayCost); - } - let contractTerms: MerchantContractTerms | undefined; if (req?.includeContractTerms) { contractTerms = download.contractTerms; @@ -635,6 +628,63 @@ export class PayMerchantTransactionContext implements TransactionContext { } } +/** + * Resolve the amounts shown for a merchant payment transaction. + * + * Older records and failed confirmation attempts can lack a choice index. + * A single v1 choice is still unambiguous, and an accepted payment's total + * cost can still provide the effective amount and its currency. + */ +export function getPayMerchantTransactionAmounts( + contractData: MerchantContractTerms, + purchaseRec: Pick<WalletPurchase, "choiceIndex" | "payInfo">, + txState: TransactionState, +): { + amountRaw: AmountString; + amountEffective: AmountString; + currency: string; +} { + let resolvedRaw: AmountString | undefined; + switch (contractData.version) { + case undefined: + case MerchantContractVersion.V0: + resolvedRaw = contractData.amount; + break; + case MerchantContractVersion.V1: { + const index = purchaseRec.choiceIndex; + if (index !== undefined) { + const choice = contractData.choices[index]; + if (!choice) { + throw Error(`invalid choice index ${index}`); + } + resolvedRaw = choice.amount; + } else if (contractData.choices.length === 1) { + resolvedRaw = contractData.choices[0].amount; + } + break; + } + default: + assertUnreachable(contractData); + } + + const currency = resolvedRaw + ? Amounts.currencyOf(resolvedRaw) + : purchaseRec.payInfo + ? Amounts.currencyOf(purchaseRec.payInfo.totalPayCost) + : "UNKNOWN"; + const zero = Amounts.stringify(Amounts.zeroOfCurrency(currency)); + const amountEffective = + purchaseRec.payInfo && !isUnsuccessfulTransaction(txState) + ? Amounts.stringify(purchaseRec.payInfo.totalPayCost) + : zero; + + return { + amountRaw: resolvedRaw ?? "UNKNOWN:0", + amountEffective, + currency, + }; +} + async function computePayMerchantExchangesInTx( tx: WalletDbTransaction, purchaseRecord: WalletPurchase, @@ -3153,6 +3203,57 @@ export async function confirmPay( const currency = Amounts.currencyOf(amountRaw); + if (contractTerms.version === MerchantContractVersion.V1) { + const choicePersistence = await wex.runWalletDbTx(async (tx) => { + const [p, h] = await ctx.getRecordHandle(tx); + if (!p) { + throw makeTransactionNotFoundError(transactionId); + } + if (p.payInfo) { + if (p.choiceIndex !== choiceIndex) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "choiceIndex" }, + "the choice index of an existing purchase cannot be changed", + ); + } + return "already-confirmed" as const; + } + switch (p.purchaseStatus) { + case PurchaseStatus.DialogShared: + case PurchaseStatus.DialogProposed: + break; + default: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(p), + debugStateNum: p.purchaseStatus, + }, + "this payment cannot be confirmed in its current state", + ); + } + p.choiceIndex = choiceIndex; + if (p.download) { + p.download.currency = currency; + } + await h.update(p, "select-payment-choice", BalanceEffect.None); + return "selected" as const; + }); + + if (choicePersistence === "already-confirmed") { + await wex.taskScheduler.resetTask(ctx.taskId); + if (args.noWait) { + return { + type: ConfirmPayResultType.Pending, + transactionId: transactionId as TransactionIdStr, + lastError: undefined, + }; + } + return waitPaymentResult(wex, proposalId, sessionIdOverride); + } + } + let sessionId: string | undefined; if (sessionIdOverride) { sessionId = sessionIdOverride; @@ -3164,10 +3265,47 @@ export async function confirmPay( `recording payment on ${proposal.orderId} with session ID ${sessionId}`, ); - await wex.runWalletDbTx(async (tx) => { + const paymentPrepared = await wex.runWalletDbTx(async (tx) => { const [p, h] = await ctx.getRecordHandle(tx); if (!p) { - return; + throw makeTransactionNotFoundError(transactionId); + } + if (p.payInfo) { + if ( + contractTerms.version === MerchantContractVersion.V1 && + p.choiceIndex !== choiceIndex + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "choiceIndex" }, + "the selected payment choice changed while confirming", + ); + } + return false; + } + switch (p.purchaseStatus) { + case PurchaseStatus.DialogShared: + case PurchaseStatus.DialogProposed: + break; + default: + throw TalerError.fromDetail( + TalerErrorCode.WALLET_REQUEST_TRANSACTION_STATE_UNSUPPORTED, + { + txState: computePayMerchantTransactionState(p), + debugStateNum: p.purchaseStatus, + }, + "this payment cannot be confirmed in its current state", + ); + } + if ( + contractTerms.version === MerchantContractVersion.V1 && + p.choiceIndex !== choiceIndex + ) { + throw TalerError.fromDetail( + TalerErrorCode.WALLET_CORE_API_BAD_REQUEST, + { parameter: "choiceIndex" }, + "the selected payment choice changed while confirming", + ); } let selectTokensResult: SelectPayTokensResult | undefined; @@ -3236,16 +3374,6 @@ export async function confirmPay( const payCostInfo = await getTotalPaymentCostInTx(wex, tx, currency, coins); - p.choiceIndex = choiceIndex; - if ( - p.download && - choiceIndex !== undefined && - contractTerms.version === MerchantContractVersion.V1 - ) { - const amount = contractTerms.choices[choiceIndex].amount; - p.download.currency = Amounts.currencyOf(amount); - } - const confRes = await tx.getConfig(ConfigRecordKey.DonauConfig); if ( @@ -3276,50 +3404,54 @@ export async function confirmPay( } } - switch (p.purchaseStatus) { - case PurchaseStatus.DialogShared: - case PurchaseStatus.DialogProposed: - p.payInfo = { - totalPayCost: Amounts.stringify(payCostInfo), - }; - if (selectTokensResult?.type === "success") { - const tokens = selectTokensResult.tokens; - p.payInfo.payTokenSelection = { - tokenPubs: tokens.map((t) => t.tokenUsePub), - }; - } - if (selectCoinsResult.type === "success") { - setCoinSel(p, selectCoinsResult.coinSel); - } - p.lastSessionId = sessionId; - p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); - p.purchaseStatus = PurchaseStatus.PendingPaying; - await h.update(p, "confirm-pay"); - if (p.payInfo.payTokenSelection) { - await spendTokens(tx, { - tokenPubs: p.payInfo.payTokenSelection.tokenPubs, - transactionId: ctx.transactionId, - }); - } - if (p.payInfo.payCoinSelection) { - const sel = p.payInfo.payCoinSelection; - await spendCoins(wex, tx, { - transactionId: transactionId as TransactionIdStr, - coinPubs: sel.coinPubs, - contributions: sel.coinContributions.map((x) => - Amounts.parseOrThrow(x), - ), - refreshReason: RefreshReason.PayMerchant, - }); - } - break; - case PurchaseStatus.Done: - case PurchaseStatus.PendingPaying: - default: - break; + p.payInfo = { + totalPayCost: Amounts.stringify(payCostInfo), + }; + if (selectTokensResult?.type === "success") { + const tokens = selectTokensResult.tokens; + p.payInfo.payTokenSelection = { + tokenPubs: tokens.map((t) => t.tokenUsePub), + }; } + if (selectCoinsResult.type === "success") { + setCoinSel(p, selectCoinsResult.coinSel); + } + p.lastSessionId = sessionId; + p.timestampAccept = timestampPreciseToDb(TalerPreciseTimestamp.now()); + p.purchaseStatus = PurchaseStatus.PendingPaying; + await h.update(p, "confirm-pay"); + if (p.payInfo.payTokenSelection) { + await spendTokens(tx, { + tokenPubs: p.payInfo.payTokenSelection.tokenPubs, + transactionId: ctx.transactionId, + }); + } + if (p.payInfo.payCoinSelection) { + const sel = p.payInfo.payCoinSelection; + await spendCoins(wex, tx, { + transactionId: transactionId as TransactionIdStr, + coinPubs: sel.coinPubs, + contributions: sel.coinContributions.map((x) => + Amounts.parseOrThrow(x), + ), + refreshReason: RefreshReason.PayMerchant, + }); + } + return true; }); + if (!paymentPrepared) { + await wex.taskScheduler.resetTask(ctx.taskId); + if (args.noWait) { + return { + type: ConfirmPayResultType.Pending, + transactionId: transactionId as TransactionIdStr, + lastError: undefined, + }; + } + return waitPaymentResult(wex, proposalId, sessionIdOverride); + } + // TODO: pre-generate slates based on choice priority! if ( choiceIndex != null &&