summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2023-05-05 19:03:44 +0200
committerFlorian Dold <florian@dold.me>2023-05-07 21:51:02 +0200
commit7f0edb6a783d9a50f94f65c815c1280baecaac89 (patch)
treeb4f3bc24fece1f651e8c2f69fc0fcf52e1d6f330 /packages/taler-wallet-core/src
parenta0bf83fbb5db026389cc7d203adcff050d5a1b28 (diff)
downloadwallet-core-7f0edb6a783d9a50f94f65c815c1280baecaac89.tar.gz
wallet-core-7f0edb6a783d9a50f94f65c815c1280baecaac89.tar.bz2
wallet-core-7f0edb6a783d9a50f94f65c815c1280baecaac89.zip
wallet-core: refund DD37 refactoring
Diffstat (limited to 'packages/taler-wallet-core/src')
-rw-r--r--packages/taler-wallet-core/src/db.ts189
-rw-r--r--packages/taler-wallet-core/src/operations/backup/export.ts59
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts95
-rw-r--r--packages/taler-wallet-core/src/operations/pay-merchant.ts1460
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts111
-rw-r--r--packages/taler-wallet-core/src/operations/testing.ts6
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts310
-rw-r--r--packages/taler-wallet-core/src/util/query.ts2
-rw-r--r--packages/taler-wallet-core/src/util/retries.ts5
-rw-r--r--packages/taler-wallet-core/src/wallet-api-types.ts35
-rw-r--r--packages/taler-wallet-core/src/wallet.ts33
11 files changed, 1013 insertions, 1292 deletions
diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts
index d1258f2f9..92781d2ed 100644
--- a/packages/taler-wallet-core/src/db.ts
+++ b/packages/taler-wallet-core/src/db.ts
@@ -118,7 +118,7 @@ export const CURRENT_DB_CONFIG_KEY = "currentMainDbName";
* backwards-compatible way or object stores and indices
* are added.
*/
-export const WALLET_DB_MINOR_VERSION = 6;
+export const WALLET_DB_MINOR_VERSION = 7;
/**
* Ranges for operation status fields.
@@ -208,7 +208,7 @@ export enum WithdrawalGroupStatus {
* talk to the exchange. Money might have been
* wired or not.
*/
- AbortedExchange = 60
+ AbortedExchange = 60,
}
/**
@@ -1012,63 +1012,6 @@ export interface RefreshSessionRecord {
norevealIndex?: number;
}
-export enum RefundState {
- Failed = "failed",
- Applied = "applied",
- Pending = "pending",
-}
-
-/**
- * State of one refund from the merchant, maintained by the wallet.
- */
-export type WalletRefundItem =
- | WalletRefundFailedItem
- | WalletRefundPendingItem
- | WalletRefundAppliedItem;
-
-export interface WalletRefundItemCommon {
- // Execution time as claimed by the merchant
- executionTime: TalerProtocolTimestamp;
-
- /**
- * Time when the wallet became aware of the refund.
- */
- obtainedTime: TalerProtocolTimestamp;
-
- refundAmount: AmountString;
-
- refundFee: AmountString;
-
- /**
- * Upper bound on the refresh cost incurred by
- * applying this refund.
- *
- * Might be lower in practice when two refunds on the same
- * coin are refreshed in the same refresh operation.
- */
- totalRefreshCostBound: AmountString;
-
- coinPub: string;
-
- rtransactionId: number;
-}
-
-/**
- * Failed refund, either because the merchant did
- * something wrong or it expired.
- */
-export interface WalletRefundFailedItem extends WalletRefundItemCommon {
- type: RefundState.Failed;
-}
-
-export interface WalletRefundPendingItem extends WalletRefundItemCommon {
- type: RefundState.Pending;
-}
-
-export interface WalletRefundAppliedItem extends WalletRefundItemCommon {
- type: RefundState.Applied;
-}
-
export enum RefundReason {
/**
* Normal refund given by the merchant.
@@ -1161,6 +1104,8 @@ export enum PurchaseStatus {
*/
QueryingAutoRefund = 15,
+ PendingAcceptRefund = 16,
+
/**
* Proposal downloaded, but the user needs to accept/reject it.
*/
@@ -1169,12 +1114,12 @@ export enum PurchaseStatus {
/**
* The user has rejected the proposal.
*/
- ProposalRefused = 50,
+ AbortedProposalRefused = 50,
/**
* Downloading or processing the proposal has failed permanently.
*/
- ProposalDownloadFailed = 51,
+ FailedClaim = 51,
/**
* Downloaded proposal was detected as a re-purchase.
@@ -1184,12 +1129,12 @@ export enum PurchaseStatus {
/**
* The payment has been aborted.
*/
- PaymentAbortFinished = 53,
+ AbortedIncompletePayment = 53,
/**
* Payment was successful.
*/
- Paid = 54,
+ Done = 54,
}
/**
@@ -1303,7 +1248,7 @@ export interface PurchaseRecord {
*
* FIXME: Put this into a separate object store?
*/
- refunds: { [refundKey: string]: WalletRefundItem };
+ // refunds: { [refundKey: string]: WalletRefundItem };
/**
* When was the last refund made?
@@ -2152,6 +2097,97 @@ export interface CurrencySettingsRecord {
// Later, we might add stuff related to how the currency is rendered.
}
+export enum RefundGroupStatus {
+ Pending = 10,
+ Done = 50,
+ Failed = 51,
+ Aborted = 52,
+}
+
+/**
+ * Metadata about a group of refunds with the merchant.
+ */
+export interface RefundGroupRecord {
+ status: RefundGroupStatus;
+
+ /**
+ * Timestamp when the refund group was created.
+ */
+ timestampCreated: TalerProtocolTimestamp;
+
+ proposalId: string;
+
+ refundGroupId: string;
+
+ refreshGroupId?: string;
+
+ amountRaw: AmountString;
+
+ /**
+ * Estimated effective amount, based on
+ * refund fees and refresh costs.
+ */
+ amountEffective: AmountString;
+}
+
+export enum RefundItemStatus {
+ /**
+ * Intermittent error that the merchant is
+ * reporting from the exchange.
+ *
+ * We'll try again!
+ */
+ Pending = 10,
+ /**
+ * Refund was obtained successfully.
+ */
+ Done = 50,
+ /**
+ * Permanent error reported by the exchange
+ * for the refund.
+ */
+ Failed = 51,
+}
+
+/**
+ * Refund for a single coin in a payment with a merchant.
+ */
+export interface RefundItemRecord {
+ /**
+ * Auto-increment DB record ID.
+ */
+ id?: number;
+
+ status: RefundItemStatus;
+
+ refundGroupId: string;
+
+ // Execution time as claimed by the merchant
+ executionTime: TalerProtocolTimestamp;
+
+ /**
+ * Time when the wallet became aware of the refund.
+ */
+ obtainedTime: TalerProtocolTimestamp;
+
+ refundAmount: AmountString;
+
+ //refundFee: AmountString;
+
+ /**
+ * Upper bound on the refresh cost incurred by
+ * applying this refund.
+ *
+ * Might be lower in practice when two refunds on the same
+ * coin are refreshed in the same refresh operation.
+ */
+ //totalRefreshCostBound: AmountString;
+
+ coinPub: string;
+
+ rtxid: number;
+}
+
/**
* Schema definition for the IndexedDB
* wallet database.
@@ -2494,6 +2530,31 @@ export const WalletStoresV1 = {
}),
{},
),
+ refundGroups: describeStore(
+ "refundGroups",
+ describeContents<RefundGroupRecord>({
+ keyPath: "refundGroupId",
+ versionAdded: 7,
+ }),
+ {
+ byProposalId: describeIndex("byProposalId", "proposalId"),
+ },
+ ),
+ refundItems: describeStore(
+ "refundItems",
+ describeContents<RefundItemRecord>({
+ keyPath: "id",
+ versionAdded: 7,
+ autoIncrement: true,
+ }),
+ {
+ byCoinPubAndRtxid: describeIndex("byCoinPubAndRtxid", [
+ "coinPub",
+ "rtxid",
+ ]),
+ byRefundGroupId: describeIndex("byRefundGroupId", ["refundGroupId"]),
+ },
+ ),
fixups: describeStore(
"fixups",
describeContents<FixupRecord>({
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts
index 68f8beb93..7b245a4eb 100644
--- a/packages/taler-wallet-core/src/operations/backup/export.ts
+++ b/packages/taler-wallet-core/src/operations/backup/export.ts
@@ -69,7 +69,6 @@ import {
DenominationRecord,
PurchaseStatus,
RefreshCoinStatus,
- RefundState,
WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../../db.js";
@@ -384,34 +383,34 @@ export async function exportBackup(
await tx.purchases.iter().forEachAsync(async (purch) => {
const refunds: BackupRefundItem[] = [];
purchaseProposalIdSet.add(purch.proposalId);
- for (const refundKey of Object.keys(purch.refunds)) {
- const ri = purch.refunds[refundKey];
- const common = {
- coin_pub: ri.coinPub,
- execution_time: ri.executionTime,
- obtained_time: ri.obtainedTime,
- refund_amount: Amounts.stringify(ri.refundAmount),
- rtransaction_id: ri.rtransactionId,
- total_refresh_cost_bound: Amounts.stringify(
- ri.totalRefreshCostBound,
- ),
- };
- switch (ri.type) {
- case RefundState.Applied:
- refunds.push({ type: BackupRefundState.Applied, ...common });
- break;
- case RefundState.Failed:
- refunds.push({ type: BackupRefundState.Failed, ...common });
- break;
- case RefundState.Pending:
- refunds.push({ type: BackupRefundState.Pending, ...common });
- break;
- }
- }
+ // for (const refundKey of Object.keys(purch.refunds)) {
+ // const ri = purch.refunds[refundKey];
+ // const common = {
+ // coin_pub: ri.coinPub,
+ // execution_time: ri.executionTime,
+ // obtained_time: ri.obtainedTime,
+ // refund_amount: Amounts.stringify(ri.refundAmount),
+ // rtransaction_id: ri.rtransactionId,
+ // total_refresh_cost_bound: Amounts.stringify(
+ // ri.totalRefreshCostBound,
+ // ),
+ // };
+ // switch (ri.type) {
+ // case RefundState.Applied:
+ // refunds.push({ type: BackupRefundState.Applied, ...common });
+ // break;
+ // case RefundState.Failed:
+ // refunds.push({ type: BackupRefundState.Failed, ...common });
+ // break;
+ // case RefundState.Pending:
+ // refunds.push({ type: BackupRefundState.Pending, ...common });
+ // break;
+ // }
+ // }
let propStatus: BackupProposalStatus;
switch (purch.purchaseStatus) {
- case PurchaseStatus.Paid:
+ case PurchaseStatus.Done:
case PurchaseStatus.QueryingAutoRefund:
case PurchaseStatus.QueryingRefund:
propStatus = BackupProposalStatus.Paid;
@@ -422,19 +421,19 @@ export async function exportBackup(
case PurchaseStatus.Paying:
propStatus = BackupProposalStatus.Proposed;
break;
- case PurchaseStatus.ProposalDownloadFailed:
- case PurchaseStatus.PaymentAbortFinished:
+ case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.AbortedIncompletePayment:
propStatus = BackupProposalStatus.PermanentlyFailed;
break;
case PurchaseStatus.AbortingWithRefund:
- case PurchaseStatus.ProposalRefused:
+ case PurchaseStatus.AbortedProposalRefused:
propStatus = BackupProposalStatus.Refused;
break;
case PurchaseStatus.RepurchaseDetected:
propStatus = BackupProposalStatus.Repurchase;
break;
default: {
- const error: never = purch.purchaseStatus;
+ const error = purch.purchaseStatus;
throw Error(`purchase status ${error} is not handled`);
}
}
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts
index 296517162..5375a58bb 100644
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -49,9 +49,7 @@ import {
PurchasePayInfo,
RefreshCoinStatus,
RefreshSessionRecord,
- RefundState,
WalletContractData,
- WalletRefundItem,
WalletStoresV1,
WgInfo,
WithdrawalGroupStatus,
@@ -65,7 +63,6 @@ import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
import {
makeCoinAvailable,
makeTombstoneId,
- makeTransactionId,
TombstoneTag,
} from "../common.js";
import { getExchangeDetails } from "../exchanges.js";
@@ -576,16 +573,16 @@ export async function importBackup(
let proposalStatus: PurchaseStatus;
switch (backupPurchase.proposal_status) {
case BackupProposalStatus.Paid:
- proposalStatus = PurchaseStatus.Paid;
+ proposalStatus = PurchaseStatus.Done;
break;
case BackupProposalStatus.Proposed:
proposalStatus = PurchaseStatus.Proposed;
break;
case BackupProposalStatus.PermanentlyFailed:
- proposalStatus = PurchaseStatus.PaymentAbortFinished;
+ proposalStatus = PurchaseStatus.AbortedIncompletePayment;
break;
case BackupProposalStatus.Refused:
- proposalStatus = PurchaseStatus.ProposalRefused;
+ proposalStatus = PurchaseStatus.AbortedProposalRefused;
break;
case BackupProposalStatus.Repurchase:
proposalStatus = PurchaseStatus.RepurchaseDetected;
@@ -596,48 +593,48 @@ export async function importBackup(
}
}
if (!existingPurchase) {
- const refunds: { [refundKey: string]: WalletRefundItem } = {};
- for (const backupRefund of backupPurchase.refunds) {
- const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
- const coin = await tx.coins.get(backupRefund.coin_pub);
- checkBackupInvariant(!!coin);
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- checkBackupInvariant(!!denom);
- const common = {
- coinPub: backupRefund.coin_pub,
- executionTime: backupRefund.execution_time,
- obtainedTime: backupRefund.obtained_time,
- refundAmount: Amounts.stringify(backupRefund.refund_amount),
- refundFee: Amounts.stringify(denom.fees.feeRefund),
- rtransactionId: backupRefund.rtransaction_id,
- totalRefreshCostBound: Amounts.stringify(
- backupRefund.total_refresh_cost_bound,
- ),
- };
- switch (backupRefund.type) {
- case BackupRefundState.Applied:
- refunds[key] = {
- type: RefundState.Applied,
- ...common,
- };
- break;
- case BackupRefundState.Failed:
- refunds[key] = {
- type: RefundState.Failed,
- ...common,
- };
- break;
- case BackupRefundState.Pending:
- refunds[key] = {
- type: RefundState.Pending,
- ...common,
- };
- break;
- }
- }
+ //const refunds: { [refundKey: string]: WalletRefundItem } = {};
+ // for (const backupRefund of backupPurchase.refunds) {
+ // const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`;
+ // const coin = await tx.coins.get(backupRefund.coin_pub);
+ // checkBackupInvariant(!!coin);
+ // const denom = await tx.denominations.get([
+ // coin.exchangeBaseUrl,
+ // coin.denomPubHash,
+ // ]);
+ // checkBackupInvariant(!!denom);
+ // const common = {
+ // coinPub: backupRefund.coin_pub,
+ // executionTime: backupRefund.execution_time,
+ // obtainedTime: backupRefund.obtained_time,
+ // refundAmount: Amounts.stringify(backupRefund.refund_amount),
+ // refundFee: Amounts.stringify(denom.fees.feeRefund),
+ // rtransactionId: backupRefund.rtransaction_id,
+ // totalRefreshCostBound: Amounts.stringify(
+ // backupRefund.total_refresh_cost_bound,
+ // ),
+ // };
+ // switch (backupRefund.type) {
+ // case BackupRefundState.Applied:
+ // refunds[key] = {
+ // type: RefundState.Applied,
+ // ...common,
+ // };
+ // break;
+ // case BackupRefundState.Failed:
+ // refunds[key] = {
+ // type: RefundState.Failed,
+ // ...common,
+ // };
+ // break;
+ // case BackupRefundState.Pending:
+ // refunds[key] = {
+ // type: RefundState.Pending,
+ // ...common,
+ // };
+ // break;
+ // }
+ // }
const parsedContractTerms = codecForMerchantContractTerms().decode(
backupPurchase.contract_terms_raw,
);
@@ -694,7 +691,7 @@ export async function importBackup(
posConfirmation: backupPurchase.pos_confirmation,
lastSessionId: undefined,
download,
- refunds,
+ //refunds,
claimToken: backupPurchase.claim_token,
downloadSessionId: backupPurchase.download_session_id,
merchantBaseUrl: backupPurchase.merchant_base_url,
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts
index 6aad1d742..99b9a18d2 100644
--- a/packages/taler-wallet-core/src/operations/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -58,19 +58,23 @@ import {
MerchantCoinRefundSuccessStatus,
MerchantContractTerms,
MerchantPayResponse,
+ MerchantRefundResponse,
NotificationType,
parsePayUri,
parseRefundUri,
+ parseTalerUri,
PayCoinSelection,
PreparePayResult,
PreparePayResultType,
PrepareRefundResult,
+ randomBytes,
RefreshReason,
TalerError,
TalerErrorCode,
TalerErrorDetail,
TalerProtocolTimestamp,
TalerProtocolViolationError,
+ TalerUriAction,
TransactionMajorState,
TransactionMinorState,
TransactionState,
@@ -93,11 +97,16 @@ import {
PurchaseRecord,
PurchaseStatus,
RefundReason,
- RefundState,
WalletContractData,
WalletStoresV1,
} from "../db.js";
-import { GetReadWriteAccess, PendingTaskType } from "../index.js";
+import {
+ PendingTaskType,
+ RefundGroupRecord,
+ RefundGroupStatus,
+ RefundItemRecord,
+ RefundItemStatus,
+} from "../index.js";
import {
EXCHANGE_COINS_LOCK,
InternalWalletState,
@@ -116,10 +125,19 @@ import {
} from "../util/retries.js";
import {
makeTransactionId,
+ runLongpollAsync,
runOperationWithErrorReporting,
spendCoins,
} from "./common.js";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
+import {
+ calculateRefreshOutput,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
/**
* Logger.
@@ -193,7 +211,7 @@ async function failProposalPermanently(
if (!p) {
return;
}
- p.purchaseStatus = PurchaseStatus.ProposalDownloadFailed;
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
await tx.purchases.put(p);
});
}
@@ -601,7 +619,6 @@ async function startDownloadProposal(
merchantPaySig: undefined,
payInfo: undefined,
refundAmountAwaiting: undefined,
- refunds: {},
timestampAccept: undefined,
timestampFirstSuccessfulPay: undefined,
timestampLastRefundStatus: undefined,
@@ -649,7 +666,7 @@ async function storeFirstPaySuccess(
return;
}
if (purchase.purchaseStatus === PurchaseStatus.Paying) {
- purchase.purchaseStatus = PurchaseStatus.Paid;
+ purchase.purchaseStatus = PurchaseStatus.Done;
}
purchase.timestampFirstSuccessfulPay = now;
purchase.lastSessionId = sessionId;
@@ -701,7 +718,7 @@ async function storePayReplaySuccess(
purchase.purchaseStatus === PurchaseStatus.Paying ||
purchase.purchaseStatus === PurchaseStatus.PayingReplay
) {
- purchase.purchaseStatus = PurchaseStatus.Paid;
+ purchase.purchaseStatus = PurchaseStatus.Done;
}
purchase.lastSessionId = sessionId;
await tx.purchases.put(purchase);
@@ -899,6 +916,11 @@ export async function checkPaymentByProposalId(
proposalId = proposal.proposalId;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
const talerUri = constructPayUri(
proposal.merchantBaseUrl,
proposal.orderId,
@@ -937,6 +959,7 @@ export async function checkPaymentByProposalId(
status: PreparePayResultType.InsufficientBalance,
contractTerms: d.contractTermsRaw,
proposalId: proposal.proposalId,
+ transactionId,
noncePriv: proposal.noncePriv,
amountRaw: Amounts.stringify(d.contractData.amount),
talerUri,
@@ -951,6 +974,7 @@ export async function checkPaymentByProposalId(
return {
status: PreparePayResultType.PaymentPossible,
contractTerms: d.contractTermsRaw,
+ transactionId,
proposalId: proposal.proposalId,
noncePriv: proposal.noncePriv,
amountEffective: Amounts.stringify(totalCost),
@@ -961,7 +985,7 @@ export async function checkPaymentByProposalId(
}
if (
- purchase.purchaseStatus === PurchaseStatus.Paid &&
+ purchase.purchaseStatus === PurchaseStatus.Done &&
purchase.lastSessionId !== sessionId
) {
logger.trace(
@@ -992,6 +1016,7 @@ export async function checkPaymentByProposalId(
paid: true,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ transactionId,
proposalId,
talerUri,
};
@@ -1004,12 +1029,13 @@ export async function checkPaymentByProposalId(
paid: false,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ transactionId,
proposalId,
talerUri,
};
} else {
const paid =
- purchase.purchaseStatus === PurchaseStatus.Paid ||
+ purchase.purchaseStatus === PurchaseStatus.Done ||
purchase.purchaseStatus === PurchaseStatus.QueryingRefund ||
purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund;
const download = await expectProposalDownload(ws, purchase);
@@ -1021,6 +1047,7 @@ export async function checkPaymentByProposalId(
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ transactionId,
proposalId,
talerUri,
};
@@ -1244,7 +1271,7 @@ export async function confirmPay(
) {
logger.trace(`changing session ID to ${sessionIdOverride}`);
purchase.lastSessionId = sessionIdOverride;
- if (purchase.purchaseStatus === PurchaseStatus.Paid) {
+ if (purchase.purchaseStatus === PurchaseStatus.Done) {
purchase.purchaseStatus = PurchaseStatus.PayingReplay;
}
await tx.purchases.put(purchase);
@@ -1331,7 +1358,7 @@ export async function confirmPay(
refreshReason: RefreshReason.PayMerchant,
});
break;
- case PurchaseStatus.Paid:
+ case PurchaseStatus.Done:
case PurchaseStatus.Paying:
default:
break;
@@ -1371,20 +1398,24 @@ export async function processPurchase(
switch (purchase.purchaseStatus) {
case PurchaseStatus.DownloadingProposal:
- return processDownloadProposal(ws, proposalId, options);
+ return processDownloadProposal(ws, proposalId);
case PurchaseStatus.Paying:
case PurchaseStatus.PayingReplay:
- return processPurchasePay(ws, proposalId, options);
+ return processPurchasePay(ws, proposalId);
case PurchaseStatus.QueryingRefund:
+ return processPurchaseQueryRefund(ws, purchase);
case PurchaseStatus.QueryingAutoRefund:
+ return processPurchaseAutoRefund(ws, purchase);
case PurchaseStatus.AbortingWithRefund:
- return processPurchaseQueryRefund(ws, proposalId, options);
- case PurchaseStatus.ProposalDownloadFailed:
- case PurchaseStatus.Paid:
+ return processPurchaseAbortingRefund(ws, purchase);
+ case PurchaseStatus.PendingAcceptRefund:
+ return processPurchaseAcceptRefund(ws, purchase);
+ case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.Done:
case PurchaseStatus.RepurchaseDetected:
case PurchaseStatus.Proposed:
- case PurchaseStatus.ProposalRefused:
- case PurchaseStatus.PaymentAbortFinished:
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedIncompletePayment:
return {
type: OperationAttemptResultType.Finished,
result: undefined,
@@ -1588,7 +1619,7 @@ export async function refuseProposal(
if (proposal.purchaseStatus !== PurchaseStatus.Proposed) {
return false;
}
- proposal.purchaseStatus = PurchaseStatus.ProposalRefused;
+ proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
await tx.purchases.put(proposal);
return true;
});
@@ -1599,603 +1630,324 @@ export async function refuseProposal(
}
}
-export async function prepareRefund(
- ws: InternalWalletState,
- talerRefundUri: string,
-): Promise<PrepareRefundResult> {
- const parseResult = parseRefundUri(talerRefundUri);
-
- logger.trace("preparing refund offer", parseResult);
-
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
-
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.get([
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
- });
-
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
- );
- }
-
- const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
- const summary = await calculateRefundSummary(ws, purchase);
- const proposalId = purchase.proposalId;
-
- const { contractData: c } = await expectProposalDownload(ws, purchase);
-
- return {
- proposalId,
- effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
- gone: Amounts.stringify(summary.amountRefundGone),
- granted: Amounts.stringify(summary.amountRefundGranted),
- pending: summary.pendingAtExchange,
- awaiting: Amounts.stringify(awaiting),
- info: {
- contractTermsHash: c.contractTermsHash,
- merchant: c.merchant,
- orderId: c.orderId,
- products: c.products,
- summary: c.summary,
- fulfillmentMessage: c.fulfillmentMessage,
- summary_i18n: c.summaryI18n,
- fulfillmentMessage_i18n: c.fulfillmentMessageI18n,
- },
- };
-}
-
-function getRefundKey(d: MerchantCoinRefundStatus): string {
- return `${d.coin_pub}-${d.rtransaction_id}`;
-}
-
-async function applySuccessfulRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, CoinRefreshRequest>,
- r: MerchantCoinRefundSuccessStatus,
- denomselAllowLate: boolean,
-): Promise<void> {
- // FIXME: check signature before storing it as valid!
-
- const refundKey = getRefundKey(r);
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("inconsistent database");
- }
- const refundAmount = Amounts.parseOrThrow(r.refund_amount);
- const refundFee = denom.fees.feeRefund;
- const amountLeft = Amounts.sub(refundAmount, refundFee).amount;
- coin.status = CoinStatus.Dormant;
- await tx.coins.put(coin);
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- denomselAllowLate,
- );
-
- refreshCoinsMap[coin.coinPub] = {
- coinPub: coin.coinPub,
- amount: Amounts.stringify(amountLeft),
- };
-
- p.refunds[refundKey] = {
- type: RefundState.Applied,
- obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
- executionTime: r.execution_time,
- refundAmount: Amounts.stringify(r.refund_amount),
- refundFee: Amounts.stringify(denom.fees.feeRefund),
- totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound),
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storePendingRefund(
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- }>,
- p: PurchaseRecord,
- r: MerchantCoinRefundFailureStatus,
- denomselAllowLate: boolean,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- // Refunded amount after fees.
- const amountLeft = Amounts.sub(
- Amounts.parseOrThrow(r.refund_amount),
- denom.fees.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- denomselAllowLate,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Pending,
- obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
- executionTime: r.execution_time,
- refundAmount: Amounts.stringify(r.refund_amount),
- refundFee: Amounts.stringify(denom.fees.feeRefund),
- totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound),
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storeFailedRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, CoinRefreshRequest>,
- r: MerchantCoinRefundFailureStatus,
- denomselAllowLate: boolean,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.parseOrThrow(r.refund_amount),
- denom.fees.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- denomselAllowLate,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Failed,
- obtainedTime: TalerProtocolTimestamp.now(),
- executionTime: r.execution_time,
- refundAmount: Amounts.stringify(r.refund_amount),
- refundFee: Amounts.stringify(denom.fees.feeRefund),
- totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound),
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-
- if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- // Refund failed because the merchant didn't even try to deposit
- // the coin yet, so we try to refresh.
- // FIXME: Is this case tested?!
- if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination for coin missing");
- return;
- }
- const payCoinSelection = p.payInfo?.payCoinSelection;
- if (!payCoinSelection) {
- logger.warn("no pay coin selection, can't apply refund");
- return;
- }
- let contrib: AmountJson | undefined;
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- if (payCoinSelection.coinPubs[i] === r.coin_pub) {
- contrib = Amounts.parseOrThrow(payCoinSelection.coinContributions[i]);
- }
- }
- // FIXME: Is this case tested?!
- refreshCoinsMap[coin.coinPub] = {
- coinPub: coin.coinPub,
- amount: Amounts.stringify(amountLeft),
- };
- await tx.coins.put(coin);
- }
- }
-}
-
-async function acceptRefunds(
+export async function abortPayMerchant(
ws: InternalWalletState,
proposalId: string,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
): Promise<void> {
- logger.trace("handling refunds", refunds);
- const now = TalerProtocolTimestamp.now();
-
+ const opId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
await ws.db
.mktx((x) => [
x.purchases,
- x.coins,
- x.coinAvailability,
- x.denominations,
x.refreshGroups,
+ x.denominations,
+ x.coinAvailability,
+ x.coins,
+ x.operationRetries,
])
.runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("purchase not found, not adding refunds");
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldStatus = purchase.purchaseStatus;
+ if (purchase.timestampFirstSuccessfulPay) {
+ // No point in aborting it. We don't even report an error.
+ logger.warn(`tried to abort successful payment`);
return;
}
-
- const refreshCoinsMap: Record<string, CoinRefreshRequest> = {};
- for (const refundStatus of refunds) {
- const refundKey = getRefundKey(refundStatus);
- const existingRefundInfo = p.refunds[refundKey];
-
- const isPermanentFailure =
- refundStatus.type === "failure" &&
- refundStatus.exchange_status >= 400 &&
- refundStatus.exchange_status < 500;
-
- // Already failed.
- if (existingRefundInfo?.type === RefundState.Failed) {
- continue;
- }
-
- // Already applied.
- if (existingRefundInfo?.type === RefundState.Applied) {
- continue;
- }
-
- // Still pending.
- if (
- refundStatus.type === "failure" &&
- !isPermanentFailure &&
- existingRefundInfo?.type === RefundState.Pending
- ) {
- continue;
- }
-
- // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
-
- if (refundStatus.type === "success") {
- await applySuccessfulRefund(
- tx,
- p,
- refreshCoinsMap,
- refundStatus,
- ws.config.testing.denomselAllowLate,
- );
- } else if (isPermanentFailure) {
- await storeFailedRefund(
- tx,
- p,
- refreshCoinsMap,
- refundStatus,
- ws.config.testing.denomselAllowLate,
- );
- } else {
- await storePendingRefund(
- tx,
- p,
- refundStatus,
- ws.config.testing.denomselAllowLate,
- );
- }
+ if (oldStatus === PurchaseStatus.Paying) {
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
}
-
- if (reason !== RefundReason.AbortRefund) {
- // For abort-refunds, the refresh group has already been
- // created before the refund was started.
- // For other refunds, we need to create it after we know
- // the amounts.
- const refreshCoinsPubs = Object.values(refreshCoinsMap);
- logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`);
- if (refreshCoinsPubs.length > 0) {
+ await tx.purchases.put(purchase);
+ if (oldStatus === PurchaseStatus.Paying) {
+ if (purchase.payInfo) {
+ const coinSel = purchase.payInfo.payCoinSelection;
+ const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < coinSel.coinPubs.length; i++) {
+ refreshCoins.push({
+ amount: coinSel.coinContributions[i],
+ coinPub: coinSel.coinPubs[i],
+ });
+ }
await createRefreshGroup(
ws,
tx,
- Amounts.currencyOf(refreshCoinsPubs[0].amount),
- refreshCoinsPubs,
- RefreshReason.Refund,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortPay,
);
}
}
-
- // Are we done with querying yet, or do we need to do another round
- // after a retry delay?
- let queryDone = true;
-
- let numPendingRefunds = 0;
- for (const ri of Object.values(p.refunds)) {
- switch (ri.type) {
- case RefundState.Pending:
- numPendingRefunds++;
- break;
- }
- }
-
- if (numPendingRefunds > 0) {
- queryDone = false;
- }
-
- if (queryDone) {
- p.timestampLastRefundStatus = now;
- if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- p.purchaseStatus = PurchaseStatus.PaymentAbortFinished;
- } else if (p.purchaseStatus === PurchaseStatus.QueryingAutoRefund) {
- const autoRefundDeadline = p.autoRefundDeadline;
- checkDbInvariant(!!autoRefundDeadline);
- if (
- AbsoluteTime.isExpired(
- AbsoluteTime.fromTimestamp(autoRefundDeadline),
- )
- ) {
- p.purchaseStatus = PurchaseStatus.Paid;
- }
- } else if (p.purchaseStatus === PurchaseStatus.QueryingRefund) {
- p.purchaseStatus = PurchaseStatus.Paid;
- p.refundAmountAwaiting = undefined;
- }
- logger.trace("refund query done");
- ws.notify({
- type: NotificationType.RefundFinished,
- transactionId: makeTransactionId(
- TransactionType.Payment,
- p.proposalId,
- ),
- });
- } else {
- // No error, but we need to try again!
- p.timestampLastRefundStatus = now;
- logger.trace("refund query not done");
- }
-
- await tx.purchases.put(p);
+ await tx.operationRetries.delete(opId);
});
- ws.notify({
- type: NotificationType.RefundQueried,
- transactionId: makeTransactionId(TransactionType.Payment, proposalId),
- });
+ ws.workAvailable.trigger();
+}
+
+export function computePayMerchantTransactionState(
+ purchaseRecord: PurchaseRecord,
+): TransactionState {
+ switch (purchaseRecord.purchaseStatus) {
+ case PurchaseStatus.DownloadingProposal:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PurchaseStatus.AbortedIncompletePayment:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.Proposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ case PurchaseStatus.FailedClaim:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.RepurchaseDetected:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Repurchase,
+ };
+ case PurchaseStatus.AbortingWithRefund:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ case PurchaseStatus.Paying:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Pay,
+ };
+ case PurchaseStatus.PayingReplay:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.AbortedProposalRefused:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Refused,
+ };
+ case PurchaseStatus.QueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.QueryingRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CheckRefunds,
+ };
+ case PurchaseStatus.PendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ }
}
-async function calculateRefundSummary(
+async function processPurchaseAutoRefund(
ws: InternalWalletState,
- p: PurchaseRecord,
-): Promise<RefundSummary> {
- const download = await expectProposalDownload(ws, p);
- let amountRefundGranted = Amounts.zeroOfAmount(download.contractData.amount);
- let amountRefundGone = Amounts.zeroOfAmount(download.contractData.amount);
+ purchase: PurchaseRecord,
+): Promise<OperationAttemptResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing auto-refund for proposal ${proposalId}`);
+
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
- let pendingAtExchange = false;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
- const payInfo = p.payInfo;
- if (!payInfo) {
- throw Error("can't calculate refund summary without payInfo");
+ // FIXME: Put this logic into runLongpollAsync?
+ if (ws.activeLongpoll[taskId]) {
+ return OperationAttemptResult.longpoll();
}
- Object.keys(p.refunds).forEach((rk) => {
- const refund = p.refunds[rk];
- if (refund.type === RefundState.Pending) {
- pendingAtExchange = true;
- }
+ const download = await expectProposalDownload(ws, purchase);
+
+ runLongpollAsync(ws, taskId, async (ct) => {
if (
- refund.type === RefundState.Applied ||
- refund.type === RefundState.Pending
+ !purchase.autoRefundDeadline ||
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
+ )
) {
- amountRefundGranted = Amounts.add(
- amountRefundGranted,
- Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount,
- ).amount;
- } else {
- amountRefundGone = Amounts.add(
- amountRefundGone,
- refund.refundAmount,
- ).amount;
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return {
+ ready: true,
+ };
}
- });
- return {
- amountEffectivePaid: Amounts.parseOrThrow(payInfo.totalPayCost),
- amountRefundGone,
- amountRefundGranted,
- pendingAtExchange,
- };
-}
-
-/**
- * Summary of the refund status of a purchase.
- */
-export interface RefundSummary {
- pendingAtExchange: boolean;
- amountEffectivePaid: AmountJson;
- amountRefundGranted: AmountJson;
- amountRefundGone: AmountJson;
-}
-/**
- * Accept a refund, return the contract hash for the contract
- * that was involved in the refund.
- */
-export async function applyRefund(
- ws: InternalWalletState,
- talerRefundUri: string,
-): Promise<ApplyRefundResponse> {
- const parseResult = parseRefundUri(talerRefundUri);
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
- logger.trace("applying refund", parseResult);
+ requestUrl.searchParams.set("timeout_ms", "1000");
+ requestUrl.searchParams.set("await_refund_obtained", "yes");
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
+ const resp = await ws.http.fetch(requestUrl.href);
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.get([
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
- });
+ // FIXME: Check other status codes!
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
);
- }
- return applyRefundFromPurchaseId(ws, purchase.proposalId);
+ if (orderStatus.refund_pending) {
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.QueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return {
+ ready: true,
+ };
+ } else {
+ return {
+ ready: false,
+ };
+ }
+ });
+
+ return OperationAttemptResult.longpoll();
}
-export async function applyRefundFromPurchaseId(
+async function processPurchaseAbortingRefund(
ws: InternalWalletState,
- proposalId: string,
-): Promise<ApplyRefundResponse> {
- logger.trace("applying refund for purchase", proposalId);
+ purchase: PurchaseRecord,
+): Promise<OperationAttemptResult> {
+ const proposalId = purchase.proposalId;
+ const download = await expectProposalDownload(ws, purchase);
+ logger.trace(`processing aborting-refund for proposal ${proposalId}`);
- logger.info("processing purchase for refund");
- const success = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("no purchase found for refund URL");
- return false;
- }
- if (p.purchaseStatus === PurchaseStatus.Paid) {
- p.purchaseStatus = PurchaseStatus.QueryingRefund;
- }
- await tx.purchases.put(p);
- return true;
- });
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/abort`,
+ download.contractData.merchantBaseUrl,
+ );
- if (success) {
- ws.notify({
- type: NotificationType.RefundStarted,
- });
- await processPurchaseQueryRefund(ws, proposalId, {
- forceNow: true,
- waitForAutoRefund: false,
- });
+ const abortingCoins: AbortingCoin[] = [];
+
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ throw Error("can't abort, no coins selected");
}
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
+ await ws.db
+ .mktx((x) => [x.coins])
.runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(
+ payCoinSelection.coinContributions[i],
+ ),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
});
- if (!purchase) {
- throw Error("purchase no longer exists");
- }
+ const abortReq: AbortRequest = {
+ h_contract: download.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
- const summary = await calculateRefundSummary(ws, purchase);
- const download = await expectProposalDownload(ws, purchase);
+ logger.trace(`making order abort request to ${requestUrl.href}`);
- const lastExec = Object.values(purchase.refunds).reduce(
- (prev, cur) => {
- return TalerProtocolTimestamp.max(cur.executionTime, prev);
- },
- { t_s: 0 } as TalerProtocolTimestamp,
+ const request = await ws.http.postJson(requestUrl.href, abortReq);
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForAbortResponse(),
);
- const transactionId =
- lastExec.t_s === "never" || lastExec.t_s === 0
- ? makeTransactionId(TransactionType.Payment, proposalId)
- : makeTransactionId(
- TransactionType.Refund,
- proposalId,
- String(lastExec.t_s),
- );
+ const refunds: MerchantCoinRefundStatus[] = [];
- return {
- contractTermsHash: download.contractData.contractTermsHash,
- proposalId: purchase.proposalId,
- transactionId,
- amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
- amountRefundGone: Amounts.stringify(summary.amountRefundGone),
- amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
- pendingAtExchange: summary.pendingAtExchange,
- info: {
- contractTermsHash: download.contractData.contractTermsHash,
- merchant: download.contractData.merchant,
- orderId: download.contractData.orderId,
- products: download.contractData.products,
- summary: download.contractData.summary,
- fulfillmentMessage: download.contractData.fulfillmentMessage,
- summary_i18n: download.contractData.summaryI18n,
- fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n,
- },
- };
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ rtransaction_id: 0,
+ execution_time: AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.fromTimestamp(download.contractData.timestamp),
+ Duration.fromSpec({ seconds: 1 }),
+ ),
+ ),
+ });
+ }
+ return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);
}
-async function queryAndSaveAwaitingRefund(
+async function processPurchaseQueryRefund(
ws: InternalWalletState,
purchase: PurchaseRecord,
- waitForAutoRefund?: boolean,
-): Promise<AmountJson> {
+): Promise<OperationAttemptResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing query-refund for proposal ${proposalId}`);
+
const download = await expectProposalDownload(ws, purchase);
+
const requestUrl = new URL(
`orders/${download.contractData.orderId}`,
download.contractData.merchantBaseUrl,
@@ -2204,32 +1956,45 @@ async function queryAndSaveAwaitingRefund(
"h_contract",
download.contractData.contractTermsHash,
);
- // Long-poll for one second
- if (waitForAutoRefund) {
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
- logger.trace("making long-polling request for auto-refund");
- }
- const resp = await ws.http.get(requestUrl.href);
+
+ const resp = await ws.http.fetch(requestUrl.href);
const orderStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantOrderStatusPaid(),
);
- if (!orderStatus.refunded) {
- // Wait for retry ...
- return Amounts.zeroOfAmount(download.contractData.amount);
- }
- const refundAwaiting = Amounts.sub(
- Amounts.parseOrThrow(orderStatus.refund_amount),
- Amounts.parseOrThrow(orderStatus.refund_taken),
- ).amount;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
- if (
- purchase.refundAmountAwaiting === undefined ||
- Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0
- ) {
- await ws.db
+ if (!orderStatus.refund_pending) {
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return OperationAttemptResult.finishedEmpty();
+ } else {
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
@@ -2237,304 +2002,359 @@ async function queryAndSaveAwaitingRefund(
logger.warn("purchase does not exist anymore");
return;
}
+ if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
+ return { oldTxState, newTxState };
});
+ notifyTransition(ws, transactionId, transitionInfo);
+ return OperationAttemptResult.finishedEmpty();
}
-
- return refundAwaiting;
}
-export async function processPurchaseQueryRefund(
+async function processPurchaseAcceptRefund(
ws: InternalWalletState,
- proposalId: string,
- options: {
- forceNow?: boolean;
- waitForAutoRefund?: boolean;
- } = {},
+ purchase: PurchaseRecord,
): Promise<OperationAttemptResult> {
- logger.trace(`processing refund query for proposal ${proposalId}`);
- const waitForAutoRefund = options.waitForAutoRefund ?? false;
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return OperationAttemptResult.finishedEmpty();
- }
-
- if (
- !(
- purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund ||
- purchase.purchaseStatus === PurchaseStatus.QueryingRefund ||
- purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund
- )
- ) {
- return OperationAttemptResult.finishedEmpty();
- }
+ const proposalId = purchase.proposalId;
const download = await expectProposalDownload(ws, purchase);
- if (purchase.timestampFirstSuccessfulPay) {
- if (
- !purchase.autoRefundDeadline ||
- !AbsoluteTime.isExpired(
- AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
- )
- ) {
- const awaitingAmount = await queryAndSaveAwaitingRefund(
- ws,
- purchase,
- waitForAutoRefund,
- );
- if (Amounts.isZero(awaitingAmount)) {
- // Maybe the user wanted to check for refund to find out
- // that there is no refund pending from merchant
- await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- p.purchaseStatus = PurchaseStatus.Paid;
- await tx.purchases.put(p);
- });
-
- // No new refunds, but we still need to notify
- // the wallet client that the query finished.
- ws.notify({
- type: NotificationType.RefundQueried,
- transactionId: makeTransactionId(TransactionType.Payment, proposalId),
- });
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
- return OperationAttemptResult.finishedEmpty();
- }
- }
+ logger.trace(`making refund request to ${requestUrl.href}`);
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/refund`,
- download.contractData.merchantBaseUrl,
- );
+ const request = await ws.http.postJson(requestUrl.href, {
+ h_contract: download.contractData.contractTermsHash,
+ });
- logger.trace(`making refund request to ${requestUrl.href}`);
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForMerchantOrderRefundPickupResponse(),
+ );
+ return await storeRefunds(
+ ws,
+ purchase,
+ refundResponse.refunds,
+ RefundReason.AbortRefund,
+ );
+}
- const request = await ws.http.postJson(requestUrl.href, {
- h_contract: download.contractData.contractTermsHash,
+export async function startRefundQueryForUri(
+ ws: InternalWalletState,
+ talerUri: string,
+): Promise<void> {
+ const parsedUri = parseTalerUri(talerUri);
+ if (!parsedUri) {
+ throw Error("invalid taler:// URI");
+ }
+ if (parsedUri.type !== TalerUriAction.Refund) {
+ throw Error("expected taler://refund URI");
+ }
+ const purchaseRecord = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.get([
+ parsedUri.merchantBaseUrl,
+ parsedUri.orderId,
+ ]);
});
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
-
- await acceptRefunds(
- ws,
- proposalId,
- refundResponse.refunds,
- RefundReason.NormalRefund,
- );
- } else if (purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/abort`,
- download.contractData.merchantBaseUrl,
- );
-
- const abortingCoins: AbortingCoin[] = [];
-
- const payCoinSelection = purchase.payInfo?.payCoinSelection;
- if (!payCoinSelection) {
- throw Error("can't abort, no coins selected");
- }
-
- await ws.db
- .mktx((x) => [x.coins])
- .runReadOnly(async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- const coin = await tx.coins.get(coinPub);
- checkDbInvariant(!!coin, "expected coin to be present");
- abortingCoins.push({
- coin_pub: coinPub,
- contribution: Amounts.stringify(
- payCoinSelection.coinContributions[i],
- ),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
-
- const abortReq: AbortRequest = {
- h_contract: download.contractData.contractTermsHash,
- coins: abortingCoins,
- };
-
- logger.trace(`making order abort request to ${requestUrl.href}`);
-
- const request = await ws.http.postJson(requestUrl.href, abortReq);
- const abortResp = await readSuccessResponseJsonOrThrow(
- request,
- codecForAbortResponse(),
- );
-
- const refunds: MerchantCoinRefundStatus[] = [];
-
- if (abortResp.refunds.length != abortingCoins.length) {
- // FIXME: define error code!
- throw Error("invalid order abort response");
- }
-
- for (let i = 0; i < abortResp.refunds.length; i++) {
- const r = abortResp.refunds[i];
- refunds.push({
- ...r,
- coin_pub: payCoinSelection.coinPubs[i],
- refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
- rtransaction_id: 0,
- execution_time: AbsoluteTime.toTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.fromTimestamp(download.contractData.timestamp),
- Duration.fromSpec({ seconds: 1 }),
- ),
- ),
- });
- }
- await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
+ if (!purchaseRecord) {
+ throw Error("no purchase found, can't refund");
}
- return OperationAttemptResult.finishedEmpty();
+ return startQueryRefund(ws, purchaseRecord.proposalId);
}
-export async function abortPayMerchant(
+export async function startQueryRefund(
ws: InternalWalletState,
proposalId: string,
- cancelImmediately?: boolean,
): Promise<void> {
- const opId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
proposalId,
});
- await ws.db
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.warn(`purchase ${proposalId} does not exist anymore`);
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.Done) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.QueryingRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.workAvailable.trigger();
+}
+
+/**
+ * Store refunds, possibly creating a new refund group.
+ */
+async function storeRefunds(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<OperationAttemptResult> {
+ logger.info(`storing refunds: ${j2s(refunds)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchase.proposalId,
+ });
+
+ const newRefundGroupId = encodeCrock(randomBytes(32));
+ const now = TalerProtocolTimestamp.now();
+
+ const download = await expectProposalDownload(ws, purchase);
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ const getItemStatus = (rf: MerchantCoinRefundStatus) => {
+ if (rf.type === "success") {
+ return RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ return RefundItemStatus.Pending;
+ } else {
+ return RefundItemStatus.Failed;
+ }
+ }
+ };
+
+ const result = await ws.db
.mktx((x) => [
x.purchases,
- x.refreshGroups,
+ x.refundGroups,
+ x.refundItems,
+ x.coins,
x.denominations,
x.coinAvailability,
- x.coins,
- x.operationRetries,
+ x.refreshGroups,
])
.runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
+ const computeRefreshRequest = async (items: RefundItemRecord[]) => {
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const item of items) {
+ const coin = await tx.coins.get(item.coinPub);
+ if (!coin) {
+ throw Error("coin not found");
+ }
+ const denomInfo = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error("denom not found");
+ }
+ if (item.status === RefundItemStatus.Done) {
+ const refundedAmount = Amounts.sub(
+ item.refundAmount,
+ denomInfo.feeRefund,
+ ).amount;
+ refreshCoins.push({
+ amount: Amounts.stringify(refundedAmount),
+ coinPub: item.coinPub,
+ });
+ }
+ }
+ return refreshCoins;
+ };
+
+ const myPurchase = await tx.purchases.get(purchase.proposalId);
+ if (!myPurchase) {
+ logger.warn("purchase group not found anymore");
+ return;
}
- const oldStatus = purchase.purchaseStatus;
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
+ if (myPurchase.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) {
return;
}
- if (oldStatus === PurchaseStatus.Paying) {
- purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+
+ let newGroup: RefundGroupRecord | undefined = undefined;
+ // Pending, but not part of an aborted refund group.
+ let numPendingItemsTotal = 0;
+ const newGroupRefunds: RefundItemRecord[] = [];
+
+ for (const rf of refunds) {
+ const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
+ rf.coin_pub,
+ rf.rtransaction_id,
+ ]);
+ if (oldItem) {
+ logger.info("already have refund in database");
+ if (oldItem.status === RefundItemStatus.Done) {
+ continue;
+ }
+ if (rf.type === "success") {
+ oldItem.status = RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ oldItem.status = RefundItemStatus.Pending;
+ numPendingItemsTotal += 1;
+ } else {
+ oldItem.status = RefundItemStatus.Failed;
+ }
+ }
+ await tx.refundItems.put(oldItem);
+ } else {
+ // Put refund item into a new group!
+ if (!newGroup) {
+ newGroup = {
+ proposalId: purchase.proposalId,
+ refundGroupId: newRefundGroupId,
+ status: RefundGroupStatus.Pending,
+ timestampCreated: now,
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfCurrency(currency),
+ ),
+ amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ };
+ }
+ const status: RefundItemStatus = getItemStatus(rf);
+ const newItem: RefundItemRecord = {
+ coinPub: rf.coin_pub,
+ executionTime: rf.execution_time,
+ obtainedTime: now,
+ refundAmount: rf.refund_amount,
+ refundGroupId: newGroup.refundGroupId,
+ rtxid: rf.rtransaction_id,
+ status,
+ };
+ if (status === RefundItemStatus.Pending) {
+ numPendingItemsTotal += 1;
+ }
+ newGroupRefunds.push(newItem);
+ await tx.refundItems.put(newItem);
+ }
}
- if (
- cancelImmediately &&
- oldStatus === PurchaseStatus.AbortingWithRefund
- ) {
- purchase.purchaseStatus = PurchaseStatus.PaymentAbortFinished;
+
+ // Now that we know all the refunds for the new refund group,
+ // we can compute the raw/effective amounts.
+ if (newGroup) {
+ const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
+ const refreshCoins = await computeRefreshRequest(newGroupRefunds);
+ const outInfo = await calculateRefreshOutput(
+ ws,
+ tx,
+ currency,
+ refreshCoins,
+ );
+ newGroup.amountEffective = Amounts.stringify(
+ Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
+ );
+ newGroup.amountRaw = Amounts.stringify(
+ Amounts.sumOrZero(currency, amountsRaw).amount,
+ );
+ await tx.refundGroups.put(newGroup);
}
- await tx.purchases.put(purchase);
- if (oldStatus === PurchaseStatus.Paying) {
- if (purchase.payInfo) {
- const coinSel = purchase.payInfo.payCoinSelection;
- const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
- const refreshCoins: CoinRefreshRequest[] = [];
- for (let i = 0; i < coinSel.coinPubs.length; i++) {
- refreshCoins.push({
- amount: coinSel.coinContributions[i],
- coinPub: coinSel.coinPubs[i],
- });
+
+ const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
+ myPurchase.proposalId,
+ );
+
+ logger.info(
+ `refund groups for proposal ${myPurchase.proposalId}: ${j2s(
+ refundGroups,
+ )}`,
+ );
+
+ for (const refundGroup of refundGroups) {
+ if (refundGroup.status === RefundGroupStatus.Aborted) {
+ continue;
+ }
+ if (refundGroup.status === RefundGroupStatus.Done) {
+ continue;
+ }
+ const items = await tx.refundItems.indexes.byRefundGroupId.getAll(
+ refundGroup.refundGroupId,
+ );
+ let numPending = 0;
+ for (const item of items) {
+ if (item.status === RefundItemStatus.Pending) {
+ numPending++;
}
+ }
+ logger.info(`refund items pending for refund group: ${numPending}`);
+ if (numPending === 0) {
+ logger.info("refund group is done!");
+ // We're done for this refund group!
+ refundGroup.status = RefundGroupStatus.Done;
+ await tx.refundGroups.put(refundGroup);
+ const refreshCoins = await computeRefreshRequest(items);
await createRefreshGroup(
ws,
tx,
- currency,
+ Amounts.currencyOf(download.contractData.amount),
refreshCoins,
- RefreshReason.AbortPay,
+ RefreshReason.Refund,
);
}
}
- await tx.operationRetries.delete(opId);
- });
- runOperationWithErrorReporting(ws, opId, async () => {
- return await processPurchaseQueryRefund(ws, proposalId, {
- forceNow: true,
+ const oldTxState = computePayMerchantTransactionState(myPurchase);
+ if (numPendingItemsTotal === 0) {
+ myPurchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ await tx.purchases.put(myPurchase);
+ const newTxState = computePayMerchantTransactionState(myPurchase);
+
+ return {
+ numPendingItemsTotal,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
});
- });
+
+ if (!result) {
+ return OperationAttemptResult.finishedEmpty();
+ }
+
+ notifyTransition(ws, transactionId, result.transitionInfo);
+
+ if (result.numPendingItemsTotal > 0) {
+ return OperationAttemptResult.pendingEmpty();
+ }
+
+ return OperationAttemptResult.finishedEmpty();
}
-export function computePayMerchantTransactionState(
- purchaseRecord: PurchaseRecord,
+export function computeRefundTransactionState(
+ refundGroupRecord: RefundGroupRecord,
): TransactionState {
- switch (purchaseRecord.purchaseStatus) {
- case PurchaseStatus.DownloadingProposal:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.Paid:
- return {
- major: TransactionMajorState.Done,
- };
- case PurchaseStatus.PaymentAbortFinished:
+ switch (refundGroupRecord.status) {
+ case RefundGroupStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
- case PurchaseStatus.Proposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.MerchantOrderProposed,
- };
- case PurchaseStatus.ProposalDownloadFailed:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.RepurchaseDetected:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Repurchase,
- };
- case PurchaseStatus.AbortingWithRefund:
+ case RefundGroupStatus.Done:
return {
- major: TransactionMajorState.Aborting,
- };
- case PurchaseStatus.Paying:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pay,
- };
- case PurchaseStatus.PayingReplay:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.RebindSession,
+ major: TransactionMajorState.Done,
};
- case PurchaseStatus.ProposalRefused:
+ case RefundGroupStatus.Failed:
return {
major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Refused,
};
- case PurchaseStatus.QueryingAutoRefund:
+ case RefundGroupStatus.Pending:
return {
major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AutoRefund,
- };
- case PurchaseStatus.QueryingRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CheckRefunds,
- };
+ }
}
}
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
index fda9a886a..843f37c8e 100644
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -81,7 +81,7 @@ import {
readUnexpectedResponseDetails,
} from "@gnu-taler/taler-util/http";
import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadWriteAccess } from "../util/query.js";
+import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";
import {
constructTaskIdentifier,
OperationAttemptResult,
@@ -874,18 +874,13 @@ async function processRefreshSession(
await refreshReveal(ws, refreshGroupId, coinIndex);
}
-/**
- * Create a refresh group for a list of coins.
- *
- * Refreshes the remaining amount on the coin, effectively capturing the remaining
- * value in the refresh group.
- *
- * The caller must also ensure that the coins that should be refreshed exist
- * in the current database transaction.
- */
-export async function createRefreshGroup(
+export interface RefreshOutputInfo {
+ outputPerCoin: AmountJson[];
+}
+
+export async function calculateRefreshOutput(
ws: InternalWalletState,
- tx: GetReadWriteAccess<{
+ tx: GetReadOnlyAccess<{
denominations: typeof WalletStoresV1.denominations;
coins: typeof WalletStoresV1.coins;
refreshGroups: typeof WalletStoresV1.refreshGroups;
@@ -893,12 +888,7 @@ export async function createRefreshGroup(
}>,
currency: string,
oldCoinPubs: CoinRefreshRequest[],
- reason: RefreshReason,
- reasonDetails?: RefreshReasonDetails,
-): Promise<RefreshGroupId> {
- const refreshGroupId = encodeCrock(getRandomBytes(32));
-
- const inputPerCoin: AmountJson[] = [];
+): Promise<RefreshOutputInfo> {
const estimatedOutputPerCoin: AmountJson[] = [];
const denomsPerExchange: Record<string, DenominationRecord[]> = {};
@@ -931,6 +921,47 @@ export async function createRefreshGroup(
!!denom,
"denomination for existing coin must be in database",
);
+ const refreshAmount = ocp.amount;
+ const denoms = await getDenoms(coin.exchangeBaseUrl);
+ const cost = getTotalRefreshCost(
+ denoms,
+ denom,
+ Amounts.parseOrThrow(refreshAmount),
+ ws.config.testing.denomselAllowLate,
+ );
+ const output = Amounts.sub(refreshAmount, cost).amount;
+ estimatedOutputPerCoin.push(output);
+ }
+
+ return {
+ outputPerCoin: estimatedOutputPerCoin,
+ }
+}
+
+async function applyRefresh(
+ ws: InternalWalletState,
+ tx: GetReadWriteAccess<{
+ denominations: typeof WalletStoresV1.denominations;
+ coins: typeof WalletStoresV1.coins;
+ refreshGroups: typeof WalletStoresV1.refreshGroups;
+ coinAvailability: typeof WalletStoresV1.coinAvailability;
+ }>,
+ oldCoinPubs: CoinRefreshRequest[],
+ refreshGroupId: string,
+): Promise<void> {
+ for (const ocp of oldCoinPubs) {
+ const coin = await tx.coins.get(ocp.coinPub);
+ checkDbInvariant(!!coin, "coin must be in database");
+ const denom = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ checkDbInvariant(
+ !!denom,
+ "denomination for existing coin must be in database",
+ );
switch (coin.status) {
case CoinStatus.Dormant:
break;
@@ -962,19 +993,39 @@ export async function createRefreshGroup(
id: `txn:refresh:${refreshGroupId}`,
};
}
- const refreshAmount = ocp.amount;
- inputPerCoin.push(Amounts.parseOrThrow(refreshAmount));
await tx.coins.put(coin);
- const denoms = await getDenoms(coin.exchangeBaseUrl);
- const cost = getTotalRefreshCost(
- denoms,
- denom,
- Amounts.parseOrThrow(refreshAmount),
- ws.config.testing.denomselAllowLate,
- );
- const output = Amounts.sub(refreshAmount, cost).amount;
- estimatedOutputPerCoin.push(output);
}
+}
+
+/**
+ * Create a refresh group for a list of coins.
+ *
+ * Refreshes the remaining amount on the coin, effectively capturing the remaining
+ * value in the refresh group.
+ *
+ * The caller must also ensure that the coins that should be refreshed exist
+ * in the current database transaction.
+ */
+export async function createRefreshGroup(
+ ws: InternalWalletState,
+ tx: GetReadWriteAccess<{
+ denominations: typeof WalletStoresV1.denominations;
+ coins: typeof WalletStoresV1.coins;
+ refreshGroups: typeof WalletStoresV1.refreshGroups;
+ coinAvailability: typeof WalletStoresV1.coinAvailability;
+ }>,
+ currency: string,
+ oldCoinPubs: CoinRefreshRequest[],
+ reason: RefreshReason,
+ reasonDetails?: RefreshReasonDetails,
+): Promise<RefreshGroupId> {
+ const refreshGroupId = encodeCrock(getRandomBytes(32));
+
+ const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs);
+
+ const estimatedOutputPerCoin = outInfo.outputPerCoin;
+
+ await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId);
const refreshGroup: RefreshGroupRecord = {
operationStatus: RefreshOperationStatus.Pending,
@@ -987,7 +1038,7 @@ export async function createRefreshGroup(
reason,
refreshGroupId,
refreshSessionPerCoin: oldCoinPubs.map(() => undefined),
- inputPerCoin: inputPerCoin.map((x) => Amounts.stringify(x)),
+ inputPerCoin: oldCoinPubs.map((x) => x.amount),
estimatedOutputPerCoin: estimatedOutputPerCoin.map((x) =>
Amounts.stringify(x),
),
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts
index 74cf7b4f2..8341d2f26 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -45,7 +45,7 @@ import {
PreparePayResultType,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
-import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js";
+import { confirmPay, preparePayForUri, startRefundQueryForUri } from "./pay-merchant.js";
import { getBalances } from "./balance.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
@@ -416,7 +416,7 @@ export async function runIntegrationTest(
logger.trace("refund URI", refundUri);
- await applyRefund(ws, refundUri);
+ await startRefundQueryForUri(ws, refundUri);
logger.trace("integration test: applied refund");
@@ -512,7 +512,7 @@ export async function runIntegrationTest2(
logger.trace("refund URI", refundUri);
- await applyRefund(ws, refundUri);
+ await startRefundQueryForUri(ws, refundUri);
logger.trace("integration test: applied refund");
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
index 02f11d82d..d9778f0c2 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -19,7 +19,6 @@
*/
import {
AbsoluteTime,
- AmountJson,
Amounts,
constructPayPullUri,
constructPayPushUri,
@@ -51,9 +50,7 @@ import {
PeerPushPaymentInitiationRecord,
PurchaseStatus,
PurchaseRecord,
- RefundState,
TipRecord,
- WalletRefundItem,
WithdrawalGroupRecord,
WithdrawalRecordType,
WalletContractData,
@@ -66,6 +63,7 @@ import {
PeerPushPaymentIncomingRecord,
PeerPushPaymentIncomingStatus,
PeerPullPaymentInitiationRecord,
+ RefundGroupRecord,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
import { PendingTaskType } from "../pending-types.js";
@@ -89,6 +87,7 @@ import { getExchangeDetails } from "./exchanges.js";
import {
abortPayMerchant,
computePayMerchantTransactionState,
+ computeRefundTransactionState,
expectProposalDownload,
extractContractData,
processPurchasePay,
@@ -205,40 +204,15 @@ export async function getTransactionById(
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (!purchase) throw Error("not found");
-
- const filteredRefunds = await Promise.all(
- Object.values(purchase.refunds).map(async (r) => {
- const t = await tx.tombstones.get(
- makeTombstoneId(
- TombstoneTag.DeleteRefund,
- purchase.proposalId,
- `${r.executionTime.t_s}`,
- ),
- );
- if (!t) return r;
- return undefined;
- }),
- );
-
const download = await expectProposalDownload(ws, purchase, tx);
-
- const cleanRefunds = filteredRefunds.filter(
- (x): x is WalletRefundItem => !!x,
- );
-
const contractData = download.contractData;
- const refunds = mergeRefundByExecutionTime(
- cleanRefunds,
- Amounts.zeroOfAmount(contractData.amount),
- );
-
const payOpId = TaskIdentifiers.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
return buildTransactionForPurchase(
purchase,
contractData,
- refunds,
+ [], // FIXME: Add refunds from refund group records here.
payRetryRecord,
);
});
@@ -272,66 +246,8 @@ export async function getTransactionById(
return buildTransactionForDeposit(depositRecord, retries);
});
} else if (type === TransactionType.Refund) {
- const proposalId = rest[0];
- const executionTimeStr = rest[1];
-
- return await ws.db
- .mktx((x) => [
- x.operationRetries,
- x.purchases,
- x.tombstones,
- x.contractTerms,
- ])
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) throw Error("not found");
-
- const t = await tx.tombstones.get(
- makeTombstoneId(
- TombstoneTag.DeleteRefund,
- purchase.proposalId,
- executionTimeStr,
- ),
- );
- if (t) throw Error("deleted");
-
- const filteredRefunds = await Promise.all(
- Object.values(purchase.refunds).map(async (r) => {
- const t = await tx.tombstones.get(
- makeTombstoneId(
- TombstoneTag.DeleteRefund,
- purchase.proposalId,
- `${r.executionTime.t_s}`,
- ),
- );
- if (!t) return r;
- return undefined;
- }),
- );
-
- const cleanRefunds = filteredRefunds.filter(
- (x): x is WalletRefundItem => !!x,
- );
-
- const download = await expectProposalDownload(ws, purchase, tx);
- const contractData = download.contractData;
- const refunds = mergeRefundByExecutionTime(
- cleanRefunds,
- Amounts.zeroOfAmount(contractData.amount),
- );
-
- const theRefund = refunds.find(
- (r) => `${r.executionTime.t_s}` === executionTimeStr,
- );
- if (!theRefund) throw Error("not found");
-
- return buildTransactionForRefund(
- purchase,
- contractData,
- theRefund,
- undefined,
- );
- });
+ // FIXME!
+ throw Error("not implemented");
} else if (type === TransactionType.PeerPullDebit) {
const peerPullPaymentIncomingId = rest[0];
return await ws.db
@@ -730,6 +646,29 @@ function buildTransactionForManualWithdraw(
};
}
+function buildTransactionForRefund(
+ refundRecord: RefundGroupRecord,
+): Transaction {
+ return {
+ type: TransactionType.Refund,
+ amountEffective: refundRecord.amountEffective,
+ amountRaw: refundRecord.amountEffective,
+ refundedTransactionId: constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: refundRecord.proposalId
+ }),
+ timestamp: refundRecord.timestampCreated,
+ transactionId: constructTransactionIdentifier({
+ tag: TransactionType.Refund,
+ refundGroupId: refundRecord.refundGroupId,
+ }),
+ txState: computeRefundTransactionState(refundRecord),
+ extendedStatus: ExtendedStatus.Done,
+ frozen: false,
+ pending: false,
+ }
+}
+
function buildTransactionForRefresh(
refreshGroupRecord: RefreshGroupRecord,
ort?: OperationRetryRecord,
@@ -850,113 +789,11 @@ function buildTransactionForTip(
};
}
-/**
- * For a set of refund with the same executionTime.
- */
-interface MergedRefundInfo {
- executionTime: TalerProtocolTimestamp;
- amountAppliedRaw: AmountJson;
- amountAppliedEffective: AmountJson;
- firstTimestamp: TalerProtocolTimestamp;
-}
-
-function mergeRefundByExecutionTime(
- rs: WalletRefundItem[],
- zero: AmountJson,
-): MergedRefundInfo[] {
- const refundByExecTime = rs.reduce((prev, refund) => {
- const key = `${refund.executionTime.t_s}`;
-
- // refunds count if applied
- const effective =
- refund.type === RefundState.Applied
- ? Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount
- : zero;
- const raw =
- refund.type === RefundState.Applied ? refund.refundAmount : zero;
-
- const v = prev.get(key);
- if (!v) {
- prev.set(key, {
- executionTime: refund.executionTime,
- amountAppliedEffective: effective,
- amountAppliedRaw: Amounts.parseOrThrow(raw),
- firstTimestamp: refund.obtainedTime,
- });
- } else {
- //v.executionTime is the same
- v.amountAppliedEffective = Amounts.add(
- v.amountAppliedEffective,
- effective,
- ).amount;
- v.amountAppliedRaw = Amounts.add(
- v.amountAppliedRaw,
- refund.refundAmount,
- ).amount;
- v.firstTimestamp = TalerProtocolTimestamp.min(
- v.firstTimestamp,
- refund.obtainedTime,
- );
- }
- return prev;
- }, new Map<string, MergedRefundInfo>());
-
- return Array.from(refundByExecTime.values());
-}
-
-async function buildTransactionForRefund(
- purchaseRecord: PurchaseRecord,
- contractData: WalletContractData,
- refundInfo: MergedRefundInfo,
- ort?: OperationRetryRecord,
-): Promise<Transaction> {
- const info: OrderShortInfo = {
- merchant: contractData.merchant,
- orderId: contractData.orderId,
- products: contractData.products,
- summary: contractData.summary,
- summary_i18n: contractData.summaryI18n,
- contractTermsHash: contractData.contractTermsHash,
- };
- if (contractData.fulfillmentUrl !== "") {
- info.fulfillmentUrl = contractData.fulfillmentUrl;
- }
-
- return {
- type: TransactionType.Refund,
- txState: mkTxStateUnknown(),
- info,
- refundedTransactionId: makeTransactionId(
- TransactionType.Payment,
- purchaseRecord.proposalId,
- ),
- transactionId: makeTransactionId(
- TransactionType.Refund,
- purchaseRecord.proposalId,
- `${refundInfo.executionTime.t_s}`,
- ),
- timestamp: refundInfo.firstTimestamp,
- amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
- amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
- refundPending:
- purchaseRecord.refundAmountAwaiting === undefined
- ? undefined
- : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
- extendedStatus: ExtendedStatus.Done,
- pending: false,
- frozen: false,
- ...(ort?.lastError ? { error: ort.lastError } : {}),
- };
-}
async function buildTransactionForPurchase(
purchaseRecord: PurchaseRecord,
contractData: WalletContractData,
- refundsInfo: MergedRefundInfo[],
+ refundsInfo: RefundGroupRecord[],
ort?: OperationRetryRecord,
): Promise<Transaction> {
const zero = Amounts.zeroOfAmount(contractData.amount);
@@ -974,30 +811,7 @@ async function buildTransactionForPurchase(
info.fulfillmentUrl = contractData.fulfillmentUrl;
}
- const totalRefund = refundsInfo.reduce(
- (prev, cur) => {
- return {
- raw: Amounts.add(prev.raw, cur.amountAppliedRaw).amount,
- effective: Amounts.add(prev.effective, cur.amountAppliedEffective)
- .amount,
- };
- },
- {
- raw: zero,
- effective: zero,
- } as { raw: AmountJson; effective: AmountJson },
- );
-
- const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({
- amountEffective: Amounts.stringify(r.amountAppliedEffective),
- amountRaw: Amounts.stringify(r.amountAppliedRaw),
- timestamp: r.executionTime,
- transactionId: makeTransactionId(
- TransactionType.Refund,
- purchaseRecord.proposalId,
- `${r.executionTime.t_s}`,
- ),
- }));
+ const refunds: RefundInfoShort[] = [];
const timestamp = purchaseRecord.timestampAccept;
checkDbInvariant(!!timestamp);
@@ -1008,7 +822,7 @@ async function buildTransactionForPurchase(
case PurchaseStatus.AbortingWithRefund:
status = ExtendedStatus.Aborting;
break;
- case PurchaseStatus.Paid:
+ case PurchaseStatus.Done:
case PurchaseStatus.RepurchaseDetected:
status = ExtendedStatus.Done;
break;
@@ -1018,10 +832,10 @@ async function buildTransactionForPurchase(
case PurchaseStatus.Paying:
status = ExtendedStatus.Pending;
break;
- case PurchaseStatus.ProposalDownloadFailed:
+ case PurchaseStatus.FailedClaim:
status = ExtendedStatus.Failed;
break;
- case PurchaseStatus.PaymentAbortFinished:
+ case PurchaseStatus.AbortedIncompletePayment:
status = ExtendedStatus.Aborted;
break;
default:
@@ -1034,8 +848,8 @@ async function buildTransactionForPurchase(
txState: computePayMerchantTransactionState(purchaseRecord),
amountRaw: Amounts.stringify(contractData.amount),
amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
- totalRefundRaw: Amounts.stringify(totalRefund.raw),
- totalRefundEffective: Amounts.stringify(totalRefund.effective),
+ totalRefundRaw: Amounts.stringify(zero), // FIXME!
+ totalRefundEffective: Amounts.stringify(zero), // FIXME!
refundPending:
purchaseRecord.refundAmountAwaiting === undefined
? undefined
@@ -1057,7 +871,7 @@ async function buildTransactionForPurchase(
refundQueryActive:
purchaseRecord.purchaseStatus === PurchaseStatus.QueryingRefund,
frozen:
- purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ??
+ purchaseRecord.purchaseStatus === PurchaseStatus.AbortedIncompletePayment ??
false,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
@@ -1092,6 +906,7 @@ export async function getTransactions(
x.tombstones,
x.withdrawalGroups,
x.refreshGroups,
+ x.refundGroups,
])
.runReadOnly(async (tx) => {
tx.peerPushPaymentInitiations.iter().forEachAsync(async (pi) => {
@@ -1202,6 +1017,14 @@ export async function getTransactions(
);
});
+ tx.refundGroups.iter().forEachAsync(async (refundGroup) => {
+ const currency = Amounts.currencyOf(refundGroup.amountRaw);
+ if (shouldSkipCurrency(transactionsRequest, currency)) {
+ return;
+ }
+ transactions.push(buildTransactionForRefund(refundGroup))
+ });
+
tx.refreshGroups.iter().forEachAsync(async (rg) => {
if (shouldSkipCurrency(transactionsRequest, rg.currency)) {
return;
@@ -1318,47 +1141,13 @@ export async function getTransactions(
download.contractTermsMerchantSig,
);
- const filteredRefunds = await Promise.all(
- Object.values(purchase.refunds).map(async (r) => {
- const t = await tx.tombstones.get(
- makeTombstoneId(
- TombstoneTag.DeleteRefund,
- purchase.proposalId,
- `${r.executionTime.t_s}`,
- ),
- );
- if (!t) return r;
- return undefined;
- }),
- );
-
- const cleanRefunds = filteredRefunds.filter(
- (x): x is WalletRefundItem => !!x,
- );
-
- const refunds = mergeRefundByExecutionTime(
- cleanRefunds,
- Amounts.zeroOfCurrency(download.currency),
- );
-
- refunds.forEach(async (refundInfo) => {
- transactions.push(
- await buildTransactionForRefund(
- purchase,
- contractData,
- refundInfo,
- undefined,
- ),
- );
- });
-
const payOpId = TaskIdentifiers.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
transactions.push(
await buildTransactionForPurchase(
purchase,
contractData,
- refunds,
+ [], // FIXME!
payRetryRecord,
),
);
@@ -1425,7 +1214,7 @@ export type ParsedTransactionIdentifier =
| { tag: TransactionType.PeerPushCredit; peerPushPaymentIncomingId: string }
| { tag: TransactionType.PeerPushDebit; pursePub: string }
| { tag: TransactionType.Refresh; refreshGroupId: string }
- | { tag: TransactionType.Refund; proposalId: string; executionTime: string }
+ | { tag: TransactionType.Refund; refundGroupId: string }
| { tag: TransactionType.Tip; walletTipId: string }
| { tag: TransactionType.Withdrawal; withdrawalGroupId: string };
@@ -1448,7 +1237,7 @@ export function constructTransactionIdentifier(
case TransactionType.Refresh:
return `txn:${pTxId.tag}:${pTxId.refreshGroupId}`;
case TransactionType.Refund:
- return `txn:${pTxId.tag}:${pTxId.proposalId}:${pTxId.executionTime}`;
+ return `txn:${pTxId.tag}:${pTxId.refundGroupId}`;
case TransactionType.Tip:
return `txn:${pTxId.tag}:${pTxId.walletTipId}`;
case TransactionType.Withdrawal:
@@ -1490,8 +1279,7 @@ export function parseTransactionIdentifier(
case TransactionType.Refund:
return {
tag: TransactionType.Refund,
- proposalId: rest[0],
- executionTime: rest[1],
+ refundGroupId: rest[0],
};
case TransactionType.Tip:
return {
diff --git a/packages/taler-wallet-core/src/util/query.ts b/packages/taler-wallet-core/src/util/query.ts
index 647e5e4b5..1de1e9a0d 100644
--- a/packages/taler-wallet-core/src/util/query.ts
+++ b/packages/taler-wallet-core/src/util/query.ts
@@ -35,7 +35,7 @@ import {
IDBKeyPath,
IDBKeyRange,
} from "@gnu-taler/idb-bridge";
-import { Logger } from "@gnu-taler/taler-util";
+import { Logger, j2s } from "@gnu-taler/taler-util";
const logger = new Logger("query.ts");
diff --git a/packages/taler-wallet-core/src/util/retries.ts b/packages/taler-wallet-core/src/util/retries.ts
index 12e1df7e9..7607a6583 100644
--- a/packages/taler-wallet-core/src/util/retries.ts
+++ b/packages/taler-wallet-core/src/util/retries.ts
@@ -76,6 +76,11 @@ export namespace OperationAttemptResult {
result: undefined,
};
}
+ export function longpoll(): OperationAttemptResult<unknown, unknown> {
+ return {
+ type: OperationAttemptResultType.Longpoll,
+ }
+ }
}
export interface OperationAttemptFinishedResult<T> {
diff --git a/packages/taler-wallet-core/src/wallet-api-types.ts b/packages/taler-wallet-core/src/wallet-api-types.ts
index f394aa9ca..f0da6059f 100644
--- a/packages/taler-wallet-core/src/wallet-api-types.ts
+++ b/packages/taler-wallet-core/src/wallet-api-types.ts
@@ -36,7 +36,7 @@ import {
AddKnownBankAccountsRequest,
ApplyDevExperimentRequest,
ApplyRefundFromPurchaseIdRequest,
- ApplyRefundRequest,
+ AcceptRefundRequest,
ApplyRefundResponse,
BackupRecovery,
BalancesResponse,
@@ -90,6 +90,7 @@ import {
RetryTransactionRequest,
SetCoinSuspendedRequest,
SetWalletDeviceIdRequest,
+ StartRefundQueryRequest,
TestPayArgs,
TestPayResult,
Transaction,
@@ -149,9 +150,8 @@ export enum WalletApiOperation {
MarkAttentionRequestAsRead = "markAttentionRequestAsRead",
GetPendingOperations = "getPendingOperations",
SetExchangeTosAccepted = "setExchangeTosAccepted",
- ApplyRefund = "applyRefund",
- ApplyRefundFromPurchaseId = "applyRefundFromPurchaseId",
- PrepareRefund = "prepareRefund",
+ StartRefundQueryForUri = "startRefundQueryForUri",
+ StartRefundQuery = "startRefundQuery",
AcceptBankIntegratedWithdrawal = "acceptBankIntegratedWithdrawal",
GetExchangeTos = "getExchangeTos",
GetExchangeDetailedInfo = "getExchangeDetailedInfo",
@@ -435,22 +435,16 @@ export type ConfirmPayOp = {
/**
* Check for a refund based on a taler://refund URI.
*/
-export type ApplyRefundOp = {
- op: WalletApiOperation.ApplyRefund;
- request: ApplyRefundRequest;
- response: ApplyRefundResponse;
-};
-
-export type ApplyRefundFromPurchaseIdOp = {
- op: WalletApiOperation.ApplyRefundFromPurchaseId;
- request: ApplyRefundFromPurchaseIdRequest;
- response: ApplyRefundResponse;
+export type StartRefundQueryForUriOp = {
+ op: WalletApiOperation.StartRefundQueryForUri;
+ request: PrepareRefundRequest;
+ response: EmptyObject;
};
-export type PrepareRefundOp = {
- op: WalletApiOperation.PrepareRefund;
- request: PrepareRefundRequest;
- response: PrepareRefundResult;
+export type StartRefundQueryOp = {
+ op: WalletApiOperation.StartRefundQuery;
+ request: StartRefundQueryRequest;
+ response: EmptyObject;
};
// group: Tipping
@@ -954,9 +948,8 @@ export type WalletOperations = {
[WalletApiOperation.RetryTransaction]: RetryTransactionOp;
[WalletApiOperation.PrepareTip]: PrepareTipOp;
[WalletApiOperation.AcceptTip]: AcceptTipOp;
- [WalletApiOperation.ApplyRefund]: ApplyRefundOp;
- [WalletApiOperation.ApplyRefundFromPurchaseId]: ApplyRefundFromPurchaseIdOp;
- [WalletApiOperation.PrepareRefund]: PrepareRefundOp;
+ [WalletApiOperation.StartRefundQueryForUri]: StartRefundQueryForUriOp;
+ [WalletApiOperation.StartRefundQuery]: StartRefundQueryOp;
[WalletApiOperation.ListCurrencies]: ListCurrenciesOp;
[WalletApiOperation.GetWithdrawalDetailsForAmount]: GetWithdrawalDetailsForAmountOp;
[WalletApiOperation.GetWithdrawalDetailsForUri]: GetWithdrawalDetailsForUriOp;
diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts
index ed174e33b..d76aa4ec9 100644
--- a/packages/taler-wallet-core/src/wallet.ts
+++ b/packages/taler-wallet-core/src/wallet.ts
@@ -48,6 +48,7 @@ import {
RefreshReason,
TalerError,
TalerErrorCode,
+ TransactionType,
URL,
ValidateIbanResponse,
WalletCoreVersion,
@@ -95,6 +96,7 @@ import {
codecForRetryTransactionRequest,
codecForSetCoinSuspendedRequest,
codecForSetWalletDeviceIdRequest,
+ codecForStartRefundQueryRequest,
codecForSuspendTransaction,
codecForTestPayArgs,
codecForTransactionByIdRequest,
@@ -188,13 +190,11 @@ import {
} from "./operations/exchanges.js";
import { getMerchantInfo } from "./operations/merchants.js";
import {
- applyRefund,
- applyRefundFromPurchaseId,
confirmPay,
getContractTermsDetails,
preparePayForUri,
- prepareRefund,
processPurchase,
+ startRefundQueryForUri,
} from "./operations/pay-merchant.js";
import {
checkPeerPullPaymentInitiation,
@@ -233,6 +233,7 @@ import {
deleteTransaction,
getTransactionById,
getTransactions,
+ parseTransactionIdentifier,
resumeTransaction,
retryTransaction,
suspendTransaction,
@@ -276,6 +277,7 @@ import {
WalletCoreApiClient,
WalletCoreResponseType,
} from "./wallet-api-types.js";
+import { startQueryRefund } from "./operations/pay-merchant.js";
const logger = new Logger("wallet.ts");
@@ -1141,14 +1143,6 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
await acceptExchangeTermsOfService(ws, req.exchangeBaseUrl, req.etag);
return {};
}
- case WalletApiOperation.ApplyRefund: {
- const req = codecForApplyRefundRequest().decode(payload);
- return await applyRefund(ws, req.talerRefundUri);
- }
- case WalletApiOperation.ApplyRefundFromPurchaseId: {
- const req = codecForApplyRefundFromPurchaseIdRequest().decode(payload);
- return await applyRefundFromPurchaseId(ws, req.purchaseId);
- }
case WalletApiOperation.AcceptBankIntegratedWithdrawal: {
const req =
codecForAcceptBankIntegratedWithdrawalRequest().decode(payload);
@@ -1292,9 +1286,22 @@ async function dispatchRequestInternal<Op extends WalletApiOperation>(
const req = codecForPrepareTipRequest().decode(payload);
return await prepareTip(ws, req.talerTipUri);
}
- case WalletApiOperation.PrepareRefund: {
+ case WalletApiOperation.StartRefundQueryForUri: {
const req = codecForPrepareRefundRequest().decode(payload);
- return await prepareRefund(ws, req.talerRefundUri);
+ await startRefundQueryForUri(ws, req.talerRefundUri);
+ return {};
+ }
+ case WalletApiOperation.StartRefundQuery: {
+ const req = codecForStartRefundQueryRequest().decode(payload);
+ const txIdParsed = parseTransactionIdentifier(req.transactionId);
+ if (!txIdParsed) {
+ throw Error("invalid transaction ID");
+ }
+ if (txIdParsed.tag !== TransactionType.Payment) {
+ throw Error("expected payment transaction ID");
+ }
+ await startQueryRefund(ws, txIdParsed.proposalId);
+ return {};
}
case WalletApiOperation.AcceptTip: {
const req = codecForAcceptTipRequest().decode(payload);