summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2022-10-08 20:56:57 +0200
committerFlorian Dold <florian@dold.me>2022-10-08 23:07:07 +0200
commit526f4eba9554f27e33afb0e02d19d870825b038c (patch)
treec35e41a20a3bc90da3beb81fa7831505ee64cfee /packages/taler-wallet-core/src/operations
parenteace0e0e7aad9113af758b829fffd873826e36e3 (diff)
downloadwallet-core-526f4eba9554f27e33afb0e02d19d870825b038c.tar.gz
wallet-core-526f4eba9554f27e33afb0e02d19d870825b038c.tar.bz2
wallet-core-526f4eba9554f27e33afb0e02d19d870825b038c.zip
wallet-core: Clean up merchant payments DB schema
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
-rw-r--r--packages/taler-wallet-core/src/operations/backup/export.ts95
-rw-r--r--packages/taler-wallet-core/src/operations/backup/import.ts201
-rw-r--r--packages/taler-wallet-core/src/operations/backup/index.ts11
-rw-r--r--packages/taler-wallet-core/src/operations/common.ts292
-rw-r--r--packages/taler-wallet-core/src/operations/deposits.ts5
-rw-r--r--packages/taler-wallet-core/src/operations/exchanges.ts3
-rw-r--r--packages/taler-wallet-core/src/operations/merchants.ts4
-rw-r--r--packages/taler-wallet-core/src/operations/pay-merchant.ts (renamed from packages/taler-wallet-core/src/operations/pay.ts)1225
-rw-r--r--packages/taler-wallet-core/src/operations/pay-peer.ts (renamed from packages/taler-wallet-core/src/operations/peer-to-peer.ts)3
-rw-r--r--packages/taler-wallet-core/src/operations/pending.ts85
-rw-r--r--packages/taler-wallet-core/src/operations/recoup.ts7
-rw-r--r--packages/taler-wallet-core/src/operations/refresh.ts2
-rw-r--r--packages/taler-wallet-core/src/operations/refund.ts815
-rw-r--r--packages/taler-wallet-core/src/operations/testing.ts5
-rw-r--r--packages/taler-wallet-core/src/operations/tip.ts5
-rw-r--r--packages/taler-wallet-core/src/operations/transactions.ts147
-rw-r--r--packages/taler-wallet-core/src/operations/withdraw.ts14
17 files changed, 1481 insertions, 1438 deletions
diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts
index c8454a62f..04fac7d38 100644
--- a/packages/taler-wallet-core/src/operations/backup/export.ts
+++ b/packages/taler-wallet-core/src/operations/backup/export.ts
@@ -37,7 +37,7 @@ import {
BackupExchangeDetails,
BackupExchangeWireFee,
BackupOperationStatus,
- BackupProposal,
+ BackupPayInfo,
BackupProposalStatus,
BackupPurchase,
BackupRecoupGroup,
@@ -62,11 +62,9 @@ import {
WalletBackupContentV1,
} from "@gnu-taler/taler-util";
import {
- AbortStatus,
CoinSourceType,
CoinStatus,
DenominationRecord,
- OperationStatus,
ProposalStatus,
RefreshCoinStatus,
RefundState,
@@ -92,7 +90,6 @@ export async function exportBackup(
x.coins,
x.denominations,
x.purchases,
- x.proposals,
x.refreshGroups,
x.backupProviders,
x.tips,
@@ -109,7 +106,6 @@ export async function exportBackup(
[url: string]: BackupDenomination[];
} = {};
const backupPurchases: BackupPurchase[] = [];
- const backupProposals: BackupProposal[] = [];
const backupRefreshGroups: BackupRefreshGroup[] = [];
const backupBackupProviders: BackupBackupProvider[] = [];
const backupTips: BackupTip[] = [];
@@ -385,65 +381,61 @@ export async function exportBackup(
}
}
- backupPurchases.push({
- contract_terms_raw: purch.download.contractTermsRaw,
- auto_refund_deadline: purch.autoRefundDeadline,
- merchant_pay_sig: purch.merchantPaySig,
- pay_coins: purch.payCoinSelection.coinPubs.map((x, i) => ({
- coin_pub: x,
- contribution: Amounts.stringify(
- purch.payCoinSelection.coinContributions[i],
- ),
- })),
- proposal_id: purch.proposalId,
- refunds,
- timestamp_accept: purch.timestampAccept,
- timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
- abort_status:
- purch.abortStatus === AbortStatus.None
- ? undefined
- : purch.abortStatus,
- nonce_priv: purch.noncePriv,
- merchant_sig: purch.download.contractData.merchantSig,
- total_pay_cost: Amounts.stringify(purch.totalPayCost),
- pay_coins_uid: purch.payCoinSelectionUid,
- });
- });
-
- await tx.proposals.iter().forEach((prop) => {
- if (purchaseProposalIdSet.has(prop.proposalId)) {
- return;
- }
let propStatus: BackupProposalStatus;
- switch (prop.proposalStatus) {
- case ProposalStatus.Accepted:
+ switch (purch.status) {
+ case ProposalStatus.Paid:
+ propStatus = BackupProposalStatus.Paid;
return;
- case ProposalStatus.Downloading:
+ case ProposalStatus.DownloadingProposal:
case ProposalStatus.Proposed:
propStatus = BackupProposalStatus.Proposed;
break;
- case ProposalStatus.PermanentlyFailed:
+ case ProposalStatus.ProposalDownloadFailed:
propStatus = BackupProposalStatus.PermanentlyFailed;
break;
- case ProposalStatus.Refused:
+ case ProposalStatus.ProposalRefused:
propStatus = BackupProposalStatus.Refused;
break;
- case ProposalStatus.Repurchase:
+ case ProposalStatus.RepurchaseDetected:
propStatus = BackupProposalStatus.Repurchase;
break;
+ default:
+ throw Error();
}
- backupProposals.push({
- claim_token: prop.claimToken,
- nonce_priv: prop.noncePriv,
- proposal_id: prop.noncePriv,
+
+ const payInfo = purch.payInfo;
+ let backupPayInfo: BackupPayInfo | undefined = undefined;
+ if (payInfo) {
+ backupPayInfo = {
+ pay_coins: payInfo.payCoinSelection.coinPubs.map((x, i) => ({
+ coin_pub: x,
+ contribution: Amounts.stringify(
+ payInfo.payCoinSelection.coinContributions[i],
+ ),
+ })),
+ total_pay_cost: Amounts.stringify(payInfo.totalPayCost),
+ pay_coins_uid: payInfo.payCoinSelectionUid,
+ };
+ }
+
+ backupPurchases.push({
+ contract_terms_raw: purch.download?.contractTermsRaw,
+ auto_refund_deadline: purch.autoRefundDeadline,
+ merchant_pay_sig: purch.merchantPaySig,
+ pay_info: backupPayInfo,
+ proposal_id: purch.proposalId,
+ refunds,
+ timestamp_accepted: purch.timestampAccept,
+ timestamp_first_successful_pay: purch.timestampFirstSuccessfulPay,
+ nonce_priv: purch.noncePriv,
+ merchant_sig: purch.download?.contractData.merchantSig,
+ claim_token: purch.claimToken,
+ merchant_base_url: purch.merchantBaseUrl,
+ order_id: purch.orderId,
proposal_status: propStatus,
- repurchase_proposal_id: prop.repurchaseProposalId,
- timestamp: prop.timestamp,
- contract_terms_raw: prop.download?.contractTermsRaw,
- download_session_id: prop.downloadSessionId,
- merchant_base_url: prop.merchantBaseUrl,
- order_id: prop.orderId,
- merchant_sig: prop.download?.contractData.merchantSig,
+ repurchase_proposal_id: purch.repurchaseProposalId,
+ download_session_id: purch.downloadSessionId,
+ timestamp_proposed: purch.timestamp,
});
});
@@ -498,7 +490,6 @@ export async function exportBackup(
wallet_root_pub: bs.walletRootPub,
backup_providers: backupBackupProviders,
current_device_id: bs.deviceId,
- proposals: backupProposals,
purchases: backupPurchases,
recoup_groups: backupRecoupGroups,
refresh_groups: backupRefreshGroups,
diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts
index fb747ef1c..00dbf6fa8 100644
--- a/packages/taler-wallet-core/src/operations/backup/import.ts
+++ b/packages/taler-wallet-core/src/operations/backup/import.ts
@@ -21,8 +21,8 @@ import {
BackupCoin,
BackupCoinSourceType,
BackupDenomSel,
+ BackupPayInfo,
BackupProposalStatus,
- BackupPurchase,
BackupRefreshReason,
BackupRefundState,
BackupWgType,
@@ -37,7 +37,6 @@ import {
WireInfo,
} from "@gnu-taler/taler-util";
import {
- AbortStatus,
CoinRecord,
CoinSource,
CoinSourceType,
@@ -48,28 +47,23 @@ import {
OperationStatus,
ProposalDownload,
ProposalStatus,
+ PurchasePayInfo,
RefreshCoinStatus,
RefreshSessionRecord,
RefundState,
- ReserveBankInfo,
- WithdrawalGroupStatus,
WalletContractData,
WalletRefundItem,
WalletStoresV1,
WgInfo,
+ WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../../db.js";
import { InternalWalletState } from "../../internal-wallet-state.js";
import { assertUnreachable } from "../../util/assertUnreachable.js";
-import {
- checkDbInvariant,
- checkLogicInvariant,
-} from "../../util/invariants.js";
+import { checkLogicInvariant } from "../../util/invariants.js";
import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";
-import { RetryInfo } from "../../util/retries.js";
-import { makeCoinAvailable } from "../../wallet.js";
+import { makeCoinAvailable, makeEventId, TombstoneTag } from "../common.js";
import { getExchangeDetails } from "../exchanges.js";
-import { makeEventId, TombstoneTag } from "../transactions.js";
import { provideBackupState } from "./state.js";
const logger = new Logger("operations/backup/import.ts");
@@ -95,10 +89,10 @@ async function recoverPayCoinSelection(
denominations: typeof WalletStoresV1.denominations;
}>,
contractData: WalletContractData,
- backupPurchase: BackupPurchase,
+ payInfo: BackupPayInfo,
): Promise<PayCoinSelection> {
- const coinPubs: string[] = backupPurchase.pay_coins.map((x) => x.coin_pub);
- const coinContributions: AmountJson[] = backupPurchase.pay_coins.map((x) =>
+ const coinPubs: string[] = payInfo.pay_coins.map((x) => x.coin_pub);
+ const coinContributions: AmountJson[] = payInfo.pay_coins.map((x) =>
Amounts.parseOrThrow(x.contribution),
);
@@ -316,7 +310,6 @@ export async function importBackup(
x.coinAvailability,
x.denominations,
x.purchases,
- x.proposals,
x.refreshGroups,
x.backupProviders,
x.tips,
@@ -560,113 +553,6 @@ export async function importBackup(
}
}
- for (const backupProposal of backupBlob.proposals) {
- const ts = makeEventId(
- TombstoneTag.DeletePayment,
- backupProposal.proposal_id,
- );
- if (tombstoneSet.has(ts)) {
- continue;
- }
- const existingProposal = await tx.proposals.get(
- backupProposal.proposal_id,
- );
- if (!existingProposal) {
- let download: ProposalDownload | undefined;
- let proposalStatus: ProposalStatus;
- switch (backupProposal.proposal_status) {
- case BackupProposalStatus.Proposed:
- if (backupProposal.contract_terms_raw) {
- proposalStatus = ProposalStatus.Proposed;
- } else {
- proposalStatus = ProposalStatus.Downloading;
- }
- break;
- case BackupProposalStatus.Refused:
- proposalStatus = ProposalStatus.Refused;
- break;
- case BackupProposalStatus.Repurchase:
- proposalStatus = ProposalStatus.Repurchase;
- break;
- case BackupProposalStatus.PermanentlyFailed:
- proposalStatus = ProposalStatus.PermanentlyFailed;
- break;
- }
- if (backupProposal.contract_terms_raw) {
- checkDbInvariant(!!backupProposal.merchant_sig);
- const parsedContractTerms = codecForContractTerms().decode(
- backupProposal.contract_terms_raw,
- );
- const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
- const contractTermsHash =
- cryptoComp.proposalIdToContractTermsHash[
- backupProposal.proposal_id
- ];
- let maxWireFee: AmountJson;
- if (parsedContractTerms.max_wire_fee) {
- maxWireFee = Amounts.parseOrThrow(
- parsedContractTerms.max_wire_fee,
- );
- } else {
- maxWireFee = Amounts.getZero(amount.currency);
- }
- download = {
- contractData: {
- amount,
- contractTermsHash: contractTermsHash,
- fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
- merchantBaseUrl: parsedContractTerms.merchant_base_url,
- merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: backupProposal.merchant_sig,
- orderId: parsedContractTerms.order_id,
- summary: parsedContractTerms.summary,
- autoRefund: parsedContractTerms.auto_refund,
- maxWireFee,
- payDeadline: parsedContractTerms.pay_deadline,
- refundDeadline: parsedContractTerms.refund_deadline,
- wireFeeAmortization:
- parsedContractTerms.wire_fee_amortization || 1,
- allowedAuditors: parsedContractTerms.auditors.map((x) => ({
- auditorBaseUrl: x.url,
- auditorPub: x.auditor_pub,
- })),
- allowedExchanges: parsedContractTerms.exchanges.map((x) => ({
- exchangeBaseUrl: x.url,
- exchangePub: x.master_pub,
- })),
- timestamp: parsedContractTerms.timestamp,
- wireMethod: parsedContractTerms.wire_method,
- wireInfoHash: parsedContractTerms.h_wire,
- maxDepositFee: Amounts.parseOrThrow(
- parsedContractTerms.max_fee,
- ),
- merchant: parsedContractTerms.merchant,
- products: parsedContractTerms.products,
- summaryI18n: parsedContractTerms.summary_i18n,
- deliveryDate: parsedContractTerms.delivery_date,
- deliveryLocation: parsedContractTerms.delivery_location,
- },
- contractTermsRaw: backupProposal.contract_terms_raw,
- };
- }
- await tx.proposals.put({
- claimToken: backupProposal.claim_token,
- merchantBaseUrl: backupProposal.merchant_base_url,
- timestamp: backupProposal.timestamp,
- orderId: backupProposal.order_id,
- noncePriv: backupProposal.nonce_priv,
- noncePub:
- cryptoComp.proposalNoncePrivToPub[backupProposal.nonce_priv],
- proposalId: backupProposal.proposal_id,
- repurchaseProposalId: backupProposal.repurchase_proposal_id,
- download,
- proposalStatus,
- // FIXME!
- downloadSessionId: undefined,
- });
- }
- }
-
for (const backupPurchase of backupBlob.purchases) {
const ts = makeEventId(
TombstoneTag.DeletePayment,
@@ -678,6 +564,14 @@ export async function importBackup(
const existingPurchase = await tx.purchases.get(
backupPurchase.proposal_id,
);
+ let proposalStatus: ProposalStatus;
+ switch (backupPurchase.proposal_status) {
+ case BackupProposalStatus.Paid:
+ proposalStatus = ProposalStatus.Paid;
+ break;
+ default:
+ throw Error();
+ }
if (!existingPurchase) {
const refunds: { [refundKey: string]: WalletRefundItem } = {};
for (const backupRefund of backupPurchase.refunds) {
@@ -721,25 +615,6 @@ export async function importBackup(
break;
}
}
- let abortStatus: AbortStatus;
- switch (backupPurchase.abort_status) {
- case "abort-finished":
- abortStatus = AbortStatus.AbortFinished;
- break;
- case "abort-refund":
- abortStatus = AbortStatus.AbortRefund;
- break;
- case undefined:
- abortStatus = AbortStatus.None;
- break;
- default:
- logger.warn(
- `got backup purchase abort_status ${j2s(
- backupPurchase.abort_status,
- )}`,
- );
- throw Error("not reachable");
- }
const parsedContractTerms = codecForContractTerms().decode(
backupPurchase.contract_terms_raw,
);
@@ -761,7 +636,7 @@ export async function importBackup(
fulfillmentUrl: parsedContractTerms.fulfillment_url ?? "",
merchantBaseUrl: parsedContractTerms.merchant_base_url,
merchantPub: parsedContractTerms.merchant_pub,
- merchantSig: backupPurchase.merchant_sig,
+ merchantSig: backupPurchase.merchant_sig!,
orderId: parsedContractTerms.order_id,
summary: parsedContractTerms.summary,
autoRefund: parsedContractTerms.auto_refund,
@@ -790,33 +665,46 @@ export async function importBackup(
},
contractTermsRaw: backupPurchase.contract_terms_raw,
};
+
+ let payInfo: PurchasePayInfo | undefined = undefined;
+ if (backupPurchase.pay_info) {
+ payInfo = {
+ coinDepositPermissions: undefined,
+ payCoinSelection: await recoverPayCoinSelection(
+ tx,
+ download.contractData,
+ backupPurchase.pay_info,
+ ),
+ payCoinSelectionUid: backupPurchase.pay_info.pay_coins_uid,
+ totalPayCost: Amounts.parseOrThrow(
+ backupPurchase.pay_info.total_pay_cost,
+ ),
+ };
+ }
+
await tx.purchases.put({
proposalId: backupPurchase.proposal_id,
noncePriv: backupPurchase.nonce_priv,
noncePub:
cryptoComp.proposalNoncePrivToPub[backupPurchase.nonce_priv],
autoRefundDeadline: TalerProtocolTimestamp.never(),
- refundAwaiting: undefined,
- timestampAccept: backupPurchase.timestamp_accept,
+ timestampAccept: backupPurchase.timestamp_accepted,
timestampFirstSuccessfulPay:
backupPurchase.timestamp_first_successful_pay,
timestampLastRefundStatus: undefined,
merchantPaySig: backupPurchase.merchant_pay_sig,
lastSessionId: undefined,
- abortStatus,
download,
- paymentSubmitPending:
- !backupPurchase.timestamp_first_successful_pay,
- refundQueryRequested: false,
- payCoinSelection: await recoverPayCoinSelection(
- tx,
- download.contractData,
- backupPurchase,
- ),
- coinDepositPermissions: undefined,
- totalPayCost: Amounts.parseOrThrow(backupPurchase.total_pay_cost),
refunds,
- payCoinSelectionUid: backupPurchase.pay_coins_uid,
+ claimToken: backupPurchase.claim_token,
+ downloadSessionId: backupPurchase.download_session_id,
+ merchantBaseUrl: backupPurchase.merchant_base_url,
+ orderId: backupPurchase.order_id,
+ payInfo,
+ refundAmountAwaiting: undefined,
+ repurchaseProposalId: backupPurchase.repurchase_proposal_id,
+ status: proposalStatus,
+ timestamp: backupPurchase.timestamp_proposed,
});
}
}
@@ -948,7 +836,6 @@ export async function importBackup(
await tx.depositGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeletePayment) {
await tx.purchases.delete(rest[0]);
- await tx.proposals.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefreshGroup) {
await tx.refreshGroups.delete(rest[0]);
} else if (type === TombstoneTag.DeleteRefund) {
diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts
index fc84ce4ef..3d3ebf04a 100644
--- a/packages/taler-wallet-core/src/operations/backup/index.ts
+++ b/packages/taler-wallet-core/src/operations/backup/index.ts
@@ -96,7 +96,7 @@ import {
checkPaymentByProposalId,
confirmPay,
preparePayForUri,
-} from "../pay.js";
+} from "../pay-merchant.js";
import { exportBackup } from "./export.js";
import { BackupCryptoPrecomputedData, importBackup } from "./import.js";
import { getWalletBackupState, provideBackupState } from "./state.js";
@@ -193,15 +193,6 @@ async function computeBackupCryptoData(
eddsaGetPublic(decodeCrock(backupWg.reserve_priv)),
);
}
- for (const prop of backupContent.proposals) {
- const { h: contractTermsHash } = await cryptoApi.hashString({
- str: canonicalJson(prop.contract_terms_raw),
- });
- const noncePub = encodeCrock(eddsaGetPublic(decodeCrock(prop.nonce_priv)));
- cryptoData.proposalNoncePrivToPub[prop.nonce_priv] = noncePub;
- cryptoData.proposalIdToContractTermsHash[prop.proposal_id] =
- contractTermsHash;
- }
for (const purch of backupContent.purchases) {
const { h: contractTermsHash } = await cryptoApi.hashString({
str: canonicalJson(purch.contract_terms_raw),
diff --git a/packages/taler-wallet-core/src/operations/common.ts b/packages/taler-wallet-core/src/operations/common.ts
index 6d54503a1..9f235c9b4 100644
--- a/packages/taler-wallet-core/src/operations/common.ts
+++ b/packages/taler-wallet-core/src/operations/common.ts
@@ -17,38 +17,272 @@
/**
* Imports.
*/
-import { TalerErrorDetail, TalerErrorCode } from "@gnu-taler/taler-util";
-import { CryptoApiStoppedError } from "../crypto/workers/cryptoDispatcher.js";
-import { TalerError, getErrorDetailFromException } from "../errors.js";
+import {
+ AmountJson,
+ Amounts,
+ j2s,
+ Logger,
+ RefreshReason,
+ TalerErrorCode,
+ TalerErrorDetail,
+ TransactionType,
+} from "@gnu-taler/taler-util";
+import { WalletStoresV1, CoinStatus, CoinRecord } from "../db.js";
+import { makeErrorDetail, TalerError } from "../errors.js";
+import { InternalWalletState } from "../internal-wallet-state.js";
+import { checkDbInvariant, checkLogicInvariant } from "../util/invariants.js";
+import { GetReadWriteAccess } from "../util/query.js";
+import {
+ OperationAttemptResult,
+ OperationAttemptResultType,
+ RetryInfo,
+} from "../util/retries.js";
+import { createRefreshGroup } from "./refresh.js";
-/**
- * Run an operation and call the onOpError callback
- * when there was an exception or operation error that must be reported.
- * The cause will be re-thrown to the caller.
- */
-export async function guardOperationException<T>(
- op: () => Promise<T>,
- onOpError: (e: TalerErrorDetail) => Promise<void>,
-): Promise<T> {
+const logger = new Logger("operations/common.ts");
+
+export interface CoinsSpendInfo {
+ coinPubs: string[];
+ contributions: AmountJson[];
+ refreshReason: RefreshReason;
+ /**
+ * Identifier for what the coin has been spent for.
+ */
+ allocationId: string;
+}
+
+export async function makeCoinAvailable(
+ ws: InternalWalletState,
+ tx: GetReadWriteAccess<{
+ coins: typeof WalletStoresV1.coins;
+ coinAvailability: typeof WalletStoresV1.coinAvailability;
+ denominations: typeof WalletStoresV1.denominations;
+ }>,
+ coinRecord: CoinRecord,
+): Promise<void> {
+ checkLogicInvariant(coinRecord.status === CoinStatus.Fresh);
+ const existingCoin = await tx.coins.get(coinRecord.coinPub);
+ if (existingCoin) {
+ return;
+ }
+ const denom = await tx.denominations.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ]);
+ checkDbInvariant(!!denom);
+ const ageRestriction = coinRecord.maxAge;
+ let car = await tx.coinAvailability.get([
+ coinRecord.exchangeBaseUrl,
+ coinRecord.denomPubHash,
+ ageRestriction,
+ ]);
+ if (!car) {
+ car = {
+ maxAge: ageRestriction,
+ amountFrac: denom.amountFrac,
+ amountVal: denom.amountVal,
+ currency: denom.currency,
+ denomPubHash: denom.denomPubHash,
+ exchangeBaseUrl: denom.exchangeBaseUrl,
+ freshCoinCount: 0,
+ };
+ }
+ car.freshCoinCount++;
+ await tx.coins.put(coinRecord);
+ await tx.coinAvailability.put(car);
+}
+
+export async function spendCoins(
+ ws: InternalWalletState,
+ tx: GetReadWriteAccess<{
+ coins: typeof WalletStoresV1.coins;
+ coinAvailability: typeof WalletStoresV1.coinAvailability;
+ refreshGroups: typeof WalletStoresV1.refreshGroups;
+ denominations: typeof WalletStoresV1.denominations;
+ }>,
+ csi: CoinsSpendInfo,
+): Promise<void> {
+ for (let i = 0; i < csi.coinPubs.length; i++) {
+ const coin = await tx.coins.get(csi.coinPubs[i]);
+ if (!coin) {
+ throw Error("coin allocated for payment doesn't exist anymore");
+ }
+ const coinAvailability = await tx.coinAvailability.get([
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ coin.maxAge,
+ ]);
+ checkDbInvariant(!!coinAvailability);
+ const contrib = csi.contributions[i];
+ if (coin.status !== CoinStatus.Fresh) {
+ const alloc = coin.allocation;
+ if (!alloc) {
+ continue;
+ }
+ if (alloc.id !== csi.allocationId) {
+ // FIXME: assign error code
+ throw Error("conflicting coin allocation (id)");
+ }
+ if (0 !== Amounts.cmp(alloc.amount, contrib)) {
+ // FIXME: assign error code
+ throw Error("conflicting coin allocation (contrib)");
+ }
+ continue;
+ }
+ coin.status = CoinStatus.Dormant;
+ coin.allocation = {
+ id: csi.allocationId,
+ amount: Amounts.stringify(contrib),
+ };
+ const remaining = Amounts.sub(coin.currentAmount, contrib);
+ if (remaining.saturated) {
+ throw Error("not enough remaining balance on coin for payment");
+ }
+ coin.currentAmount = remaining.amount;
+ checkDbInvariant(!!coinAvailability);
+ if (coinAvailability.freshCoinCount === 0) {
+ throw Error(
+ `invalid coin count ${coinAvailability.freshCoinCount} in DB`,
+ );
+ }
+ coinAvailability.freshCoinCount--;
+ await tx.coins.put(coin);
+ await tx.coinAvailability.put(coinAvailability);
+ }
+ const refreshCoinPubs = csi.coinPubs.map((x) => ({
+ coinPub: x,
+ }));
+ await ws.refreshOps.createRefreshGroup(
+ ws,
+ tx,
+ refreshCoinPubs,
+ RefreshReason.PayMerchant,
+ );
+}
+
+export async function storeOperationError(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+ e: TalerErrorDetail,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [x.operationRetries])
+ .runReadWrite(async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ lastError: e,
+ retryInfo: RetryInfo.reset(),
+ };
+ } else {
+ retryRecord.lastError = e;
+ retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ });
+}
+
+export async function storeOperationPending(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [x.operationRetries])
+ .runReadWrite(async (tx) => {
+ let retryRecord = await tx.operationRetries.get(pendingTaskId);
+ if (!retryRecord) {
+ retryRecord = {
+ id: pendingTaskId,
+ retryInfo: RetryInfo.reset(),
+ };
+ } else {
+ delete retryRecord.lastError;
+ retryRecord.retryInfo = RetryInfo.increment(retryRecord.retryInfo);
+ }
+ await tx.operationRetries.put(retryRecord);
+ });
+}
+
+export async function runOperationWithErrorReporting(
+ ws: InternalWalletState,
+ opId: string,
+ f: () => Promise<OperationAttemptResult>,
+): Promise<void> {
+ let maybeError: TalerErrorDetail | undefined;
try {
- return await op();
- } catch (e: any) {
- if (e instanceof CryptoApiStoppedError) {
- throw e;
+ const resp = await f();
+ switch (resp.type) {
+ case OperationAttemptResultType.Error:
+ return await storeOperationError(ws, opId, resp.errorDetail);
+ case OperationAttemptResultType.Finished:
+ return await storeOperationFinished(ws, opId);
+ case OperationAttemptResultType.Pending:
+ return await storeOperationPending(ws, opId);
+ case OperationAttemptResultType.Longpoll:
+ break;
}
- if (
- e instanceof TalerError &&
- e.hasErrorCode(TalerErrorCode.WALLET_PENDING_OPERATION_FAILED)
- ) {
- throw e;
+ } catch (e) {
+ if (e instanceof TalerError) {
+ logger.warn("operation processed resulted in error");
+ logger.warn(`error was: ${j2s(e.errorDetail)}`);
+ maybeError = e.errorDetail;
+ return await storeOperationError(ws, opId, maybeError!);
+ } else if (e instanceof Error) {
+ // This is a bug, as we expect pending operations to always
+ // do their own error handling and only throw WALLET_PENDING_OPERATION_FAILED
+ // or return something.
+ logger.error(`Uncaught exception: ${e.message}`);
+ logger.error(`Stack: ${e.stack}`);
+ maybeError = makeErrorDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ {
+ stack: e.stack,
+ },
+ `unexpected exception (message: ${e.message})`,
+ );
+ return await storeOperationError(ws, opId, maybeError);
+ } else {
+ logger.error("Uncaught exception, value is not even an error.");
+ maybeError = makeErrorDetail(
+ TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ {},
+ `unexpected exception (not even an error)`,
+ );
+ return await storeOperationError(ws, opId, maybeError);
}
- const opErr = getErrorDetailFromException(e);
- await onOpError(opErr);
- throw TalerError.fromDetail(
- TalerErrorCode.WALLET_PENDING_OPERATION_FAILED,
- {
- innerError: opErr,
- },
- );
}
}
+
+export async function storeOperationFinished(
+ ws: InternalWalletState,
+ pendingTaskId: string,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [x.operationRetries])
+ .runReadWrite(async (tx) => {
+ await tx.operationRetries.delete(pendingTaskId);
+ });
+}
+
+export enum TombstoneTag {
+ DeleteWithdrawalGroup = "delete-withdrawal-group",
+ DeleteReserve = "delete-reserve",
+ DeletePayment = "delete-payment",
+ DeleteTip = "delete-tip",
+ DeleteRefreshGroup = "delete-refresh-group",
+ DeleteDepositGroup = "delete-deposit-group",
+ DeleteRefund = "delete-refund",
+ DeletePeerPullDebit = "delete-peer-pull-debit",
+ DeletePeerPushDebit = "delete-peer-push-debit",
+}
+
+/**
+ * Create an event ID from the type and the primary key for the event.
+ */
+export function makeEventId(
+ type: TransactionType | TombstoneTag,
+ ...args: string[]
+): string {
+ return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
+}
diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts
index 625bc0828..1f7d05d29 100644
--- a/packages/taler-wallet-core/src/operations/deposits.ts
+++ b/packages/taler-wallet-core/src/operations/deposits.ts
@@ -53,16 +53,15 @@ import {
import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { OperationAttemptResult } from "../util/retries.js";
-import { spendCoins } from "../wallet.js";
+import { makeEventId, spendCoins } from "./common.js";
import { getExchangeDetails } from "./exchanges.js";
import {
extractContractData,
generateDepositPermissions,
getTotalPaymentCost,
selectPayCoinsNew,
-} from "./pay.js";
+} from "./pay-merchant.js";
import { getTotalRefreshCost } from "./refresh.js";
-import { makeEventId } from "./transactions.js";
/**
* Logger.
diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts
index 1dd8660b5..9a6c72577 100644
--- a/packages/taler-wallet-core/src/operations/exchanges.ts
+++ b/packages/taler-wallet-core/src/operations/exchanges.ts
@@ -40,7 +40,6 @@ import {
parsePaytoUri,
Recoup,
TalerErrorCode,
- TalerErrorDetail,
TalerProtocolDuration,
TalerProtocolTimestamp,
URL,
@@ -71,11 +70,9 @@ import {
import {
OperationAttemptResult,
OperationAttemptResultType,
- RetryInfo,
runOperationHandlerForResult,
} from "../util/retries.js";
import { WALLET_EXCHANGE_PROTOCOL_VERSION } from "../versions.js";
-import { guardOperationException } from "./common.js";
const logger = new Logger("exchanges.ts");
diff --git a/packages/taler-wallet-core/src/operations/merchants.ts b/packages/taler-wallet-core/src/operations/merchants.ts
index 614478715..f5b3ca38c 100644
--- a/packages/taler-wallet-core/src/operations/merchants.ts
+++ b/packages/taler-wallet-core/src/operations/merchants.ts
@@ -25,7 +25,7 @@ import {
LibtoolVersion,
} from "@gnu-taler/taler-util";
import { InternalWalletState, MerchantInfo } from "../internal-wallet-state.js";
-import { readSuccessResponseJsonOrThrow } from "../index.js";
+import { readSuccessResponseJsonOrThrow } from "../util/http.js";
const logger = new Logger("taler-wallet-core:merchants.ts");
@@ -40,7 +40,7 @@ export async function getMerchantInfo(
return existingInfo;
}
- const configUrl = new URL("config", canonBaseUrl);
+const configUrl = new URL("config", canonBaseUrl);
const resp = await ws.http.get(configUrl.href);
const configResp = await readSuccessResponseJsonOrThrow(
diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts
index 6757b79b4..97901c71e 100644
--- a/packages/taler-wallet-core/src/operations/pay.ts
+++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -26,14 +26,21 @@
*/
import { GlobalIDB } from "@gnu-taler/idb-bridge";
import {
+ AbortingCoin,
+ AbortRequest,
AbsoluteTime,
AgeRestriction,
AmountJson,
Amounts,
+ ApplyRefundResponse,
+ codecForAbortResponse,
codecForContractTerms,
+ codecForMerchantOrderRefundPickupResponse,
+ codecForMerchantOrderStatusPaid,
codecForMerchantPayResponse,
codecForProposal,
CoinDepositPermission,
+ CoinPublicKey,
ConfirmPayResult,
ConfirmPayResultType,
ContractTerms,
@@ -46,12 +53,17 @@ import {
HttpStatusCode,
j2s,
Logger,
+ MerchantCoinRefundFailureStatus,
+ MerchantCoinRefundStatus,
+ MerchantCoinRefundSuccessStatus,
NotificationType,
parsePaytoUri,
parsePayUri,
+ parseRefundUri,
PayCoinSelection,
PreparePayResult,
PreparePayResultType,
+ PrepareRefundResult,
RefreshReason,
strcmp,
TalerErrorCode,
@@ -62,17 +74,19 @@ import {
} from "@gnu-taler/taler-util";
import { EddsaKeypair } from "../crypto/cryptoImplementation.js";
import {
- AbortStatus,
AllowedAuditorInfo,
AllowedExchangeInfo,
BackupProviderStateTag,
CoinRecord,
CoinStatus,
DenominationRecord,
- ProposalRecord,
+ ProposalDownload,
ProposalStatus,
PurchaseRecord,
+ RefundReason,
+ RefundState,
WalletContractData,
+ WalletStoresV1,
} from "../db.js";
import {
makeErrorDetail,
@@ -80,6 +94,7 @@ import {
TalerError,
TalerProtocolViolationError,
} from "../errors.js";
+import { GetReadWriteAccess } from "../index.browser.js";
import {
EXCHANGE_COINS_LOCK,
InternalWalletState,
@@ -109,12 +124,12 @@ import {
} from "../util/retries.js";
import {
spendCoins,
- storeOperationError,
storeOperationPending,
-} from "../wallet.js";
+ storeOperationError,
+ makeEventId,
+} from "./common.js";
import { getExchangeDetails } from "./exchanges.js";
-import { getTotalRefreshCost } from "./refresh.js";
-import { makeEventId } from "./transactions.js";
+import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
/**
* Logger.
@@ -203,98 +218,20 @@ export interface CoinSelectionRequest {
minimumAge?: number;
}
-/**
- * Record all information that is necessary to
- * pay for a proposal in the wallet's database.
- */
-async function recordConfirmPay(
- ws: InternalWalletState,
- proposal: ProposalRecord,
- coinSelection: PayCoinSelection,
- coinDepositPermissions: CoinDepositPermission[],
- sessionIdOverride: string | undefined,
-): Promise<PurchaseRecord> {
- const d = proposal.download;
- if (!d) {
- throw Error("proposal is in invalid state");
- }
- let sessionId;
- if (sessionIdOverride) {
- sessionId = sessionIdOverride;
- } else {
- sessionId = proposal.downloadSessionId;
- }
- logger.trace(
- `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
- );
- const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
- const t: PurchaseRecord = {
- abortStatus: AbortStatus.None,
- download: d,
- lastSessionId: sessionId,
- payCoinSelection: coinSelection,
- payCoinSelectionUid: encodeCrock(getRandomBytes(32)),
- totalPayCost: payCostInfo,
- coinDepositPermissions,
- timestampAccept: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
- timestampLastRefundStatus: undefined,
- proposalId: proposal.proposalId,
- refundQueryRequested: false,
- timestampFirstSuccessfulPay: undefined,
- autoRefundDeadline: undefined,
- refundAwaiting: undefined,
- paymentSubmitPending: true,
- refunds: {},
- merchantPaySig: undefined,
- noncePriv: proposal.noncePriv,
- noncePub: proposal.noncePub,
- };
-
- await ws.db
- .mktx((x) => [
- x.proposals,
- x.purchases,
- x.coins,
- x.refreshGroups,
- x.denominations,
- x.coinAvailability,
- ])
- .runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposal.proposalId);
- if (p) {
- p.proposalStatus = ProposalStatus.Accepted;
- await tx.proposals.put(p);
- }
- await tx.purchases.put(t);
- await spendCoins(ws, tx, {
- allocationId: `proposal:${t.proposalId}`,
- coinPubs: coinSelection.coinPubs,
- contributions: coinSelection.coinContributions,
- refreshReason: RefreshReason.PayMerchant,
- });
- });
-
- ws.notify({
- type: NotificationType.ProposalAccepted,
- proposalId: proposal.proposalId,
- });
- return t;
-}
-
async function failProposalPermanently(
ws: InternalWalletState,
proposalId: string,
err: TalerErrorDetail,
): Promise<void> {
await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
+ const p = await tx.purchases.get(proposalId);
if (!p) {
return;
}
- p.proposalStatus = ProposalStatus.PermanentlyFailed;
- await tx.proposals.put(p);
+ p.status = ProposalStatus.ProposalDownloadFailed;
+ await tx.purchases.put(p);
});
}
@@ -309,10 +246,24 @@ function getProposalRequestTimeout(retryInfo?: RetryInfo): Duration {
function getPayRequestTimeout(purchase: PurchaseRecord): Duration {
return Duration.multiply(
{ d_ms: 15000 },
- 1 + purchase.payCoinSelection.coinPubs.length / 5,
+ 1 + (purchase.payInfo?.payCoinSelection.coinPubs.length ?? 0) / 5,
);
}
+/**
+ * Return the proposal download data for a purchase, throw if not available.
+ *
+ * (Async since in the future this will query the DB.)
+ */
+export async function expectProposalDownload(
+ p: PurchaseRecord,
+): Promise<ProposalDownload> {
+ if (!p.download) {
+ throw Error("expected proposal to be downloaded");
+ }
+ return p.download;
+}
+
export function extractContractData(
parsedContractTerms: ContractTerms,
contractTermsHash: string,
@@ -366,9 +317,9 @@ export async function processDownloadProposal(
options: object = {},
): Promise<OperationAttemptResult> {
const proposal = await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
- return await tx.proposals.get(proposalId);
+ return await tx.purchases.get(proposalId);
});
if (!proposal) {
@@ -378,7 +329,7 @@ export async function processDownloadProposal(
};
}
- if (proposal.proposalStatus != ProposalStatus.Downloading) {
+ if (proposal.status != ProposalStatus.DownloadingProposal) {
return {
type: OperationAttemptResultType.Finished,
result: undefined,
@@ -401,7 +352,7 @@ export async function processDownloadProposal(
requestBody.token = proposal.claimToken;
}
- const opId = RetryTags.forProposalClaim(proposal);
+ const opId = RetryTags.forPay(proposal);
const retryRecord = await ws.db
.mktx((x) => [x.operationRetries])
.runReadOnly(async (tx) => {
@@ -543,13 +494,13 @@ export async function processDownloadProposal(
logger.trace(`extracted contract data: ${j2s(contractData)}`);
await ws.db
- .mktx((x) => [x.purchases, x.proposals])
+ .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
- const p = await tx.proposals.get(proposalId);
+ const p = await tx.purchases.get(proposalId);
if (!p) {
return;
}
- if (p.proposalStatus !== ProposalStatus.Downloading) {
+ if (p.status !== ProposalStatus.DownloadingProposal) {
return;
}
p.download = {
@@ -565,14 +516,14 @@ export async function processDownloadProposal(
await tx.purchases.indexes.byFulfillmentUrl.get(fulfillmentUrl);
if (differentPurchase) {
logger.warn("repurchase detected");
- p.proposalStatus = ProposalStatus.Repurchase;
+ p.status = ProposalStatus.RepurchaseDetected;
p.repurchaseProposalId = differentPurchase.proposalId;
- await tx.proposals.put(p);
+ await tx.purchases.put(p);
return;
}
}
- p.proposalStatus = ProposalStatus.Proposed;
- await tx.proposals.put(p);
+ p.status = ProposalStatus.Proposed;
+ await tx.purchases.put(p);
});
ws.notify({
@@ -602,9 +553,9 @@ async function startDownloadProposal(
noncePriv: string | undefined,
): Promise<string> {
const oldProposal = await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
- return tx.proposals.indexes.byUrlAndOrderId.get([
+ return tx.purchases.indexes.byUrlAndOrderId.get([
merchantBaseUrl,
orderId,
]);
@@ -635,7 +586,7 @@ async function startDownloadProposal(
const { priv, pub } = noncePair;
const proposalId = encodeCrock(getRandomBytes(32));
- const proposalRecord: ProposalRecord = {
+ const proposalRecord: PurchaseRecord = {
download: undefined,
noncePriv: priv,
noncePub: pub,
@@ -644,15 +595,25 @@ async function startDownloadProposal(
merchantBaseUrl,
orderId,
proposalId: proposalId,
- proposalStatus: ProposalStatus.Downloading,
+ status: ProposalStatus.DownloadingProposal,
repurchaseProposalId: undefined,
downloadSessionId: sessionId,
+ autoRefundDeadline: undefined,
+ lastSessionId: undefined,
+ merchantPaySig: undefined,
+ payInfo: undefined,
+ refundAmountAwaiting: undefined,
+ refunds: {},
+ timestampAccept: undefined,
+ timestampFirstSuccessfulPay: undefined,
+ timestampLastRefundStatus: undefined,
+ pendingRemovedCoinPubs: undefined,
};
await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
- const existingRecord = await tx.proposals.indexes.byUrlAndOrderId.get([
+ const existingRecord = await tx.purchases.indexes.byUrlAndOrderId.get([
merchantBaseUrl,
orderId,
]);
@@ -660,7 +621,7 @@ async function startDownloadProposal(
// Created concurrently
return;
}
- await tx.proposals.put(proposalRecord);
+ await tx.purchases.put(proposalRecord);
});
await processDownloadProposal(ws, proposalId);
@@ -688,15 +649,17 @@ async function storeFirstPaySuccess(
logger.warn("payment success already stored");
return;
}
+ if (purchase.status === ProposalStatus.Paying) {
+ purchase.status = ProposalStatus.Paid;
+ }
purchase.timestampFirstSuccessfulPay = now;
- purchase.paymentSubmitPending = false;
purchase.lastSessionId = sessionId;
purchase.merchantPaySig = paySig;
- const protoAr = purchase.download.contractData.autoRefund;
+ const protoAr = purchase.download!.contractData.autoRefund;
if (protoAr) {
const ar = Duration.fromTalerProtocolDuration(protoAr);
logger.info("auto_refund present");
- purchase.refundQueryRequested = true;
+ purchase.status = ProposalStatus.QueryingAutoRefund;
purchase.autoRefundDeadline = AbsoluteTime.toTimestamp(
AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
);
@@ -723,7 +686,9 @@ async function storePayReplaySuccess(
if (isFirst) {
throw Error("invalid payment state");
}
- purchase.paymentSubmitPending = false;
+ if (purchase.status === ProposalStatus.Paying) {
+ purchase.status = ProposalStatus.Paid;
+ }
purchase.lastSessionId = sessionId;
await tx.purchases.put(purchase);
});
@@ -774,19 +739,26 @@ async function handleInsufficientFunds(
throw new TalerProtocolViolationError();
}
- const { contractData } = proposal.download;
+ const { contractData } = proposal.download!;
const prevPayCoins: PreviousPayCoins = [];
+ const payInfo = proposal.payInfo;
+ if (!payInfo) {
+ return;
+ }
+
+ const payCoinSelection = payInfo.payCoinSelection;
+
await ws.db
.mktx((x) => [x.coins, x.denominations])
.runReadOnly(async (tx) => {
- for (let i = 0; i < proposal.payCoinSelection.coinPubs.length; i++) {
- const coinPub = proposal.payCoinSelection.coinPubs[i];
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
if (coinPub === brokenCoinPub) {
continue;
}
- const contrib = proposal.payCoinSelection.coinContributions[i];
+ const contrib = payCoinSelection.coinContributions[i];
const coin = await tx.coins.get(coinPub);
if (!coin) {
continue;
@@ -839,14 +811,19 @@ async function handleInsufficientFunds(
if (!p) {
return;
}
- p.payCoinSelection = res;
- p.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
- p.coinDepositPermissions = undefined;
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ return;
+ }
+ payInfo.payCoinSelection = res;
+ payInfo.payCoinSelection = res;
+ payInfo.payCoinSelectionUid = encodeCrock(getRandomBytes(32));
+ payInfo.coinDepositPermissions = undefined;
await tx.purchases.put(p);
await spendCoins(ws, tx, {
allocationId: `proposal:${p.proposalId}`,
- coinPubs: p.payCoinSelection.coinPubs,
- contributions: p.payCoinSelection.coinContributions,
+ coinPubs: payInfo.payCoinSelection.coinPubs,
+ contributions: payInfo.payCoinSelection.coinContributions,
refreshReason: RefreshReason.PayMerchant,
});
});
@@ -1255,23 +1232,23 @@ export async function checkPaymentByProposalId(
sessionId?: string,
): Promise<PreparePayResult> {
let proposal = await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
+ return tx.purchases.get(proposalId);
});
if (!proposal) {
throw Error(`could not get proposal ${proposalId}`);
}
- if (proposal.proposalStatus === ProposalStatus.Repurchase) {
+ if (proposal.status === ProposalStatus.RepurchaseDetected) {
const existingProposalId = proposal.repurchaseProposalId;
if (!existingProposalId) {
throw Error("invalid proposal state");
}
logger.trace("using existing purchase for same product");
proposal = await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
- return tx.proposals.get(existingProposalId);
+ return tx.purchases.get(existingProposalId);
});
if (!proposal) {
throw Error("existing proposal is in wrong state");
@@ -1297,7 +1274,7 @@ export async function checkPaymentByProposalId(
return tx.purchases.get(proposalId);
});
- if (!purchase) {
+ if (!purchase || purchase.status === ProposalStatus.Proposed) {
// If not already paid, check if we could pay for it.
const res = await selectPayCoinsNew(ws, {
auditors: contractData.allowedAuditors,
@@ -1337,10 +1314,14 @@ export async function checkPaymentByProposalId(
};
}
- if (purchase.lastSessionId !== sessionId) {
+ if (
+ purchase.status === ProposalStatus.Paid &&
+ purchase.lastSessionId !== sessionId
+ ) {
logger.trace(
"automatically re-submitting payment with different session ID",
);
+ logger.trace(`last: ${purchase.lastSessionId}, current: ${sessionId}`);
await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
@@ -1349,7 +1330,7 @@ export async function checkPaymentByProposalId(
return;
}
p.lastSessionId = sessionId;
- p.paymentSubmitPending = true;
+ p.status = ProposalStatus.PayingReplay;
await tx.purchases.put(p);
});
const r = await processPurchasePay(ws, proposalId, { forceNow: true });
@@ -1357,35 +1338,41 @@ export async function checkPaymentByProposalId(
// FIXME: This does not surface the original error
throw Error("submitting pay failed");
}
+ const download = await expectProposalDownload(purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
paid: true,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
proposalId,
};
} else if (!purchase.timestampFirstSuccessfulPay) {
+ const download = await expectProposalDownload(purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
paid: false,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
proposalId,
};
} else {
- const paid = !purchase.paymentSubmitPending;
+ const paid =
+ purchase.status === ProposalStatus.Paid ||
+ purchase.status === ProposalStatus.QueryingRefund ||
+ purchase.status === ProposalStatus.QueryingAutoRefund;
+ const download = await expectProposalDownload(purchase);
return {
status: PreparePayResultType.AlreadyConfirmed,
- contractTerms: purchase.download.contractTermsRaw,
- contractTermsHash: purchase.download.contractData.contractTermsHash,
+ contractTerms: download.contractTermsRaw,
+ contractTermsHash: download.contractData.contractTermsHash,
paid,
- amountRaw: Amounts.stringify(purchase.download.contractData.amount),
- amountEffective: Amounts.stringify(purchase.totalPayCost),
- ...(paid ? { nextUrl: purchase.download.contractData.orderId } : {}),
+ amountRaw: Amounts.stringify(download.contractData.amount),
+ amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ ...(paid ? { nextUrl: download.contractData.orderId } : {}),
proposalId,
};
}
@@ -1396,9 +1383,9 @@ export async function getContractTermsDetails(
proposalId: string,
): Promise<WalletContractData> {
const proposal = await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
+ return tx.purchases.get(proposalId);
});
if (!proposal) {
@@ -1574,7 +1561,10 @@ export async function runPayForConfirmPay(
}
}
case OperationAttemptResultType.Pending:
- await storeOperationPending(ws, `${PendingTaskType.Pay}:${proposalId}`);
+ await storeOperationPending(
+ ws,
+ `${PendingTaskType.Purchase}:${proposalId}`,
+ );
return {
type: ConfirmPayResultType.Pending,
transactionId: makeEventId(TransactionType.Payment, proposalId),
@@ -1600,9 +1590,9 @@ export async function confirmPay(
`executing confirmPay with proposalId ${proposalId} and sessionIdOverride ${sessionIdOverride}`,
);
const proposal = await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadOnly(async (tx) => {
- return tx.proposals.get(proposalId);
+ return tx.purchases.get(proposalId);
});
if (!proposal) {
@@ -1625,13 +1615,12 @@ export async function confirmPay(
) {
logger.trace(`changing session ID to ${sessionIdOverride}`);
purchase.lastSessionId = sessionIdOverride;
- purchase.paymentSubmitPending = true;
await tx.purchases.put(purchase);
}
return purchase;
});
- if (existingPurchase) {
+ if (existingPurchase && existingPurchase.payInfo) {
logger.trace("confirmPay: submitting payment for existing purchase");
return runPayForConfirmPay(ws, proposalId);
}
@@ -1640,9 +1629,9 @@ export async function confirmPay(
const contractData = d.contractData;
- let res: PayCoinSelection | undefined = undefined;
+ let maybeCoinSelection: PayCoinSelection | undefined = undefined;
- res = await selectPayCoinsNew(ws, {
+ maybeCoinSelection = await selectPayCoinsNew(ws, {
auditors: contractData.allowedAuditors,
exchanges: contractData.allowedExchanges,
wireMethod: contractData.wireMethod,
@@ -1655,9 +1644,9 @@ export async function confirmPay(
forcedSelection: forcedCoinSel,
});
- logger.trace("coin selection result", res);
+ logger.trace("coin selection result", maybeCoinSelection);
- if (!res) {
+ if (!maybeCoinSelection) {
// Should not happen, since checkPay should be called first
// FIXME: Actually, this should be handled gracefully,
// and the status should be stored in the DB.
@@ -1665,23 +1654,121 @@ export async function confirmPay(
throw Error("insufficient balance");
}
+ const coinSelection = maybeCoinSelection;
+
const depositPermissions = await generateDepositPermissions(
ws,
- res,
+ coinSelection,
d.contractData,
);
- await recordConfirmPay(
- ws,
- proposal,
- res,
- depositPermissions,
- sessionIdOverride,
+ const payCostInfo = await getTotalPaymentCost(ws, coinSelection);
+
+ let sessionId: string | undefined;
+ if (sessionIdOverride) {
+ sessionId = sessionIdOverride;
+ } else {
+ sessionId = proposal.downloadSessionId;
+ }
+
+ logger.trace(
+ `recording payment on ${proposal.orderId} with session ID ${sessionId}`,
);
+ await ws.db
+ .mktx((x) => [
+ x.purchases,
+ x.coins,
+ x.refreshGroups,
+ x.denominations,
+ x.coinAvailability,
+ ])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposal.proposalId);
+ if (!p) {
+ return;
+ }
+ switch (p.status) {
+ case ProposalStatus.Proposed:
+ p.payInfo = {
+ payCoinSelection: coinSelection,
+ payCoinSelectionUid: encodeCrock(getRandomBytes(16)),
+ totalPayCost: payCostInfo,
+ coinDepositPermissions: depositPermissions,
+ };
+ p.lastSessionId = sessionId;
+ p.timestampAccept = TalerProtocolTimestamp.now();
+ p.status = ProposalStatus.Paying;
+ await tx.purchases.put(p);
+ await spendCoins(ws, tx, {
+ allocationId: `proposal:${p.proposalId}`,
+ coinPubs: coinSelection.coinPubs,
+ contributions: coinSelection.coinContributions,
+ refreshReason: RefreshReason.PayMerchant,
+ });
+ break;
+ case ProposalStatus.Paid:
+ case ProposalStatus.Paying:
+ default:
+ break;
+ }
+ });
+
+ ws.notify({
+ type: NotificationType.ProposalAccepted,
+ proposalId: proposal.proposalId,
+ });
+
return runPayForConfirmPay(ws, proposalId);
}
+export async function processPurchase(
+ ws: InternalWalletState,
+ proposalId: string,
+ options: {
+ forceNow?: boolean;
+ } = {},
+): Promise<OperationAttemptResult> {
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+ if (!purchase) {
+ return {
+ type: OperationAttemptResultType.Error,
+ errorDetail: {
+ // FIXME: allocate more specific error code
+ code: TalerErrorCode.WALLET_UNEXPECTED_EXCEPTION,
+ hint: `trying to pay for purchase that is not in the database`,
+ proposalId: proposalId,
+ },
+ };
+ }
+
+ switch (purchase.status) {
+ case ProposalStatus.DownloadingProposal:
+ return processDownloadProposal(ws, proposalId, options);
+ case ProposalStatus.Paying:
+ case ProposalStatus.PayingReplay:
+ return processPurchasePay(ws, proposalId, options);
+ case ProposalStatus.QueryingAutoRefund:
+ case ProposalStatus.QueryingAutoRefund:
+ case ProposalStatus.AbortingWithRefund:
+ return processPurchaseQueryRefund(ws, proposalId, options);
+ case ProposalStatus.ProposalDownloadFailed:
+ case ProposalStatus.Paid:
+ case ProposalStatus.AbortingWithRefund:
+ case ProposalStatus.RepurchaseDetected:
+ return {
+ type: OperationAttemptResultType.Finished,
+ result: undefined,
+ };
+ default:
+ throw Error(`unexpected purchase status (${purchase.status})`);
+ }
+}
+
export async function processPurchasePay(
ws: InternalWalletState,
proposalId: string,
@@ -1705,31 +1792,38 @@ export async function processPurchasePay(
},
};
}
- if (!purchase.paymentSubmitPending) {
- OperationAttemptResult.finishedEmpty();
+ switch (purchase.status) {
+ case ProposalStatus.Paying:
+ case ProposalStatus.PayingReplay:
+ break;
+ default:
+ return OperationAttemptResult.finishedEmpty();
}
logger.trace(`processing purchase pay ${proposalId}`);
const sessionId = purchase.lastSessionId;
- logger.trace("paying with session ID", sessionId);
+ logger.trace(`paying with session ID ${sessionId}`);
+ const payInfo = purchase.payInfo;
+ checkDbInvariant(!!payInfo, "payInfo");
+ const download = await expectProposalDownload(purchase);
if (!purchase.merchantPaySig) {
const payUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/pay`,
- purchase.download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.orderId}/pay`,
+ download.contractData.merchantBaseUrl,
).href;
let depositPermissions: CoinDepositPermission[];
- if (purchase.coinDepositPermissions) {
- depositPermissions = purchase.coinDepositPermissions;
+ if (purchase.payInfo?.coinDepositPermissions) {
+ depositPermissions = purchase.payInfo.coinDepositPermissions;
} else {
// FIXME: also cache!
depositPermissions = await generateDepositPermissions(
ws,
- purchase.payCoinSelection,
- purchase.download.contractData,
+ payInfo.payCoinSelection,
+ download.contractData,
);
}
@@ -1775,7 +1869,8 @@ export async function processPurchasePay(
if (!purch) {
return;
}
- purch.payFrozen = true;
+ // FIXME: Should be some "PayPermanentlyFailed" and error info should be stored
+ purch.status = ProposalStatus.PaymentAbortFinished;
await tx.purchases.put(purch);
});
throw makePendingOperationFailedError(
@@ -1819,9 +1914,9 @@ export async function processPurchasePay(
logger.trace("got success from pay URL", merchantResp);
- const merchantPub = purchase.download.contractData.merchantPub;
+ const merchantPub = download.contractData.merchantPub;
const { valid } = await ws.cryptoApi.isValidPaymentSignature({
- contractHash: purchase.download.contractData.contractTermsHash,
+ contractHash: download.contractData.contractTermsHash,
merchantPub,
sig: merchantResp.sig,
});
@@ -1836,17 +1931,19 @@ export async function processPurchasePay(
await unblockBackup(ws, proposalId);
} else {
const payAgainUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/paid`,
- purchase.download.contractData.merchantBaseUrl,
+ `orders/${download.contractData.orderId}/paid`,
+ download.contractData.merchantBaseUrl,
).href;
const reqBody = {
sig: purchase.merchantPaySig,
- h_contract: purchase.download.contractData.contractTermsHash,
+ h_contract: download.contractData.contractTermsHash,
session_id: sessionId ?? "",
};
+ logger.trace(`/paid request body: ${j2s(reqBody)}`);
const resp = await ws.runSequentialized([EXCHANGE_COINS_LOCK], () =>
ws.http.postJson(payAgainUrl, reqBody),
);
+ logger.trace(`/paid response status: ${resp.status}`);
if (resp.status !== 204) {
throw TalerError.fromDetail(
TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR,
@@ -1871,18 +1968,18 @@ export async function refuseProposal(
proposalId: string,
): Promise<void> {
const success = await ws.db
- .mktx((x) => [x.proposals])
+ .mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
- const proposal = await tx.proposals.get(proposalId);
+ const proposal = await tx.purchases.get(proposalId);
if (!proposal) {
logger.trace(`proposal ${proposalId} not found, won't refuse proposal`);
return false;
}
- if (proposal.proposalStatus !== ProposalStatus.Proposed) {
+ if (proposal.status !== ProposalStatus.Proposed) {
return false;
}
- proposal.proposalStatus = ProposalStatus.Refused;
- await tx.proposals.put(proposal);
+ proposal.status = ProposalStatus.ProposalRefused;
+ await tx.purchases.put(proposal);
return true;
});
if (success) {
@@ -1891,3 +1988,771 @@ 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.byMerchantUrlAndOrderId.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(purchase);
+ const proposalId = purchase.proposalId;
+
+ const { contractData: c } = await expectProposalDownload(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, { coinPub: string }>,
+ r: MerchantCoinRefundSuccessStatus,
+): 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");
+ }
+ refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+ const refundAmount = Amounts.parseOrThrow(r.refund_amount);
+ const refundFee = denom.fees.feeRefund;
+ coin.status = CoinStatus.Dormant;
+ coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
+ coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
+ logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
+ await tx.coins.put(coin);
+
+ const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
+ .iter(coin.exchangeBaseUrl)
+ .toArray();
+
+ const amountLeft = Amounts.sub(
+ Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.fees.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Applied,
+ obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.fees.feeRefund,
+ 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,
+): 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.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.fees.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Pending,
+ obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.fees.feeRefund,
+ 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, { coinPub: string }>,
+ r: MerchantCoinRefundFailureStatus,
+): 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.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
+ .amount,
+ denom.fees.feeRefund,
+ ).amount;
+
+ const totalRefreshCostBound = getTotalRefreshCost(
+ allDenoms,
+ DenominationRecord.toDenomInfo(denom),
+ amountLeft,
+ );
+
+ p.refunds[refundKey] = {
+ type: RefundState.Failed,
+ obtainedTime: TalerProtocolTimestamp.now(),
+ executionTime: r.execution_time,
+ refundAmount: Amounts.parseOrThrow(r.refund_amount),
+ refundFee: denom.fees.feeRefund,
+ totalRefreshCostBound,
+ coinPub: r.coin_pub,
+ rtransactionId: r.rtransaction_id,
+ };
+
+ if (p.status === ProposalStatus.AbortingWithRefund) {
+ // Refund failed because the merchant didn't even try to deposit
+ // the coin yet, so we try to refresh.
+ 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 = payCoinSelection.coinContributions[i];
+ }
+ }
+ if (contrib) {
+ coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
+ coin.currentAmount = Amounts.sub(
+ coin.currentAmount,
+ denom.fees.feeRefund,
+ ).amount;
+ }
+ refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
+ await tx.coins.put(coin);
+ }
+ }
+}
+
+async function acceptRefunds(
+ ws: InternalWalletState,
+ proposalId: string,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<void> {
+ logger.trace("handling refunds", refunds);
+ const now = TalerProtocolTimestamp.now();
+
+ await ws.db
+ .mktx((x) => [
+ x.purchases,
+ x.coins,
+ x.coinAvailability,
+ x.denominations,
+ x.refreshGroups,
+ ])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.error("purchase not found, not adding refunds");
+ return;
+ }
+
+ const refreshCoinsMap: Record<string, CoinPublicKey> = {};
+
+ 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);
+ } else if (isPermanentFailure) {
+ await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
+ } else {
+ await storePendingRefund(tx, p, refundStatus);
+ }
+ }
+
+ const refreshCoinsPubs = Object.values(refreshCoinsMap);
+ if (refreshCoinsPubs.length > 0) {
+ await createRefreshGroup(
+ ws,
+ tx,
+ refreshCoinsPubs,
+ RefreshReason.Refund,
+ );
+ }
+
+ // 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.status === ProposalStatus.AbortingWithRefund) {
+ p.status = ProposalStatus.PaymentAbortFinished;
+ } else if (p.status === ProposalStatus.QueryingAutoRefund) {
+ const autoRefundDeadline = p.autoRefundDeadline;
+ checkDbInvariant(!!autoRefundDeadline);
+ if (
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromTimestamp(autoRefundDeadline),
+ )
+ ) {
+ p.status = ProposalStatus.Paid;
+ }
+ } else if (p.status === ProposalStatus.QueryingRefund) {
+ p.status = ProposalStatus.Paid;
+ }
+ logger.trace("refund query done");
+ } else {
+ // No error, but we need to try again!
+ p.timestampLastRefundStatus = now;
+ logger.trace("refund query not done");
+ }
+
+ await tx.purchases.put(p);
+ });
+
+ ws.notify({
+ type: NotificationType.RefundQueried,
+ });
+}
+
+async function calculateRefundSummary(
+ p: PurchaseRecord,
+): Promise<RefundSummary> {
+ const download = await expectProposalDownload(p);
+ let amountRefundGranted = Amounts.getZero(
+ download.contractData.amount.currency,
+ );
+ let amountRefundGone = Amounts.getZero(download.contractData.amount.currency);
+
+ let pendingAtExchange = false;
+
+ const payInfo = p.payInfo;
+ if (!payInfo) {
+ throw Error("can't calculate refund summary without payInfo");
+ }
+
+ Object.keys(p.refunds).forEach((rk) => {
+ const refund = p.refunds[rk];
+ if (refund.type === RefundState.Pending) {
+ pendingAtExchange = true;
+ }
+ if (
+ refund.type === RefundState.Applied ||
+ refund.type === RefundState.Pending
+ ) {
+ amountRefundGranted = Amounts.add(
+ amountRefundGranted,
+ Amounts.sub(
+ refund.refundAmount,
+ refund.refundFee,
+ refund.totalRefreshCostBound,
+ ).amount,
+ ).amount;
+ } else {
+ amountRefundGone = Amounts.add(
+ amountRefundGone,
+ refund.refundAmount,
+ ).amount;
+ }
+ });
+ return {
+ amountEffectivePaid: 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);
+
+ logger.trace("applying refund", parseResult);
+
+ if (!parseResult) {
+ throw Error("invalid refund URI");
+ }
+
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
+ parseResult.merchantBaseUrl,
+ parseResult.orderId,
+ ]);
+ });
+
+ if (!purchase) {
+ throw Error(
+ `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+ );
+ }
+
+ return applyRefundFromPurchaseId(ws, purchase.proposalId);
+}
+
+export async function applyRefundFromPurchaseId(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<ApplyRefundResponse> {
+ logger.trace("applying refund for purchase", 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.status === ProposalStatus.Paid) {
+ p.status = ProposalStatus.QueryingRefund;
+ }
+ await tx.purchases.put(p);
+ return true;
+ });
+
+ if (success) {
+ ws.notify({
+ type: NotificationType.RefundStarted,
+ });
+ await processPurchaseQueryRefund(ws, proposalId, {
+ forceNow: true,
+ waitForAutoRefund: false,
+ });
+ }
+
+ const purchase = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.get(proposalId);
+ });
+
+ if (!purchase) {
+ throw Error("purchase no longer exists");
+ }
+
+ const summary = await calculateRefundSummary(purchase);
+ const download = await expectProposalDownload(purchase);
+
+ return {
+ contractTermsHash: download.contractData.contractTermsHash,
+ proposalId: purchase.proposalId,
+ transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund
+ 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,
+ },
+ };
+}
+
+async function queryAndSaveAwaitingRefund(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+ waitForAutoRefund?: boolean,
+): Promise<AmountJson> {
+ const download = await expectProposalDownload(purchase);
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "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 orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
+ );
+ if (!orderStatus.refunded) {
+ // Wait for retry ...
+ return Amounts.getZero(download.contractData.amount.currency);
+ }
+
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ if (
+ purchase.refundAmountAwaiting === undefined ||
+ Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0
+ ) {
+ 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;
+ }
+ p.refundAmountAwaiting = refundAwaiting;
+ await tx.purchases.put(p);
+ });
+ }
+
+ return refundAwaiting;
+}
+
+export async function processPurchaseQueryRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+ options: {
+ forceNow?: boolean;
+ waitForAutoRefund?: boolean;
+ } = {},
+): 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.status === ProposalStatus.QueryingAutoRefund ||
+ purchase.status === ProposalStatus.QueryingRefund ||
+ purchase.status === ProposalStatus.AbortingWithRefund
+ )
+ ) {
+ return OperationAttemptResult.finishedEmpty();
+ }
+
+ const download = await expectProposalDownload(purchase);
+
+ if (purchase.timestampFirstSuccessfulPay) {
+ if (
+ !purchase.autoRefundDeadline ||
+ !AbsoluteTime.isExpired(
+ AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
+ )
+ ) {
+ const awaitingAmount = await queryAndSaveAwaitingRefund(
+ ws,
+ purchase,
+ waitForAutoRefund,
+ );
+ if (Amounts.isZero(awaitingAmount)) {
+ return OperationAttemptResult.finishedEmpty();
+ }
+ }
+
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
+
+ logger.trace(`making refund request to ${requestUrl.href}`);
+
+ const request = await ws.http.postJson(requestUrl.href, {
+ h_contract: download.contractData.contractTermsHash,
+ });
+
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForMerchantOrderRefundPickupResponse(),
+ );
+
+ await acceptRefunds(
+ ws,
+ proposalId,
+ refundResponse.refunds,
+ RefundReason.NormalRefund,
+ );
+ } else if (purchase.status === ProposalStatus.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);
+ }
+ return OperationAttemptResult.finishedEmpty();
+}
+
+export async function abortFailedPayWithRefund(
+ ws: InternalWalletState,
+ proposalId: string,
+): Promise<void> {
+ await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ if (purchase.timestampFirstSuccessfulPay) {
+ // No point in aborting it. We don't even report an error.
+ logger.warn(`tried to abort successful payment`);
+ return;
+ }
+ if (purchase.status === ProposalStatus.Paying) {
+ purchase.status = ProposalStatus.AbortingWithRefund;
+ }
+ await tx.purchases.put(purchase);
+ });
+ processPurchaseQueryRefund(ws, proposalId, {
+ forceNow: true,
+ }).catch((e) => {
+ logger.trace(`error during refund processing after abort pay: ${e}`);
+ });
+}
diff --git a/packages/taler-wallet-core/src/operations/peer-to-peer.ts b/packages/taler-wallet-core/src/operations/pay-peer.ts
index d30cb294d..e9185a9d4 100644
--- a/packages/taler-wallet-core/src/operations/peer-to-peer.ts
+++ b/packages/taler-wallet-core/src/operations/pay-peer.ts
@@ -73,9 +73,8 @@ import { InternalWalletState } from "../internal-wallet-state.js";
import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { checkDbInvariant } from "../util/invariants.js";
import { GetReadOnlyAccess } from "../util/query.js";
-import { spendCoins } from "../wallet.js";
+import { spendCoins, makeEventId } from "../operations/common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
-import { makeEventId } from "./transactions.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
const logger = new Logger("operations/peer-to-peer.ts");
diff --git a/packages/taler-wallet-core/src/operations/pending.ts b/packages/taler-wallet-core/src/operations/pending.ts
index e4c270d85..db7a85432 100644
--- a/packages/taler-wallet-core/src/operations/pending.ts
+++ b/packages/taler-wallet-core/src/operations/pending.ts
@@ -23,7 +23,6 @@
*/
import {
ProposalStatus,
- AbortStatus,
WalletStoresV1,
BackupProviderStateTag,
RefreshCoinStatus,
@@ -38,7 +37,6 @@ import { AbsoluteTime } from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
import { GetReadOnlyAccess } from "../util/query.js";
import { RetryTags } from "../util/retries.js";
-import { Wallet } from "../wallet.js";
import { GlobalIDB } from "@gnu-taler/idb-bridge";
function getPendingCommon(
@@ -184,38 +182,6 @@ async function gatherWithdrawalPending(
}
}
-async function gatherProposalPending(
- ws: InternalWalletState,
- tx: GetReadOnlyAccess<{
- proposals: typeof WalletStoresV1.proposals;
- operationRetries: typeof WalletStoresV1.operationRetries;
- }>,
- now: AbsoluteTime,
- resp: PendingOperationsResponse,
-): Promise<void> {
- await tx.proposals.iter().forEachAsync(async (proposal) => {
- if (proposal.proposalStatus == ProposalStatus.Proposed) {
- // Nothing to do, user needs to choose.
- } else if (proposal.proposalStatus == ProposalStatus.Downloading) {
- const opId = RetryTags.forProposalClaim(proposal);
- const retryRecord = await tx.operationRetries.get(opId);
- const timestampDue =
- retryRecord?.retryInfo?.nextRetry ?? AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.ProposalDownload,
- ...getPendingCommon(ws, opId, timestampDue),
- givesLifeness: true,
- merchantBaseUrl: proposal.merchantBaseUrl,
- orderId: proposal.orderId,
- proposalId: proposal.proposalId,
- proposalTimestamp: proposal.timestamp,
- lastError: retryRecord?.lastError,
- retryInfo: retryRecord?.retryInfo,
- });
- }
- });
-}
-
async function gatherDepositPending(
ws: InternalWalletState,
tx: GetReadOnlyAccess<{
@@ -287,44 +253,27 @@ async function gatherPurchasePending(
resp: PendingOperationsResponse,
): Promise<void> {
// FIXME: Only iter purchases with some "active" flag!
- await tx.purchases.iter().forEachAsync(async (pr) => {
- if (
- pr.paymentSubmitPending &&
- pr.abortStatus === AbortStatus.None &&
- !pr.payFrozen
- ) {
- const payOpId = RetryTags.forPay(pr);
- const payRetryRecord = await tx.operationRetries.get(payOpId);
-
- const timestampDue =
- payRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
- resp.pendingOperations.push({
- type: PendingTaskType.Pay,
- ...getPendingCommon(ws, payOpId, timestampDue),
- givesLifeness: true,
- isReplay: false,
- proposalId: pr.proposalId,
- retryInfo: payRetryRecord?.retryInfo,
- lastError: payRetryRecord?.lastError,
- });
- }
- if (pr.refundQueryRequested) {
- const refundQueryOpId = RetryTags.forRefundQuery(pr);
- const refundQueryRetryRecord = await tx.operationRetries.get(
- refundQueryOpId,
- );
+ const keyRange = GlobalIDB.KeyRange.bound(
+ OperationStatusRange.ACTIVE_START,
+ OperationStatusRange.ACTIVE_END,
+ );
+ await tx.purchases.indexes.byStatus
+ .iter(keyRange)
+ .forEachAsync(async (pr) => {
+ const opId = RetryTags.forPay(pr);
+ const retryRecord = await tx.operationRetries.get(opId);
const timestampDue =
- refundQueryRetryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
+ retryRecord?.retryInfo.nextRetry ?? AbsoluteTime.now();
resp.pendingOperations.push({
- type: PendingTaskType.RefundQuery,
- ...getPendingCommon(ws, refundQueryOpId, timestampDue),
+ type: PendingTaskType.Purchase,
+ ...getPendingCommon(ws, opId, timestampDue),
givesLifeness: true,
+ statusStr: ProposalStatus[pr.status],
proposalId: pr.proposalId,
- retryInfo: refundQueryRetryRecord?.retryInfo,
- lastError: refundQueryRetryRecord?.lastError,
+ retryInfo: retryRecord?.retryInfo,
+ lastError: retryRecord?.lastError,
});
- }
- });
+ });
}
async function gatherRecoupPending(
@@ -404,7 +353,6 @@ export async function getPendingOperations(
x.refreshGroups,
x.coins,
x.withdrawalGroups,
- x.proposals,
x.tips,
x.purchases,
x.planchets,
@@ -419,7 +367,6 @@ export async function getPendingOperations(
await gatherExchangePending(ws, tx, now, resp);
await gatherRefreshPending(ws, tx, now, resp);
await gatherWithdrawalPending(ws, tx, now, resp);
- await gatherProposalPending(ws, tx, now, resp);
await gatherDepositPending(ws, tx, now, resp);
await gatherTipPending(ws, tx, now, resp);
await gatherPurchasePending(ws, tx, now, resp);
diff --git a/packages/taler-wallet-core/src/operations/recoup.ts b/packages/taler-wallet-core/src/operations/recoup.ts
index 2d92ff8ba..ff6bb4efc 100644
--- a/packages/taler-wallet-core/src/operations/recoup.ts
+++ b/packages/taler-wallet-core/src/operations/recoup.ts
@@ -27,16 +27,15 @@
import {
Amounts,
codecForRecoupConfirmation,
+ codecForReserveStatus,
encodeCrock,
getRandomBytes,
j2s,
Logger,
NotificationType,
RefreshReason,
- TalerErrorDetail,
TalerProtocolTimestamp,
URL,
- codecForReserveStatus,
} from "@gnu-taler/taler-util";
import {
CoinRecord,
@@ -44,8 +43,8 @@ import {
CoinStatus,
RecoupGroupRecord,
RefreshCoinSource,
- WithdrawalGroupStatus,
WalletStoresV1,
+ WithdrawalGroupStatus,
WithdrawalRecordType,
WithdrawCoinSource,
} from "../db.js";
@@ -54,10 +53,8 @@ import { readSuccessResponseJsonOrThrow } from "../util/http.js";
import { GetReadWriteAccess } from "../util/query.js";
import {
OperationAttemptResult,
- RetryInfo,
runOperationHandlerForResult,
} from "../util/retries.js";
-import { guardOperationException } from "./common.js";
import { createRefreshGroup, processRefreshGroup } from "./refresh.js";
import { internalCreateWithdrawalGroup } from "./withdraw.js";
diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts
index 9fe2e6a8f..a5951ea53 100644
--- a/packages/taler-wallet-core/src/operations/refresh.ts
+++ b/packages/taler-wallet-core/src/operations/refresh.ts
@@ -78,7 +78,7 @@ import {
OperationAttemptResult,
OperationAttemptResultType,
} from "../util/retries.js";
-import { makeCoinAvailable } from "../wallet.js";
+import { makeCoinAvailable } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
import {
isWithdrawableDenom,
diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts
deleted file mode 100644
index 0d86b92ab..000000000
--- a/packages/taler-wallet-core/src/operations/refund.ts
+++ /dev/null
@@ -1,815 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2019-2019 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-/**
- * Implementation of the refund operation.
- *
- * @author Florian Dold
- */
-
-/**
- * Imports.
- */
-import {
- AbortingCoin,
- AbortRequest,
- AbsoluteTime,
- AmountJson,
- Amounts,
- ApplyRefundResponse,
- codecForAbortResponse,
- codecForMerchantOrderRefundPickupResponse,
- codecForMerchantOrderStatusPaid,
- CoinPublicKey,
- Duration,
- Logger,
- MerchantCoinRefundFailureStatus,
- MerchantCoinRefundStatus,
- MerchantCoinRefundSuccessStatus,
- NotificationType,
- parseRefundUri,
- PrepareRefundResult,
- RefreshReason,
- TalerErrorCode,
- TalerProtocolTimestamp,
- TransactionType,
- URL,
-} from "@gnu-taler/taler-util";
-import {
- AbortStatus,
- CoinStatus,
- DenominationRecord,
- PurchaseRecord,
- RefundReason,
- RefundState,
- WalletStoresV1,
-} from "../db.js";
-import { InternalWalletState } from "../internal-wallet-state.js";
-import { readSuccessResponseJsonOrThrow } from "../util/http.js";
-import { checkDbInvariant } from "../util/invariants.js";
-import { GetReadWriteAccess } from "../util/query.js";
-import { OperationAttemptResult } from "../util/retries.js";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
-import { makeEventId } from "./transactions.js";
-
-const logger = new Logger("refund.ts");
-
-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.byMerchantUrlAndOrderId.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 = calculateRefundSummary(purchase);
- const proposalId = purchase.proposalId;
-
- const { contractData: c } = purchase.download;
-
- 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, { coinPub: string }>,
- r: MerchantCoinRefundSuccessStatus,
-): 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");
- }
- refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
- const refundAmount = Amounts.parseOrThrow(r.refund_amount);
- const refundFee = denom.fees.feeRefund;
- coin.status = CoinStatus.Dormant;
- coin.currentAmount = Amounts.add(coin.currentAmount, refundAmount).amount;
- coin.currentAmount = Amounts.sub(coin.currentAmount, refundFee).amount;
- logger.trace(`coin amount after is ${Amounts.stringify(coin.currentAmount)}`);
- await tx.coins.put(coin);
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.fees.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Applied,
- obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.fees.feeRefund,
- 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,
-): 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.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.fees.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Pending,
- obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.fees.feeRefund,
- 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, { coinPub: string }>,
- r: MerchantCoinRefundFailureStatus,
-): 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.add(coin.currentAmount, Amounts.parseOrThrow(r.refund_amount))
- .amount,
- denom.fees.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Failed,
- obtainedTime: TalerProtocolTimestamp.now(),
- executionTime: r.execution_time,
- refundAmount: Amounts.parseOrThrow(r.refund_amount),
- refundFee: denom.fees.feeRefund,
- totalRefreshCostBound,
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-
- if (p.abortStatus === AbortStatus.AbortRefund) {
- // Refund failed because the merchant didn't even try to deposit
- // the coin yet, so we try to refresh.
- 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;
- }
- let contrib: AmountJson | undefined;
- for (let i = 0; i < p.payCoinSelection.coinPubs.length; i++) {
- if (p.payCoinSelection.coinPubs[i] === r.coin_pub) {
- contrib = p.payCoinSelection.coinContributions[i];
- }
- }
- if (contrib) {
- coin.currentAmount = Amounts.add(coin.currentAmount, contrib).amount;
- coin.currentAmount = Amounts.sub(
- coin.currentAmount,
- denom.fees.feeRefund,
- ).amount;
- }
- refreshCoinsMap[coin.coinPub] = { coinPub: coin.coinPub };
- await tx.coins.put(coin);
- }
- }
-}
-
-async function acceptRefunds(
- ws: InternalWalletState,
- proposalId: string,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
-): Promise<void> {
- logger.trace("handling refunds", refunds);
- const now = TalerProtocolTimestamp.now();
-
- await ws.db
- .mktx((x) => [
- x.purchases,
- x.coins,
- x.coinAvailability,
- x.denominations,
- x.refreshGroups,
- ])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("purchase not found, not adding refunds");
- return;
- }
-
- const refreshCoinsMap: Record<string, CoinPublicKey> = {};
-
- 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);
- } else if (isPermanentFailure) {
- await storeFailedRefund(tx, p, refreshCoinsMap, refundStatus);
- } else {
- await storePendingRefund(tx, p, refundStatus);
- }
- }
-
- const refreshCoinsPubs = Object.values(refreshCoinsMap);
- if (refreshCoinsPubs.length > 0) {
- await createRefreshGroup(
- ws,
- tx,
- refreshCoinsPubs,
- RefreshReason.Refund,
- );
- }
-
- // Are we done with querying yet, or do we need to do another round
- // after a retry delay?
- let queryDone = true;
-
- if (
- p.timestampFirstSuccessfulPay &&
- p.autoRefundDeadline &&
- AbsoluteTime.cmp(
- AbsoluteTime.fromTimestamp(p.autoRefundDeadline),
- AbsoluteTime.fromTimestamp(now),
- ) > 0
- ) {
- queryDone = false;
- }
-
- 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;
- p.refundQueryRequested = false;
- if (p.abortStatus === AbortStatus.AbortRefund) {
- p.abortStatus = AbortStatus.AbortFinished;
- }
- logger.trace("refund query done");
- } else {
- // No error, but we need to try again!
- p.timestampLastRefundStatus = now;
- logger.trace("refund query not done");
- }
-
- await tx.purchases.put(p);
- });
-
- ws.notify({
- type: NotificationType.RefundQueried,
- });
-}
-
-function calculateRefundSummary(p: PurchaseRecord): RefundSummary {
- let amountRefundGranted = Amounts.getZero(
- p.download.contractData.amount.currency,
- );
- let amountRefundGone = Amounts.getZero(
- p.download.contractData.amount.currency,
- );
-
- let pendingAtExchange = false;
-
- Object.keys(p.refunds).forEach((rk) => {
- const refund = p.refunds[rk];
- if (refund.type === RefundState.Pending) {
- pendingAtExchange = true;
- }
- if (
- refund.type === RefundState.Applied ||
- refund.type === RefundState.Pending
- ) {
- amountRefundGranted = Amounts.add(
- amountRefundGranted,
- Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount,
- ).amount;
- } else {
- amountRefundGone = Amounts.add(
- amountRefundGone,
- refund.refundAmount,
- ).amount;
- }
- });
- return {
- amountEffectivePaid: p.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);
-
- logger.trace("applying refund", parseResult);
-
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
-
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.indexes.byMerchantUrlAndOrderId.get([
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
- });
-
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
- );
- }
-
- return applyRefundFromPurchaseId(ws, purchase.proposalId);
-}
-
-export async function applyRefundFromPurchaseId(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<ApplyRefundResponse> {
- logger.trace("applying refund for purchase", 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;
- }
- p.refundQueryRequested = true;
- await tx.purchases.put(p);
- return true;
- });
-
- if (success) {
- ws.notify({
- type: NotificationType.RefundStarted,
- });
- await processPurchaseQueryRefund(ws, proposalId, {
- forceNow: true,
- waitForAutoRefund: false,
- });
- }
-
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
-
- if (!purchase) {
- throw Error("purchase no longer exists");
- }
-
- const summary = calculateRefundSummary(purchase);
-
- return {
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- proposalId: purchase.proposalId,
- transactionId: makeEventId(TransactionType.Payment, proposalId), //FIXME: can we have the tx id of the refund
- amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
- amountRefundGone: Amounts.stringify(summary.amountRefundGone),
- amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
- pendingAtExchange: summary.pendingAtExchange,
- info: {
- contractTermsHash: purchase.download.contractData.contractTermsHash,
- merchant: purchase.download.contractData.merchant,
- orderId: purchase.download.contractData.orderId,
- products: purchase.download.contractData.products,
- summary: purchase.download.contractData.summary,
- fulfillmentMessage: purchase.download.contractData.fulfillmentMessage,
- summary_i18n: purchase.download.contractData.summaryI18n,
- fulfillmentMessage_i18n:
- purchase.download.contractData.fulfillmentMessageI18n,
- },
- };
-}
-
-async function queryAndSaveAwaitingRefund(
- ws: InternalWalletState,
- purchase: PurchaseRecord,
- waitForAutoRefund?: boolean,
-): Promise<AmountJson> {
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}`,
- purchase.download.contractData.merchantBaseUrl,
- );
- requestUrl.searchParams.set(
- "h_contract",
- purchase.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 orderStatus = await readSuccessResponseJsonOrThrow(
- resp,
- codecForMerchantOrderStatusPaid(),
- );
- if (!orderStatus.refunded) {
- // Wait for retry ...
- return Amounts.getZero(purchase.totalPayCost.currency);
- }
-
- const refundAwaiting = Amounts.sub(
- Amounts.parseOrThrow(orderStatus.refund_amount),
- Amounts.parseOrThrow(orderStatus.refund_taken),
- ).amount;
-
- if (
- purchase.refundAwaiting === undefined ||
- Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 0
- ) {
- 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;
- }
- p.refundAwaiting = refundAwaiting;
- await tx.purchases.put(p);
- });
- }
-
- return refundAwaiting;
-}
-
-export async function processPurchaseQueryRefund(
- ws: InternalWalletState,
- proposalId: string,
- options: {
- forceNow?: boolean;
- waitForAutoRefund?: boolean;
- } = {},
-): Promise<OperationAttemptResult> {
- 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.refundQueryRequested) {
- return OperationAttemptResult.finishedEmpty();
- }
-
- if (purchase.timestampFirstSuccessfulPay) {
- if (
- !purchase.autoRefundDeadline ||
- !AbsoluteTime.isExpired(
- AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
- )
- ) {
- const awaitingAmount = await queryAndSaveAwaitingRefund(
- ws,
- purchase,
- waitForAutoRefund,
- );
- if (Amounts.isZero(awaitingAmount)) {
- return OperationAttemptResult.finishedEmpty();
- }
- }
-
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/refund`,
- purchase.download.contractData.merchantBaseUrl,
- );
-
- logger.trace(`making refund request to ${requestUrl.href}`);
-
- const request = await ws.http.postJson(requestUrl.href, {
- h_contract: purchase.download.contractData.contractTermsHash,
- });
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
-
- await acceptRefunds(
- ws,
- proposalId,
- refundResponse.refunds,
- RefundReason.NormalRefund,
- );
- } else if (purchase.abortStatus === AbortStatus.AbortRefund) {
- const requestUrl = new URL(
- `orders/${purchase.download.contractData.orderId}/abort`,
- purchase.download.contractData.merchantBaseUrl,
- );
-
- const abortingCoins: AbortingCoin[] = [];
-
- await ws.db
- .mktx((x) => [x.coins])
- .runReadOnly(async (tx) => {
- for (let i = 0; i < purchase.payCoinSelection.coinPubs.length; i++) {
- const coinPub = purchase.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(
- purchase.payCoinSelection.coinContributions[i],
- ),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
-
- const abortReq: AbortRequest = {
- h_contract: purchase.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: purchase.payCoinSelection.coinPubs[i],
- refund_amount: Amounts.stringify(
- purchase.payCoinSelection.coinContributions[i],
- ),
- rtransaction_id: 0,
- execution_time: AbsoluteTime.toTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.fromTimestamp(
- purchase.download.contractData.timestamp,
- ),
- Duration.fromSpec({ seconds: 1 }),
- ),
- ),
- });
- }
- await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
- }
- return OperationAttemptResult.finishedEmpty();
-}
-
-export async function abortFailedPayWithRefund(
- ws: InternalWalletState,
- proposalId: string,
-): Promise<void> {
- await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
- }
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
- return;
- }
- if (purchase.abortStatus !== AbortStatus.None) {
- return;
- }
- purchase.refundQueryRequested = true;
- purchase.paymentSubmitPending = false;
- purchase.abortStatus = AbortStatus.AbortRefund;
- await tx.purchases.put(purchase);
- });
- processPurchaseQueryRefund(ws, proposalId, {
- forceNow: true,
- }).catch((e) => {
- logger.trace(`error during refund processing after abort pay: ${e}`);
- });
-}
diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts
index 598a88502..9a11af8bb 100644
--- a/packages/taler-wallet-core/src/operations/testing.ts
+++ b/packages/taler-wallet-core/src/operations/testing.ts
@@ -40,9 +40,8 @@ import {
PreparePayResultType,
} from "@gnu-taler/taler-util";
import { InternalWalletState } from "../internal-wallet-state.js";
-import { confirmPay, preparePayForUri } from "./pay.js";
+import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js";
import { getBalances } from "./balance.js";
-import { applyRefund } from "./refund.js";
import { checkLogicInvariant } from "../util/invariants.js";
import { acceptWithdrawalFromUri } from "./withdraw.js";
@@ -471,6 +470,6 @@ export async function testPay(
});
checkLogicInvariant(!!purchase);
return {
- payCoinSelection: purchase.payCoinSelection,
+ payCoinSelection: purchase.payInfo?.payCoinSelection!,
};
}
diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts
index bd5ff51e7..a83867f55 100644
--- a/packages/taler-wallet-core/src/operations/tip.ts
+++ b/packages/taler-wallet-core/src/operations/tip.ts
@@ -18,8 +18,8 @@
* Imports.
*/
import {
- AgeRestriction,
AcceptTipResponse,
+ AgeRestriction,
Amounts,
BlindedDenominationSignature,
codecForMerchantTipResponseV2,
@@ -56,9 +56,8 @@ import {
OperationAttemptResult,
OperationAttemptResultType,
} from "../util/retries.js";
-import { makeCoinAvailable } from "../wallet.js";
+import { makeCoinAvailable, makeEventId } from "./common.js";
import { updateExchangeFromUrl } from "./exchanges.js";
-import { makeEventId } from "./transactions.js";
import {
getCandidateWithdrawalDenoms,
getExchangeWithdrawalInfo,
diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts
index 4086fc9b3..6ddf14f98 100644
--- a/packages/taler-wallet-core/src/operations/transactions.ts
+++ b/packages/taler-wallet-core/src/operations/transactions.ts
@@ -36,12 +36,12 @@ import {
WithdrawalType,
} from "@gnu-taler/taler-util";
import {
- AbortStatus,
DepositGroupRecord,
ExchangeDetailsRecord,
OperationRetryRecord,
PeerPullPaymentIncomingRecord,
PeerPushPaymentInitiationRecord,
+ ProposalStatus,
PurchaseRecord,
RefundState,
TipRecord,
@@ -50,10 +50,12 @@ import {
WithdrawalRecordType,
} from "../db.js";
import { InternalWalletState } from "../internal-wallet-state.js";
+import { checkDbInvariant } from "../util/invariants.js";
import { RetryTags } from "../util/retries.js";
+import { makeEventId, TombstoneTag } from "./common.js";
import { processDepositGroup } from "./deposits.js";
import { getExchangeDetails } from "./exchanges.js";
-import { processPurchasePay } from "./pay.js";
+import { expectProposalDownload, processPurchasePay } from "./pay-merchant.js";
import { processRefreshGroup } from "./refresh.js";
import { processTip } from "./tip.js";
import {
@@ -63,28 +65,6 @@ import {
const logger = new Logger("taler-wallet-core:transactions.ts");
-export enum TombstoneTag {
- DeleteWithdrawalGroup = "delete-withdrawal-group",
- DeleteReserve = "delete-reserve",
- DeletePayment = "delete-payment",
- DeleteTip = "delete-tip",
- DeleteRefreshGroup = "delete-refresh-group",
- DeleteDepositGroup = "delete-deposit-group",
- DeleteRefund = "delete-refund",
- DeletePeerPullDebit = "delete-peer-pull-debit",
- DeletePeerPushDebit = "delete-peer-push-debit",
-}
-
-/**
- * Create an event ID from the type and the primary key for the event.
- */
-export function makeEventId(
- type: TransactionType | TombstoneTag,
- ...args: string[]
-): string {
- return type + ":" + args.map((x) => encodeURIComponent(x)).join(":");
-}
-
function shouldSkipCurrency(
transactionsRequest: TransactionsRequest | undefined,
currency: string,
@@ -219,29 +199,22 @@ export async function getTransactionById(
}),
);
+ const download = await expectProposalDownload(purchase);
+
const cleanRefunds = filteredRefunds.filter(
(x): x is WalletRefundItem => !!x,
);
- const contractData = purchase.download.contractData;
+ const contractData = download.contractData;
const refunds = mergeRefundByExecutionTime(
cleanRefunds,
Amounts.getZero(contractData.amount.currency),
);
const payOpId = RetryTags.forPay(purchase);
- const refundQueryOpId = RetryTags.forRefundQuery(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
- const refundQueryRetryRecord = await tx.operationRetries.get(
- refundQueryOpId,
- );
-
- const err =
- payRetryRecord !== undefined
- ? payRetryRecord
- : refundQueryRetryRecord;
- return buildTransactionForPurchase(purchase, refunds, err);
+ return buildTransactionForPurchase(purchase, refunds, payRetryRecord);
});
} else if (type === TransactionType.Refresh) {
const refreshGroupId = rest[0];
@@ -295,23 +268,14 @@ export async function getTransactionById(
),
);
if (t) throw Error("deleted");
-
- const contractData = purchase.download.contractData;
+ const download = await expectProposalDownload(purchase);
+ const contractData = download.contractData;
const refunds = mergeRefundByExecutionTime(
[theRefund],
Amounts.getZero(contractData.amount.currency),
);
- const refundQueryOpId = RetryTags.forRefundQuery(purchase);
- const refundQueryRetryRecord = await tx.operationRetries.get(
- refundQueryOpId,
- );
-
- return buildTransactionForRefund(
- purchase,
- refunds[0],
- refundQueryRetryRecord,
- );
+ return buildTransactionForRefund(purchase, refunds[0], undefined);
});
} else if (type === TransactionType.PeerPullDebit) {
const peerPullPaymentIncomingId = rest[0];
@@ -606,12 +570,13 @@ function mergeRefundByExecutionTime(
return Array.from(refundByExecTime.values());
}
-function buildTransactionForRefund(
+async function buildTransactionForRefund(
purchaseRecord: PurchaseRecord,
refundInfo: MergedRefundInfo,
ort?: OperationRetryRecord,
-): Transaction {
- const contractData = purchaseRecord.download.contractData;
+): Promise<Transaction> {
+ const download = await expectProposalDownload(purchaseRecord);
+ const contractData = download.contractData;
const info: OrderShortInfo = {
merchant: contractData.merchant,
@@ -641,21 +606,22 @@ function buildTransactionForRefund(
amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective),
amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw),
refundPending:
- purchaseRecord.refundAwaiting === undefined
+ purchaseRecord.refundAmountAwaiting === undefined
? undefined
- : Amounts.stringify(purchaseRecord.refundAwaiting),
+ : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
pending: false,
frozen: false,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
-function buildTransactionForPurchase(
+async function buildTransactionForPurchase(
purchaseRecord: PurchaseRecord,
refundsInfo: MergedRefundInfo[],
ort?: OperationRetryRecord,
-): Transaction {
- const contractData = purchaseRecord.download.contractData;
+): Promise<Transaction> {
+ const download = await expectProposalDownload(purchaseRecord);
+ const contractData = download.contractData;
const zero = Amounts.getZero(contractData.amount.currency);
const info: OrderShortInfo = {
@@ -696,31 +662,34 @@ function buildTransactionForPurchase(
),
}));
+ const timestamp = purchaseRecord.timestampAccept;
+ checkDbInvariant(!!timestamp);
+ checkDbInvariant(!!purchaseRecord.payInfo);
+
return {
type: TransactionType.Payment,
amountRaw: Amounts.stringify(contractData.amount),
- amountEffective: Amounts.stringify(purchaseRecord.totalPayCost),
+ amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost),
totalRefundRaw: Amounts.stringify(totalRefund.raw),
totalRefundEffective: Amounts.stringify(totalRefund.effective),
refundPending:
- purchaseRecord.refundAwaiting === undefined
+ purchaseRecord.refundAmountAwaiting === undefined
? undefined
- : Amounts.stringify(purchaseRecord.refundAwaiting),
+ : Amounts.stringify(purchaseRecord.refundAmountAwaiting),
status: purchaseRecord.timestampFirstSuccessfulPay
? PaymentStatus.Paid
: PaymentStatus.Accepted,
- pending:
- !purchaseRecord.timestampFirstSuccessfulPay &&
- purchaseRecord.abortStatus === AbortStatus.None,
+ pending: purchaseRecord.status === ProposalStatus.Paying,
refunds,
- timestamp: purchaseRecord.timestampAccept,
+ timestamp,
transactionId: makeEventId(
TransactionType.Payment,
purchaseRecord.proposalId,
),
proposalId: purchaseRecord.proposalId,
info,
- frozen: purchaseRecord.payFrozen ?? false,
+ frozen:
+ purchaseRecord.status === ProposalStatus.PaymentAbortFinished ?? false,
...(ort?.lastError ? { error: ort.lastError } : {}),
};
}
@@ -745,7 +714,6 @@ export async function getTransactions(
x.peerPullPaymentIncoming,
x.peerPushPaymentInitiations,
x.planchets,
- x.proposals,
x.purchases,
x.recoupGroups,
x.tips,
@@ -838,30 +806,33 @@ export async function getTransactions(
transactions.push(buildTransactionForDeposit(dg, retryRecord));
});
- tx.purchases.iter().forEachAsync(async (pr) => {
+ tx.purchases.iter().forEachAsync(async (purchase) => {
+ const download = purchase.download;
+ if (!download) {
+ return;
+ }
+ if (!purchase.payInfo) {
+ return;
+ }
if (
shouldSkipCurrency(
transactionsRequest,
- pr.download.contractData.amount.currency,
+ download.contractData.amount.currency,
)
) {
return;
}
- const contractData = pr.download.contractData;
+ const contractData = download.contractData;
if (shouldSkipSearch(transactionsRequest, [contractData.summary])) {
return;
}
- const proposal = await tx.proposals.get(pr.proposalId);
- if (!proposal) {
- return;
- }
const filteredRefunds = await Promise.all(
- Object.values(pr.refunds).map(async (r) => {
+ Object.values(purchase.refunds).map(async (r) => {
const t = await tx.tombstones.get(
makeEventId(
TombstoneTag.DeleteRefund,
- pr.proposalId,
+ purchase.proposalId,
`${r.executionTime.t_s}`,
),
);
@@ -880,29 +851,16 @@ export async function getTransactions(
);
refunds.forEach(async (refundInfo) => {
- const refundQueryOpId = RetryTags.forRefundQuery(pr);
- const refundQueryRetryRecord = await tx.operationRetries.get(
- refundQueryOpId,
- );
-
transactions.push(
- buildTransactionForRefund(pr, refundInfo, refundQueryRetryRecord),
+ await buildTransactionForRefund(purchase, refundInfo, undefined),
);
});
- const payOpId = RetryTags.forPay(pr);
- const refundQueryOpId = RetryTags.forRefundQuery(pr);
+ const payOpId = RetryTags.forPay(purchase);
const payRetryRecord = await tx.operationRetries.get(payOpId);
- const refundQueryRetryRecord = await tx.operationRetries.get(
- refundQueryOpId,
+ transactions.push(
+ await buildTransactionForPurchase(purchase, refunds, payRetryRecord),
);
-
- const err =
- payRetryRecord !== undefined
- ? payRetryRecord
- : refundQueryRetryRecord;
-
- transactions.push(buildTransactionForPurchase(pr, refunds, err));
});
tx.tips.iter().forEachAsync(async (tipRecord) => {
@@ -1020,14 +978,9 @@ export async function deleteTransaction(
} else if (type === TransactionType.Payment) {
const proposalId = rest[0];
await ws.db
- .mktx((x) => [x.proposals, x.purchases, x.tombstones])
+ .mktx((x) => [x.purchases, x.tombstones])
.runReadWrite(async (tx) => {
let found = false;
- const proposal = await tx.proposals.get(proposalId);
- if (proposal) {
- found = true;
- await tx.proposals.delete(proposalId);
- }
const purchase = await tx.purchases.get(proposalId);
if (purchase) {
found = true;
@@ -1083,7 +1036,7 @@ export async function deleteTransaction(
const executionTimeStr = rest[1];
await ws.db
- .mktx((x) => [x.proposals, x.purchases, x.tombstones])
+ .mktx((x) => [x.purchases, x.tombstones])
.runReadWrite(async (tx) => {
const purchase = await tx.purchases.get(proposalId);
if (purchase) {
diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts
index fb5e2c70a..3c2541e9a 100644
--- a/packages/taler-wallet-core/src/operations/withdraw.ts
+++ b/packages/taler-wallet-core/src/operations/withdraw.ts
@@ -70,12 +70,11 @@ import {
DenomSelectionState,
ExchangeDetailsRecord,
ExchangeRecord,
- OperationStatus,
PlanchetRecord,
- WithdrawalGroupStatus,
WalletStoresV1,
WgInfo,
WithdrawalGroupRecord,
+ WithdrawalGroupStatus,
WithdrawalRecordType,
} from "../db.js";
import {
@@ -84,7 +83,10 @@ import {
TalerError,
} from "../errors.js";
import { InternalWalletState } from "../internal-wallet-state.js";
-import { assertUnreachable } from "../util/assertUnreachable.js";
+import {
+ makeCoinAvailable,
+ runOperationWithErrorReporting,
+} from "../operations/common.js";
import { walletCoreDebugFlags } from "../util/debugFlags.js";
import {
HttpRequestLibrary,
@@ -108,18 +110,16 @@ import {
WALLET_EXCHANGE_PROTOCOL_VERSION,
} from "../versions.js";
import {
- makeCoinAvailable,
- runOperationWithErrorReporting,
+ makeEventId,
storeOperationError,
storeOperationPending,
-} from "../wallet.js";
+} from "./common.js";
import {
getExchangeDetails,
getExchangePaytoUri,
getExchangeTrust,
updateExchangeFromUrl,
} from "./exchanges.js";
-import { makeEventId } from "./transactions.js";
/**
* Logger for this file.