commit e86e3c1100657a0e375dedac77755046ee96d435
parent 1b31cd676ee3d765f19bd646a1d25a36405837b1
Author: Florian Dold <dold@taler.net>
Date: Fri, 4 Sep 2026 01:01:18 +0200
wallet-core: fix payment abort and refund recovery
Keep replay sessions separate, aggregate refunds per coin, and retry
transient refund failures. Verify merchant abort and Paivana responses
before completing recovery.
Diffstat:
7 files changed, 641 insertions(+), 123 deletions(-)
diff --git a/packages/taler-wallet-core/src/pay-merchant.test.ts b/packages/taler-wallet-core/src/pay-merchant.test.ts
@@ -18,6 +18,7 @@ import {
AmountString,
HttpStatusCode,
MerchantContractVersion,
+ RefreshReason,
SelectedCoin,
SignedTokenEnvelope,
TransactionAction,
@@ -34,16 +35,23 @@ import assert from "node:assert";
import { test } from "node:test";
import {
PurchaseStatus,
+ RefreshCoinStatus,
+ RefreshOperationStatus,
RefundGroupStatus,
+ RefundItemStatus,
WalletPurchase,
WalletCoinSelection,
WalletRefundGroup,
+ WalletRefundItem,
+ WalletRefreshGroup,
WalletSlate,
WalletToken,
} from "./db/records.js";
import { WalletDbTransaction } from "./db/transaction.js";
import {
applyFirstPaySuccessState,
+ applyPaySessionRequest,
+ classifyMerchantAbortRecoveryEvidence,
cleanupFailedClaimsOnStartup,
computePayMerchantTransactionActions,
computePayMerchantTransactionState,
@@ -61,6 +69,7 @@ import {
isPaymentSessionComplete,
isSharedPurchase,
isStableMerchantPayFailure,
+ isTransientRefundStatus,
PayMerchantTransactionContext,
recoveredPayFailureStatus,
releasePaymentTokensInTx,
@@ -184,6 +193,36 @@ test("unclaimed payments wait for and finish neutrally in another wallet", () =>
);
});
+test("deleting a proposed payment only uses local state", async () => {
+ const runner = await makeIdbRunner();
+ const proposalId = "delete-proposed-payment";
+ const wex = {
+ ws: {},
+ async runWalletDbTx<T>(
+ f: (tx: WalletDbTransaction) => Promise<T>,
+ ): Promise<T> {
+ return await runner.runReadWriteTx(f);
+ },
+ } as unknown as WalletExecutionContext;
+ try {
+ await runner.runReadWriteTx((tx) =>
+ tx.upsertPurchase(
+ claimPurchase(proposalId, PurchaseStatus.DialogProposed),
+ ),
+ );
+
+ const ctx = new PayMerchantTransactionContext(wex, proposalId);
+ await ctx.userDeleteTransaction();
+
+ assert.strictEqual(
+ await runner.runReadWriteTx((tx) => tx.getPurchase(proposalId)),
+ undefined,
+ );
+ } finally {
+ await runner.close();
+ }
+});
+
test("payment transaction amounts follow the selected merchant choice", () => {
const contractTerms = {
version: MerchantContractVersion.V1,
@@ -788,6 +827,50 @@ test("payment replay waits for the requested session to finish", () => {
);
});
+test("a concurrent session request is retained for replay", () => {
+ const purchase = {
+ lastSessionId: "first-session",
+ payInfo: { totalPayCost: "TESTKUDOS:1" },
+ purchaseStatus: PurchaseStatus.PendingPaying,
+ } as WalletPurchase;
+
+ assert.strictEqual(applyPaySessionRequest(purchase, "second-session"), true);
+ assert.strictEqual(purchase.purchaseStatus, PurchaseStatus.PendingPaying);
+ assert.strictEqual(
+ getPayMerchantAbortTransition(purchase.purchaseStatus, false),
+ PurchaseStatus.AbortingWithRefund,
+ );
+
+ const completed = applyFirstPaySuccessState(
+ purchase,
+ "first-session",
+ { sig: "first-payment-proof" } as never,
+ TalerPreciseTimestamp.fromSeconds(1_800_000_000),
+ );
+ assert.strictEqual(completed, false);
+ assert.strictEqual(purchase.lastSessionId, "second-session");
+ assert.strictEqual(
+ purchase.purchaseStatus,
+ PurchaseStatus.PendingPayingReplay,
+ );
+});
+
+test("refund retry classification includes timeout and throttling", () => {
+ assert.strictEqual(
+ isTransientRefundStatus(HttpStatusCode.RequestTimeout),
+ true,
+ );
+ assert.strictEqual(
+ isTransientRefundStatus(HttpStatusCode.TooManyRequests),
+ true,
+ );
+ assert.strictEqual(
+ isTransientRefundStatus(HttpStatusCode.InternalServerError),
+ true,
+ );
+ assert.strictEqual(isTransientRefundStatus(HttpStatusCode.Conflict), false);
+});
+
test("every advertised merchant action has a state transition", () => {
const statuses = Object.values(PurchaseStatus).filter(
(value): value is PurchaseStatus => typeof value === "number",
@@ -819,7 +902,7 @@ test("every advertised merchant action has a state transition", () => {
}
});
-test("a delayed payment success preserves a concurrent abort state", () => {
+test("a delayed verified payment success wins a concurrent abort race", () => {
const purchase = {
purchaseStatus: PurchaseStatus.AbortingWithRefund,
} as WalletPurchase;
@@ -831,11 +914,8 @@ test("a delayed payment success preserves a concurrent abort state", () => {
TalerPreciseTimestamp.fromSeconds(1_800_000_000),
);
- assert.strictEqual(completed, false);
- assert.strictEqual(
- purchase.purchaseStatus,
- PurchaseStatus.AbortingWithRefund,
- );
+ assert.strictEqual(completed, true);
+ assert.strictEqual(purchase.purchaseStatus, PurchaseStatus.Done);
assert.strictEqual(purchase.merchantPaySig, "merchant-payment-signature");
assert.strictEqual(purchase.lastSessionId, "session");
});
@@ -963,20 +1043,102 @@ test("merchant repair recovers the contribution of a coin the merchant rejected"
]);
});
-test("refund totals leave out refunds that did not happen", () => {
+test("refund totals retain successes from a mixed failed group", () => {
const done = refundGroup("done", "TESTKUDOS:3", "TESTKUDOS:2.5");
const pending = refundGroup("pending", "TESTKUDOS:1", "TESTKUDOS:0.5");
pending.status = RefundGroupStatus.Pending;
- const failed = refundGroup("failed", "TESTKUDOS:7", "TESTKUDOS:0");
+ const failed = refundGroup("failed", "TESTKUDOS:7", "TESTKUDOS:6");
failed.status = RefundGroupStatus.Failed;
- const aborted = refundGroup("aborted", "TESTKUDOS:5", "TESTKUDOS:0");
+ const aborted = refundGroup("aborted", "TESTKUDOS:0", "TESTKUDOS:0");
aborted.status = RefundGroupStatus.Aborted;
assert.deepStrictEqual(
getRefundTotals([done, pending, failed, aborted], "TESTKUDOS"),
{
- raw: "TESTKUDOS:4",
- effective: "TESTKUDOS:3",
+ raw: "TESTKUDOS:11",
+ effective: "TESTKUDOS:9",
},
);
});
+
+function recoveryRefresh(
+ reason: RefreshReason,
+ coinPub: string,
+ output: string,
+ status = RefreshCoinStatus.Finished,
+): WalletRefreshGroup {
+ return {
+ reason,
+ oldCoinPubs: [coinPub],
+ expectedOutputPerCoin: [output],
+ statusPerCoin: [status],
+ operationStatus:
+ status === RefreshCoinStatus.Finished
+ ? RefreshOperationStatus.Finished
+ : RefreshOperationStatus.Pending,
+ } as WalletRefreshGroup;
+}
+
+test("merchant abort requires refund recovery for a fully spent coin", () => {
+ const selection = {
+ coinPubs: ["coin"],
+ coinContributions: ["TESTKUDOS:1"],
+ } as WalletCoinSelection;
+ const abortRefresh = recoveryRefresh(
+ RefreshReason.AbortPay,
+ "coin",
+ "TESTKUDOS:0",
+ );
+
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(selection, abortRefresh, [], []),
+ "failed",
+ );
+
+ const refundItem = {
+ coinPub: "coin",
+ rtxid: 0,
+ status: RefundItemStatus.Done,
+ } as WalletRefundItem;
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(
+ selection,
+ abortRefresh,
+ [refundItem],
+ [
+ recoveryRefresh(
+ RefreshReason.Refund,
+ "coin",
+ "TESTKUDOS:0.9",
+ RefreshCoinStatus.Pending,
+ ),
+ ],
+ ),
+ "pending",
+ );
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(
+ selection,
+ abortRefresh,
+ [refundItem],
+ [recoveryRefresh(RefreshReason.Refund, "coin", "TESTKUDOS:0.9")],
+ ),
+ "recovered",
+ );
+});
+
+test("merchant abort accepts value recovered directly by abort refresh", () => {
+ const selection = {
+ coinPubs: ["coin"],
+ coinContributions: ["TESTKUDOS:1"],
+ } as WalletCoinSelection;
+ assert.strictEqual(
+ classifyMerchantAbortRecoveryEvidence(
+ selection,
+ recoveryRefresh(RefreshReason.AbortPay, "coin", "TESTKUDOS:0.9"),
+ [],
+ [],
+ ),
+ "recovered",
+ );
+});
diff --git a/packages/taler-wallet-core/src/pay-merchant.ts b/packages/taler-wallet-core/src/pay-merchant.ts
@@ -143,6 +143,7 @@ import {
RefundReason,
RefundGroupStatus,
RefundItemStatus,
+ RefreshCoinStatus,
timestampPreciseFromDb,
timestampPreciseToDb,
timestampProtocolFromDb,
@@ -155,6 +156,7 @@ import {
WalletPurchasePayInfo,
WalletRefundGroup,
WalletRefundItem,
+ WalletRefreshGroup,
WalletSlate,
WalletToken,
} from "./db/records.js";
@@ -949,9 +951,8 @@ export class RefundTransactionContext implements TransactionContext {
return {
type: TransactionType.Refund,
scopes,
- amountEffective: isUnsuccessfulTransaction(txState)
- ? Amounts.stringify(Amounts.zeroOfAmount(refundRecord.amountEffective))
- : refundRecord.amountEffective,
+ // Failed groups can still contain authenticated successful items.
+ amountEffective: refundRecord.amountEffective,
amountRaw: refundRecord.amountRaw,
refundedTransactionId: constructTransactionIdentifier({
tag: TransactionType.Payment,
@@ -2186,20 +2187,71 @@ export function applyFirstPaySuccessState(
timestamp: TalerPreciseTimestamp,
): boolean {
let completePaymentState = false;
+ let preserveRequestedReplaySession = false;
switch (purchase.purchaseStatus) {
case PurchaseStatus.PendingPaying:
+ if (purchase.lastSessionId !== sessionId) {
+ purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ preserveRequestedReplaySession = true;
+ } else {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ completePaymentState = true;
+ }
+ break;
case PurchaseStatus.SuspendedPaying:
+ if (purchase.lastSessionId !== sessionId) {
+ purchase.purchaseStatus = PurchaseStatus.SuspendedPayingReplay;
+ preserveRequestedReplaySession = true;
+ } else {
+ purchase.purchaseStatus = PurchaseStatus.Done;
+ completePaymentState = true;
+ }
+ break;
+ case PurchaseStatus.AbortingWithRefund:
+ case PurchaseStatus.SuspendedAbortingWithRefund:
purchase.purchaseStatus = PurchaseStatus.Done;
completePaymentState = true;
break;
+ case PurchaseStatus.PendingPayingReplay:
+ case PurchaseStatus.SuspendedPayingReplay:
+ preserveRequestedReplaySession = true;
+ break;
}
purchase.timestampFirstSuccessfulPay = timestampPreciseToDb(timestamp);
- purchase.lastSessionId = sessionId;
+ if (!preserveRequestedReplaySession) {
+ purchase.lastSessionId = sessionId;
+ }
purchase.merchantPaySig = payResponse.sig;
purchase.posConfirmation = payResponse.pos_confirmation;
return completePaymentState;
}
+export function applyPaySessionRequest(
+ purchase: WalletPurchase,
+ sessionId: string,
+): boolean {
+ if (purchase.lastSessionId === sessionId) {
+ return false;
+ }
+ switch (purchase.purchaseStatus) {
+ case PurchaseStatus.Done:
+ purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
+ break;
+ case PurchaseStatus.PendingPaying:
+ case PurchaseStatus.SuspendedPaying:
+ // The first /pay is still outstanding. Keep its recovery semantics;
+ // its success continuation promotes this requested session to replay.
+ break;
+ case PurchaseStatus.PendingPayingReplay:
+ case PurchaseStatus.SuspendedPayingReplay:
+ break;
+ default:
+ return false;
+ }
+ purchase.lastSessionId = sessionId;
+ return true;
+}
+
export async function storeFirstPaySuccess(
wex: WalletExecutionContext,
proposalId: string,
@@ -3045,6 +3097,15 @@ async function waitPaymentResult(
}
if (txRes.purchase.purchaseStatus >= PurchaseStatus.Done) {
+ if (waitSessionId !== undefined) {
+ // A terminal payment only proves the session recorded with its
+ // authenticated /pay or /paid response. A concurrent caller that
+ // requested another session must not be told that session is done.
+ return {
+ type: ConfirmPayResultType.Pending,
+ transactionId: ctx.transactionId,
+ };
+ }
return {
type: ConfirmPayResultType.Done,
contractTerms: d.contractTermsRaw,
@@ -3416,15 +3477,10 @@ export async function confirmPay(
const existingPurchase = await wex.runWalletDbTx(async (tx) => {
const purchase = await tx.getPurchase(proposalId);
if (
- purchase &&
+ purchase?.payInfo &&
sessionIdOverride !== undefined &&
- sessionIdOverride != purchase.lastSessionId
+ applyPaySessionRequest(purchase, sessionIdOverride)
) {
- logger.trace(`changing session ID to ${sessionIdOverride}`);
- purchase.lastSessionId = sessionIdOverride;
- if (purchase.purchaseStatus === PurchaseStatus.Done) {
- purchase.purchaseStatus = PurchaseStatus.PendingPayingReplay;
- }
await tx.upsertPurchase(purchase);
await ctx.updateTransactionMeta(tx);
}
@@ -3529,7 +3585,7 @@ export async function confirmPay(
}
let sessionId: string | undefined;
- if (sessionIdOverride) {
+ if (sessionIdOverride !== undefined) {
sessionId = sessionIdOverride;
} else {
sessionId = proposal.downloadSessionId;
@@ -3555,6 +3611,9 @@ export async function confirmPay(
"the selected payment choice changed while confirming",
);
}
+ if (sessionId !== undefined && applyPaySessionRequest(p, sessionId)) {
+ await h.update(p, "confirm-pay-session-replay", BalanceEffect.None);
+ }
return false;
}
switch (p.purchaseStatus) {
@@ -5669,17 +5728,53 @@ async function waitForRefreshOnAbortedPayment(
if (rec?.purchaseStatus !== PurchaseStatus.AbortingWithRefund) {
return false;
}
- await releasePaymentTokensInTx(
- tx,
- ctx.transactionId,
- rec.payInfo?.payTokenSelection?.tokenPubs ?? [],
- );
if (recovery === "recovered") {
+ const payCoinSelection = rec.payInfo?.payCoinSelection;
+ checkDbInvariant(!!payCoinSelection, "aborting payment has no coins");
+ checkDbInvariant(!!refreshGroup, "abort recovery refresh disappeared");
+ const relatedRefreshes =
+ await tx.getRefreshGroupsByOriginatingTransaction(ctx.transactionId);
+ const refundItems: WalletRefundItem[] = [];
+ const refundGroups = await tx.getRefundGroupsByProposal(rec.proposalId);
+ for (const refundGroup of refundGroups) {
+ refundItems.push(
+ ...(await tx.getRefundItemsByGroup(refundGroup.refundGroupId)),
+ );
+ }
+ const evidence = classifyMerchantAbortRecoveryEvidence(
+ payCoinSelection,
+ refreshGroup,
+ refundItems,
+ relatedRefreshes,
+ );
+ if (evidence === "pending") {
+ return false;
+ }
+ if (evidence === "failed") {
+ rec.purchaseStatus = PurchaseStatus.FailedAbort;
+ rec.failReason = makeErrorDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ {},
+ "merchant abort did not prove recovery of every deposited coin",
+ );
+ await h.update(rec, "abort-recovery-unproven");
+ return true;
+ }
+ await releasePaymentTokensInTx(
+ tx,
+ ctx.transactionId,
+ rec.payInfo?.payTokenSelection?.tokenPubs ?? [],
+ );
rec.purchaseStatus = rec.failReason
? recoveredPayFailureStatus(rec.failReason)
: finalStatus;
await h.update(rec, "abort-refresh-done");
} else {
+ await releasePaymentTokensInTx(
+ tx,
+ ctx.transactionId,
+ rec.payInfo?.payTokenSelection?.tokenPubs ?? [],
+ );
rec.purchaseStatus = PurchaseStatus.FailedAbort;
rec.failReason = makeErrorDetail(
TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
@@ -5694,6 +5789,85 @@ async function waitForRefreshOnAbortedPayment(
return didTransition ? TaskRunResult.progress() : TaskRunResult.backoff();
}
+export function classifyMerchantAbortRecoveryEvidence(
+ payCoinSelection: WalletCoinSelection,
+ abortRefresh: WalletRefreshGroup,
+ refundItems: WalletRefundItem[],
+ relatedRefreshes: WalletRefreshGroup[],
+): "recovered" | "pending" | "failed" {
+ if (
+ abortRefresh.oldCoinPubs.length !==
+ abortRefresh.expectedOutputPerCoin.length ||
+ abortRefresh.oldCoinPubs.length !== abortRefresh.statusPerCoin.length
+ ) {
+ return "failed";
+ }
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const abortCoinIndex = abortRefresh.oldCoinPubs.indexOf(coinPub);
+ if (abortCoinIndex < 0) {
+ return "failed";
+ }
+ const abortCoinStatus = abortRefresh.statusPerCoin[abortCoinIndex];
+ if (abortCoinStatus !== RefreshCoinStatus.Finished) {
+ return abortCoinStatus === RefreshCoinStatus.Pending ||
+ abortCoinStatus === RefreshCoinStatus.PendingRedenominate
+ ? "pending"
+ : "failed";
+ }
+ const abortOutput = abortRefresh.expectedOutputPerCoin[abortCoinIndex];
+ if (!abortOutput) {
+ return "failed";
+ }
+ if (Amounts.isNonZero(abortOutput)) {
+ continue;
+ }
+ const refundItem = refundItems.find(
+ (x) =>
+ x.coinPub === coinPub &&
+ x.rtxid === 0 &&
+ x.status !== RefundItemStatus.Failed,
+ );
+ if (!refundItem) {
+ return "failed";
+ }
+ if (refundItem.status === RefundItemStatus.Pending) {
+ return "pending";
+ }
+ const refundRefresh = relatedRefreshes.find(
+ (x) =>
+ x.reason === RefreshReason.Refund && x.oldCoinPubs.includes(coinPub),
+ );
+ if (!refundRefresh) {
+ return "failed";
+ }
+ const refundCoinIndex = refundRefresh.oldCoinPubs.indexOf(coinPub);
+ if (
+ refundCoinIndex >= refundRefresh.statusPerCoin.length ||
+ refundCoinIndex >= refundRefresh.expectedOutputPerCoin.length
+ ) {
+ return "failed";
+ }
+ const refundRecovery = classifyRecoveryRefresh(
+ refundRefresh.operationStatus,
+ );
+ if (
+ refundRecovery === "pending" ||
+ refundRefresh.statusPerCoin[refundCoinIndex] === RefreshCoinStatus.Pending
+ ) {
+ return "pending";
+ }
+ if (
+ refundRecovery !== "recovered" ||
+ refundRefresh.statusPerCoin[refundCoinIndex] !==
+ RefreshCoinStatus.Finished
+ ) {
+ return "failed";
+ }
+ }
+ return "recovered";
+}
+
/**
* Release token reservations owned by one payment.
*
@@ -5807,7 +5981,11 @@ async function processPurchaseAcceptRefund(
h_contract: download.contractTermsHash,
},
);
- if (isOrderUnknown(request) || request.case === HttpStatusCode.NoContent) {
+ if (
+ isOrderUnknown(request) ||
+ request.case === HttpStatusCode.NoContent ||
+ request.case === HttpStatusCode.Gone
+ ) {
// The merchant deleted the order, or has no refund for this payment
// after all, so nothing can be collected. The payment itself stays
// valid.
@@ -5819,7 +5997,12 @@ async function processPurchaseAcceptRefund(
}
p.purchaseStatus = PurchaseStatus.Done;
p.refundAmountAwaiting = undefined;
- await h.update(p, "accept-refund-none");
+ await h.update(
+ p,
+ request.case === HttpStatusCode.Gone
+ ? "accept-refund-expired"
+ : "accept-refund-none",
+ );
});
return TaskRunResult.progress();
}
@@ -5906,7 +6089,7 @@ async function computeRefreshRequest(
tx: WalletDbTransaction,
items: WalletRefundItem[],
): Promise<CoinRefreshRequest[]> {
- const refreshCoins: CoinRefreshRequest[] = [];
+ const refreshAmounts = new Map<string, AmountJson>();
const coins = await tx.getCoinsByPubs(items.map((item) => item.coinPub));
const coinsByPub = new Map(coins.map((coin) => [coin.coinPub, coin]));
const denoms = await getDenomInfos(wex, tx, coins);
@@ -5924,13 +6107,19 @@ async function computeRefreshRequest(
item.refundAmount,
denomInfo.feeRefund,
).amount;
- refreshCoins.push({
- amount: Amounts.stringify(refundedAmount),
- coinPub: item.coinPub,
- });
+ const previous =
+ refreshAmounts.get(item.coinPub) ??
+ Amounts.zeroOfAmount(refundedAmount);
+ refreshAmounts.set(
+ item.coinPub,
+ Amounts.add(previous, refundedAmount).amount,
+ );
}
}
- return refreshCoins;
+ return Array.from(refreshAmounts, ([coinPub, amount]) => ({
+ coinPub,
+ amount: Amounts.stringify(amount),
+ }));
}
/**
@@ -5940,7 +6129,7 @@ function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
if (rf.type === "success") {
return RefundItemStatus.Done;
} else {
- if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ if (isTransientRefundStatus(rf.exchange_status)) {
return RefundItemStatus.Pending;
} else {
return RefundItemStatus.Failed;
@@ -5948,17 +6137,22 @@ function getItemStatus(rf: MerchantCoinRefundStatus): RefundItemStatus {
}
}
+export function isTransientRefundStatus(status: number): boolean {
+ return (
+ status === HttpStatusCode.RequestTimeout ||
+ status === HttpStatusCode.TooManyRequests ||
+ (status >= 500 && status <= 599)
+ );
+}
+
export function getRefundTotals(
refundGroups: WalletRefundGroup[],
currency: string,
): { raw: AmountString; effective: AmountString } {
- // Only refunds that went through, or are still going through, count;
- // a failed or aborted group refunded nothing.
- const counted = refundGroups.filter(
- (x) =>
- x.status === RefundGroupStatus.Done ||
- x.status === RefundGroupStatus.Pending,
- );
+ // Group amounts contain only authenticated successful items. A group can
+ // therefore be Failed because one sibling failed while still carrying
+ // value that was successfully refunded and must remain visible.
+ const counted = refundGroups;
return {
raw: Amounts.stringify(
Amounts.sumOrZero(
@@ -6063,9 +6257,10 @@ async function storeRefunds(
}
let newGroup: WalletRefundGroup | undefined = undefined;
- // Pending, but not part of an aborted refund group.
- let numPendingItemsTotal = 0;
const newGroupRefunds: WalletRefundItem[] = [];
+ const newlySuccessfulRefunds = new Set<string>();
+ const refundKey = (coinPub: string, rtxid: number) =>
+ `${coinPub}\0${rtxid}`;
for (const rf of refunds) {
const oldItem = await tx.getRefundItemByCoinAndRtxid(
@@ -6079,10 +6274,13 @@ async function storeRefunds(
}
if (rf.type === "success") {
oldItem.status = RefundItemStatus.Done;
+ oldItem.refundAmount = rf.refund_amount;
+ oldItem.executionTime = timestampProtocolToDb(rf.execution_time);
+ oldItem.obtainedTime = timestampPreciseToDb(now);
+ newlySuccessfulRefunds.add(refundKey(oldItem.coinPub, oldItem.rtxid));
} else {
- if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ if (isTransientRefundStatus(rf.exchange_status)) {
oldItem.status = RefundItemStatus.Pending;
- numPendingItemsTotal += 1;
} else {
oldItem.status = RefundItemStatus.Failed;
}
@@ -6112,33 +6310,16 @@ async function storeRefunds(
rtxid: rf.rtransaction_id,
status,
};
- if (status === RefundItemStatus.Pending) {
- numPendingItemsTotal += 1;
- }
newGroupRefunds.push(newItem);
+ if (status === RefundItemStatus.Done) {
+ newlySuccessfulRefunds.add(refundKey(newItem.coinPub, newItem.rtxid));
+ }
await tx.upsertRefundItem(newItem);
}
}
- // Now that we know all the refunds for the new refund group,
- // we can compute the raw/effective amounts.
+ // Create the group before the common item-level accounting below.
if (newGroup) {
- const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
- const refreshCoins = await computeRefreshRequest(
- wex,
- tx,
- newGroupRefunds,
- );
- const outInfo = await calculateRefreshOutput(
- wex,
- tx,
- currency,
- refreshCoins,
- );
- setRefundGroupEffectiveAmount(newGroup, outInfo.outputPerCoin);
- newGroup.amountRaw = Amounts.stringify(
- Amounts.sumOrZero(currency, amountsRaw).amount,
- );
const refundCtx = new RefundTransactionContext(
wex,
newGroup.refundGroupId,
@@ -6159,6 +6340,7 @@ async function storeRefunds(
myPurchase.proposalId,
);
+ let numPendingItemsTotal = 0;
for (const refundGroup of refundGroups) {
const refundCtx = new RefundTransactionContext(
wex,
@@ -6167,23 +6349,61 @@ async function storeRefunds(
switch (refundGroup.status) {
case RefundGroupStatus.Aborted:
case RefundGroupStatus.Expired:
- case RefundGroupStatus.Failed:
case RefundGroupStatus.Done:
continue;
+ case RefundGroupStatus.Failed:
case RefundGroupStatus.Pending:
break;
default:
assertUnreachable(refundGroup.status);
}
const items = await tx.getRefundItemsByGroup(refundGroup.refundGroupId);
- const refreshCoins = await computeRefreshRequest(wex, tx, items);
- const outInfo = await calculateRefreshOutput(
+ const successfulItems = items.filter(
+ (x) => x.status === RefundItemStatus.Done,
+ );
+ refundGroup.amountRaw = Amounts.stringify(
+ Amounts.sumOrZero(
+ currency,
+ successfulItems.map((x) => x.refundAmount),
+ ).amount,
+ );
+ const newlySuccessfulItems = successfulItems.filter((x) =>
+ newlySuccessfulRefunds.has(refundKey(x.coinPub, x.rtxid)),
+ );
+ const newlyRefundedCoins = await computeRefreshRequest(
wex,
tx,
- currency,
- refreshCoins,
+ newlySuccessfulItems,
);
- setRefundGroupEffectiveAmount(refundGroup, outInfo.outputPerCoin);
+ if (newlyRefundedCoins.length > 0) {
+ const outInfo = await calculateRefreshOutput(
+ wex,
+ tx,
+ currency,
+ newlyRefundedCoins,
+ );
+ refundGroup.amountEffective = Amounts.stringify(
+ Amounts.add(
+ refundGroup.amountEffective,
+ Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
+ ).amount,
+ );
+ const paymentTransactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: myPurchase.proposalId,
+ });
+ const refreshResult = await createRefreshGroup(
+ wex,
+ tx,
+ currency,
+ newlyRefundedCoins,
+ RefreshReason.Refund,
+ // Refunds are pseudo-transactions, so their recovery refresh is
+ // associated with the payment transaction.
+ paymentTransactionId,
+ );
+ refundGroup.refreshGroupId = refreshResult.refreshGroupId;
+ }
let numPending = 0;
let numFailed = 0;
for (const item of items) {
@@ -6194,6 +6414,7 @@ async function storeRefunds(
numFailed++;
}
}
+ numPendingItemsTotal += numPending;
const oldTxState: TransactionState =
computeRefundTransactionState(refundGroup);
const oldStId = refundGroup.status;
@@ -6206,7 +6427,6 @@ async function storeRefunds(
}
await tx.upsertRefundGroup(refundGroup);
await refundCtx.updateTransactionMeta(tx);
- const refreshCoins = await computeRefreshRequest(wex, tx, items);
const newTxState: TransactionState =
computeRefundTransactionState(refundGroup);
const newStId = refundGroup.status;
@@ -6218,20 +6438,8 @@ async function storeRefunds(
newStId,
oldStId,
});
- await createRefreshGroup(
- wex,
- tx,
- Amounts.currencyOf(amountRaw),
- refreshCoins,
- RefreshReason.Refund,
- // Since refunds are really just pseudo-transactions,
- // the originating transaction for the refresh is the payment transaction.
- constructTransactionIdentifier({
- tag: TransactionType.Payment,
- proposalId: myPurchase.proposalId,
- }),
- );
} else {
+ refundGroup.status = RefundGroupStatus.Pending;
await tx.upsertRefundGroup(refundGroup);
}
}
diff --git a/packages/taler-wallet-core/src/pay-paivana-common.ts b/packages/taler-wallet-core/src/pay-paivana-common.ts
@@ -0,0 +1,63 @@
+/*
+ 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.
+*/
+
+import {
+ TalerError,
+ TalerErrorCode,
+ TalerProtocolTimestamp,
+} from "@gnu-taler/taler-util";
+
+/** Validate and canonicalize a protected Paivana resource URL. */
+export function canonicalizePaivanaUrl(url: string): string {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "url" },
+ `invalid Paivana URL "${url}"`,
+ );
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "url" },
+ "Paivana payments require an HTTP(S) URL",
+ );
+ }
+ if (parsed.username !== "" || parsed.password !== "") {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
+ { parameter: "url" },
+ "Paivana URLs must not contain credentials",
+ );
+ }
+ // Fragments are not sent in HTTP requests and therefore cannot be part of
+ // the URL reconstructed by the Paivana reverse proxy.
+ parsed.hash = "";
+ return parsed.href;
+}
+
+/** Check the externally visible DD 76 session-id shape and expiration. */
+export function isValidPaivanaSessionId(
+ sessionId: string,
+ now: TalerProtocolTimestamp = TalerProtocolTimestamp.now(),
+): boolean {
+ const match = /^([0-9]+)-[A-Za-z0-9_-]{43}$/.exec(sessionId);
+ if (!match || now.t_s === "never") {
+ return false;
+ }
+ const expiration = Number(match[1]);
+ return Number.isSafeInteger(expiration) && expiration >= now.t_s;
+}
diff --git a/packages/taler-wallet-core/src/pay-paivana.ts b/packages/taler-wallet-core/src/pay-paivana.ts
@@ -50,6 +50,9 @@ import {
parseTransactionIdentifier,
} from "./transactions.js";
import { WalletExecutionContext } from "./wallet.js";
+import { canonicalizePaivanaUrl } from "./pay-paivana-common.js";
+
+export { canonicalizePaivanaUrl } from "./pay-paivana-common.js";
const MAX_PAIVANA_PICKUP_SECONDS = 60 * 60 * 24 * 365 * 100;
@@ -66,38 +69,6 @@ function malformedPaivanaResponse(
);
}
-/** Validate and canonicalize a protected Paivana resource URL. */
-export function canonicalizePaivanaUrl(url: string): string {
- let parsed: URL;
- try {
- parsed = new URL(url);
- } catch {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
- { parameter: "url" },
- `invalid Paivana URL "${url}"`,
- );
- }
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
- { parameter: "url" },
- "Paivana payments require an HTTP(S) URL",
- );
- }
- if (parsed.username !== "" || parsed.password !== "") {
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_CORE_API_BAD_REQUEST,
- { parameter: "url" },
- "Paivana URLs must not contain credentials",
- );
- }
- // Fragments are not sent in HTTP requests and therefore cannot be part of
- // the URL reconstructed by the Paivana reverse proxy.
- parsed.hash = "";
- return parsed.href;
-}
-
/** Encode a Paivana expiration as an unsigned big-endian microsecond value. */
export function encodePaivanaExpiration(
expiration: TalerProtocolTimestamp,
diff --git a/packages/taler-wallet-core/src/pay-template.test.ts b/packages/taler-wallet-core/src/pay-template.test.ts
@@ -20,9 +20,13 @@ import {
HostPortPath,
TalerPayTemplateUri,
TalerUriAction,
+ TalerUris,
TemplateContractDetails,
TemplateContractDetailsDefaults,
TemplateType,
+ TalerError,
+ TalerErrorCode,
+ WalletTemplateDetailsResponse,
} from "@gnu-taler/taler-util";
import assert from "node:assert";
import { test } from "node:test";
@@ -143,3 +147,71 @@ test("template order creation is not retried after an ambiguous failure", async
);
assert.strictEqual(requestCount, 1);
});
+
+test("Paivana instantiation requires its session and fulfillment URL", async () => {
+ await assert.rejects(
+ instantiateTemplateRaw(
+ {} as WalletExecutionContext,
+ {
+ talerPayTemplateUri:
+ "taler://pay-template/merchant.example/template-id",
+ },
+ {
+ template_contract: {
+ template_type: TemplateType.PAIVANA,
+ choices: [],
+ },
+ },
+ ),
+ (e: unknown) =>
+ e instanceof TalerError &&
+ e.errorDetail.code === TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ );
+});
+
+test("Paivana instantiation rejects unredeemable URI bindings", async () => {
+ const templateInfo: WalletTemplateDetailsResponse = {
+ template_contract: {
+ template_type: TemplateType.PAIVANA,
+ choices: [],
+ },
+ };
+ const makeUri = (sessionId: string, fulfillmentUrl: string) =>
+ TalerUris.stringify({
+ ...paivanaUri("KUDOS:1" as AmountString),
+ sessionId,
+ fulfillmentUrl,
+ });
+ const futureSession = `${Math.floor(Date.now() / 1000) + 3600}-${"A".repeat(43)}`;
+
+ await assert.rejects(
+ instantiateTemplateRaw(
+ {} as WalletExecutionContext,
+ {
+ talerPayTemplateUri: makeUri(
+ futureSession,
+ "https://user@example.com/protected",
+ ),
+ },
+ templateInfo,
+ ),
+ (e: unknown) =>
+ e instanceof TalerError &&
+ e.errorDetail.code === TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ );
+ await assert.rejects(
+ instantiateTemplateRaw(
+ {} as WalletExecutionContext,
+ {
+ talerPayTemplateUri: makeUri(
+ "1-not-a-dd76-session",
+ "https://example.com/protected",
+ ),
+ },
+ templateInfo,
+ ),
+ (e: unknown) =>
+ e instanceof TalerError &&
+ e.errorDetail.code === TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ );
+});
diff --git a/packages/taler-wallet-core/src/pay-template.ts b/packages/taler-wallet-core/src/pay-template.ts
@@ -44,6 +44,10 @@ import {
runWithProgressRetries,
} from "./progress.js";
import { WalletExecutionContext } from "./wallet.js";
+import {
+ canonicalizePaivanaUrl,
+ isValidPaivanaSessionId,
+} from "./pay-paivana-common.js";
const logger = new Logger("pay-template.ts");
@@ -301,10 +305,46 @@ export async function instantiateTemplateRaw(
break;
}
case TalerMerchantApi.TemplateType.PAIVANA: {
+ if (!parsedUri.sessionId || !parsedUri.fulfillmentUrl) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ {
+ talerUri: req.talerPayTemplateUri,
+ expectedAction: TalerUriAction.PayTemplate,
+ },
+ "Paivana payment template URI requires session_id and fulfillment_url",
+ );
+ }
+ let website: string;
+ try {
+ website = canonicalizePaivanaUrl(parsedUri.fulfillmentUrl);
+ } catch {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ {
+ talerUri: req.talerPayTemplateUri,
+ expectedAction: TalerUriAction.PayTemplate,
+ },
+ "Paivana payment template URI has an invalid fulfillment_url",
+ );
+ }
+ if (
+ website !== parsedUri.fulfillmentUrl ||
+ !isValidPaivanaSessionId(parsedUri.sessionId)
+ ) {
+ throw TalerError.fromDetail(
+ TalerErrorCode.WALLET_TALER_URI_MALFORMED,
+ {
+ talerUri: req.talerPayTemplateUri,
+ expectedAction: TalerUriAction.PayTemplate,
+ },
+ "Paivana template URI has a non-canonical URL or invalid session",
+ );
+ }
templateDetails = {
template_type: TemplateType.PAIVANA,
- paivana_id: parsedUri.sessionId!,
- website: parsedUri.fulfillmentUrl!,
+ paivana_id: parsedUri.sessionId,
+ website,
};
break;
}
diff --git a/packages/taler-wallet-core/src/tokenFamilies.ts b/packages/taler-wallet-core/src/tokenFamilies.ts
@@ -130,6 +130,7 @@ export async function listDiscounts(
: await tx.listTokens();
return candidates
.filter((t) => isTokenValid(t))
+ .filter((t) => !isTokenInUse(t))
.filter((t) => t.kind === MerchantContractTokenKind.Discount)
.filter(
(t) => !tokenIssuePubHash || t.tokenIssuePubHash === tokenIssuePubHash,
@@ -157,6 +158,7 @@ export async function listSubscriptions(
: await tx.listTokens();
return candidates
.filter((t) => isTokenValid(t))
+ .filter((t) => !isTokenInUse(t))
.filter((t) => t.kind === MerchantContractTokenKind.Subscription)
.filter(
(t) => !tokenIssuePubHash || t.tokenIssuePubHash === tokenIssuePubHash,