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