summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-core/src/operations/pay-merchant.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-core/src/operations/pay-merchant.ts')
-rw-r--r--packages/taler-wallet-core/src/operations/pay-merchant.ts1460
1 files changed, 640 insertions, 820 deletions
diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts
index 6aad1d742..99b9a18d2 100644
--- a/packages/taler-wallet-core/src/operations/pay-merchant.ts
+++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts
@@ -58,19 +58,23 @@ import {
MerchantCoinRefundSuccessStatus,
MerchantContractTerms,
MerchantPayResponse,
+ MerchantRefundResponse,
NotificationType,
parsePayUri,
parseRefundUri,
+ parseTalerUri,
PayCoinSelection,
PreparePayResult,
PreparePayResultType,
PrepareRefundResult,
+ randomBytes,
RefreshReason,
TalerError,
TalerErrorCode,
TalerErrorDetail,
TalerProtocolTimestamp,
TalerProtocolViolationError,
+ TalerUriAction,
TransactionMajorState,
TransactionMinorState,
TransactionState,
@@ -93,11 +97,16 @@ import {
PurchaseRecord,
PurchaseStatus,
RefundReason,
- RefundState,
WalletContractData,
WalletStoresV1,
} from "../db.js";
-import { GetReadWriteAccess, PendingTaskType } from "../index.js";
+import {
+ PendingTaskType,
+ RefundGroupRecord,
+ RefundGroupStatus,
+ RefundItemRecord,
+ RefundItemStatus,
+} from "../index.js";
import {
EXCHANGE_COINS_LOCK,
InternalWalletState,
@@ -116,10 +125,19 @@ import {
} from "../util/retries.js";
import {
makeTransactionId,
+ runLongpollAsync,
runOperationWithErrorReporting,
spendCoins,
} from "./common.js";
-import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js";
+import {
+ calculateRefreshOutput,
+ createRefreshGroup,
+ getTotalRefreshCost,
+} from "./refresh.js";
+import {
+ constructTransactionIdentifier,
+ notifyTransition,
+} from "./transactions.js";
/**
* Logger.
@@ -193,7 +211,7 @@ async function failProposalPermanently(
if (!p) {
return;
}
- p.purchaseStatus = PurchaseStatus.ProposalDownloadFailed;
+ p.purchaseStatus = PurchaseStatus.FailedClaim;
await tx.purchases.put(p);
});
}
@@ -601,7 +619,6 @@ async function startDownloadProposal(
merchantPaySig: undefined,
payInfo: undefined,
refundAmountAwaiting: undefined,
- refunds: {},
timestampAccept: undefined,
timestampFirstSuccessfulPay: undefined,
timestampLastRefundStatus: undefined,
@@ -649,7 +666,7 @@ async function storeFirstPaySuccess(
return;
}
if (purchase.purchaseStatus === PurchaseStatus.Paying) {
- purchase.purchaseStatus = PurchaseStatus.Paid;
+ purchase.purchaseStatus = PurchaseStatus.Done;
}
purchase.timestampFirstSuccessfulPay = now;
purchase.lastSessionId = sessionId;
@@ -701,7 +718,7 @@ async function storePayReplaySuccess(
purchase.purchaseStatus === PurchaseStatus.Paying ||
purchase.purchaseStatus === PurchaseStatus.PayingReplay
) {
- purchase.purchaseStatus = PurchaseStatus.Paid;
+ purchase.purchaseStatus = PurchaseStatus.Done;
}
purchase.lastSessionId = sessionId;
await tx.purchases.put(purchase);
@@ -899,6 +916,11 @@ export async function checkPaymentByProposalId(
proposalId = proposal.proposalId;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
+
const talerUri = constructPayUri(
proposal.merchantBaseUrl,
proposal.orderId,
@@ -937,6 +959,7 @@ export async function checkPaymentByProposalId(
status: PreparePayResultType.InsufficientBalance,
contractTerms: d.contractTermsRaw,
proposalId: proposal.proposalId,
+ transactionId,
noncePriv: proposal.noncePriv,
amountRaw: Amounts.stringify(d.contractData.amount),
talerUri,
@@ -951,6 +974,7 @@ export async function checkPaymentByProposalId(
return {
status: PreparePayResultType.PaymentPossible,
contractTerms: d.contractTermsRaw,
+ transactionId,
proposalId: proposal.proposalId,
noncePriv: proposal.noncePriv,
amountEffective: Amounts.stringify(totalCost),
@@ -961,7 +985,7 @@ export async function checkPaymentByProposalId(
}
if (
- purchase.purchaseStatus === PurchaseStatus.Paid &&
+ purchase.purchaseStatus === PurchaseStatus.Done &&
purchase.lastSessionId !== sessionId
) {
logger.trace(
@@ -992,6 +1016,7 @@ export async function checkPaymentByProposalId(
paid: true,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ transactionId,
proposalId,
talerUri,
};
@@ -1004,12 +1029,13 @@ export async function checkPaymentByProposalId(
paid: false,
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
+ transactionId,
proposalId,
talerUri,
};
} else {
const paid =
- purchase.purchaseStatus === PurchaseStatus.Paid ||
+ purchase.purchaseStatus === PurchaseStatus.Done ||
purchase.purchaseStatus === PurchaseStatus.QueryingRefund ||
purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund;
const download = await expectProposalDownload(ws, purchase);
@@ -1021,6 +1047,7 @@ export async function checkPaymentByProposalId(
amountRaw: Amounts.stringify(download.contractData.amount),
amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),
...(paid ? { nextUrl: download.contractData.orderId } : {}),
+ transactionId,
proposalId,
talerUri,
};
@@ -1244,7 +1271,7 @@ export async function confirmPay(
) {
logger.trace(`changing session ID to ${sessionIdOverride}`);
purchase.lastSessionId = sessionIdOverride;
- if (purchase.purchaseStatus === PurchaseStatus.Paid) {
+ if (purchase.purchaseStatus === PurchaseStatus.Done) {
purchase.purchaseStatus = PurchaseStatus.PayingReplay;
}
await tx.purchases.put(purchase);
@@ -1331,7 +1358,7 @@ export async function confirmPay(
refreshReason: RefreshReason.PayMerchant,
});
break;
- case PurchaseStatus.Paid:
+ case PurchaseStatus.Done:
case PurchaseStatus.Paying:
default:
break;
@@ -1371,20 +1398,24 @@ export async function processPurchase(
switch (purchase.purchaseStatus) {
case PurchaseStatus.DownloadingProposal:
- return processDownloadProposal(ws, proposalId, options);
+ return processDownloadProposal(ws, proposalId);
case PurchaseStatus.Paying:
case PurchaseStatus.PayingReplay:
- return processPurchasePay(ws, proposalId, options);
+ return processPurchasePay(ws, proposalId);
case PurchaseStatus.QueryingRefund:
+ return processPurchaseQueryRefund(ws, purchase);
case PurchaseStatus.QueryingAutoRefund:
+ return processPurchaseAutoRefund(ws, purchase);
case PurchaseStatus.AbortingWithRefund:
- return processPurchaseQueryRefund(ws, proposalId, options);
- case PurchaseStatus.ProposalDownloadFailed:
- case PurchaseStatus.Paid:
+ return processPurchaseAbortingRefund(ws, purchase);
+ case PurchaseStatus.PendingAcceptRefund:
+ return processPurchaseAcceptRefund(ws, purchase);
+ case PurchaseStatus.FailedClaim:
+ case PurchaseStatus.Done:
case PurchaseStatus.RepurchaseDetected:
case PurchaseStatus.Proposed:
- case PurchaseStatus.ProposalRefused:
- case PurchaseStatus.PaymentAbortFinished:
+ case PurchaseStatus.AbortedProposalRefused:
+ case PurchaseStatus.AbortedIncompletePayment:
return {
type: OperationAttemptResultType.Finished,
result: undefined,
@@ -1588,7 +1619,7 @@ export async function refuseProposal(
if (proposal.purchaseStatus !== PurchaseStatus.Proposed) {
return false;
}
- proposal.purchaseStatus = PurchaseStatus.ProposalRefused;
+ proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;
await tx.purchases.put(proposal);
return true;
});
@@ -1599,603 +1630,324 @@ export async function refuseProposal(
}
}
-export async function prepareRefund(
- ws: InternalWalletState,
- talerRefundUri: string,
-): Promise<PrepareRefundResult> {
- const parseResult = parseRefundUri(talerRefundUri);
-
- logger.trace("preparing refund offer", parseResult);
-
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
-
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.get([
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
- });
-
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
- );
- }
-
- const awaiting = await queryAndSaveAwaitingRefund(ws, purchase);
- const summary = await calculateRefundSummary(ws, purchase);
- const proposalId = purchase.proposalId;
-
- const { contractData: c } = await expectProposalDownload(ws, purchase);
-
- return {
- proposalId,
- effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
- gone: Amounts.stringify(summary.amountRefundGone),
- granted: Amounts.stringify(summary.amountRefundGranted),
- pending: summary.pendingAtExchange,
- awaiting: Amounts.stringify(awaiting),
- info: {
- contractTermsHash: c.contractTermsHash,
- merchant: c.merchant,
- orderId: c.orderId,
- products: c.products,
- summary: c.summary,
- fulfillmentMessage: c.fulfillmentMessage,
- summary_i18n: c.summaryI18n,
- fulfillmentMessage_i18n: c.fulfillmentMessageI18n,
- },
- };
-}
-
-function getRefundKey(d: MerchantCoinRefundStatus): string {
- return `${d.coin_pub}-${d.rtransaction_id}`;
-}
-
-async function applySuccessfulRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, CoinRefreshRequest>,
- r: MerchantCoinRefundSuccessStatus,
- denomselAllowLate: boolean,
-): Promise<void> {
- // FIXME: check signature before storing it as valid!
-
- const refundKey = getRefundKey(r);
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- throw Error("inconsistent database");
- }
- const refundAmount = Amounts.parseOrThrow(r.refund_amount);
- const refundFee = denom.fees.feeRefund;
- const amountLeft = Amounts.sub(refundAmount, refundFee).amount;
- coin.status = CoinStatus.Dormant;
- await tx.coins.put(coin);
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- denomselAllowLate,
- );
-
- refreshCoinsMap[coin.coinPub] = {
- coinPub: coin.coinPub,
- amount: Amounts.stringify(amountLeft),
- };
-
- p.refunds[refundKey] = {
- type: RefundState.Applied,
- obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
- executionTime: r.execution_time,
- refundAmount: Amounts.stringify(r.refund_amount),
- refundFee: Amounts.stringify(denom.fees.feeRefund),
- totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound),
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storePendingRefund(
- tx: GetReadWriteAccess<{
- denominations: typeof WalletStoresV1.denominations;
- coins: typeof WalletStoresV1.coins;
- }>,
- p: PurchaseRecord,
- r: MerchantCoinRefundFailureStatus,
- denomselAllowLate: boolean,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- // Refunded amount after fees.
- const amountLeft = Amounts.sub(
- Amounts.parseOrThrow(r.refund_amount),
- denom.fees.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- denomselAllowLate,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Pending,
- obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()),
- executionTime: r.execution_time,
- refundAmount: Amounts.stringify(r.refund_amount),
- refundFee: Amounts.stringify(denom.fees.feeRefund),
- totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound),
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-}
-
-async function storeFailedRefund(
- tx: GetReadWriteAccess<{
- coins: typeof WalletStoresV1.coins;
- denominations: typeof WalletStoresV1.denominations;
- }>,
- p: PurchaseRecord,
- refreshCoinsMap: Record<string, CoinRefreshRequest>,
- r: MerchantCoinRefundFailureStatus,
- denomselAllowLate: boolean,
-): Promise<void> {
- const refundKey = getRefundKey(r);
-
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
-
- if (!denom) {
- throw Error("inconsistent database");
- }
-
- const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl
- .iter(coin.exchangeBaseUrl)
- .toArray();
-
- const amountLeft = Amounts.sub(
- Amounts.parseOrThrow(r.refund_amount),
- denom.fees.feeRefund,
- ).amount;
-
- const totalRefreshCostBound = getTotalRefreshCost(
- allDenoms,
- DenominationRecord.toDenomInfo(denom),
- amountLeft,
- denomselAllowLate,
- );
-
- p.refunds[refundKey] = {
- type: RefundState.Failed,
- obtainedTime: TalerProtocolTimestamp.now(),
- executionTime: r.execution_time,
- refundAmount: Amounts.stringify(r.refund_amount),
- refundFee: Amounts.stringify(denom.fees.feeRefund),
- totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound),
- coinPub: r.coin_pub,
- rtransactionId: r.rtransaction_id,
- };
-
- if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- // Refund failed because the merchant didn't even try to deposit
- // the coin yet, so we try to refresh.
- // FIXME: Is this case tested?!
- if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) {
- const coin = await tx.coins.get(r.coin_pub);
- if (!coin) {
- logger.warn("coin not found, can't apply refund");
- return;
- }
- const denom = await tx.denominations.get([
- coin.exchangeBaseUrl,
- coin.denomPubHash,
- ]);
- if (!denom) {
- logger.warn("denomination for coin missing");
- return;
- }
- const payCoinSelection = p.payInfo?.payCoinSelection;
- if (!payCoinSelection) {
- logger.warn("no pay coin selection, can't apply refund");
- return;
- }
- let contrib: AmountJson | undefined;
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- if (payCoinSelection.coinPubs[i] === r.coin_pub) {
- contrib = Amounts.parseOrThrow(payCoinSelection.coinContributions[i]);
- }
- }
- // FIXME: Is this case tested?!
- refreshCoinsMap[coin.coinPub] = {
- coinPub: coin.coinPub,
- amount: Amounts.stringify(amountLeft),
- };
- await tx.coins.put(coin);
- }
- }
-}
-
-async function acceptRefunds(
+export async function abortPayMerchant(
ws: InternalWalletState,
proposalId: string,
- refunds: MerchantCoinRefundStatus[],
- reason: RefundReason,
): Promise<void> {
- logger.trace("handling refunds", refunds);
- const now = TalerProtocolTimestamp.now();
-
+ const opId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
await ws.db
.mktx((x) => [
x.purchases,
- x.coins,
- x.coinAvailability,
- x.denominations,
x.refreshGroups,
+ x.denominations,
+ x.coinAvailability,
+ x.coins,
+ x.operationRetries,
])
.runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("purchase not found, not adding refunds");
+ const purchase = await tx.purchases.get(proposalId);
+ if (!purchase) {
+ throw Error("purchase not found");
+ }
+ const oldStatus = purchase.purchaseStatus;
+ if (purchase.timestampFirstSuccessfulPay) {
+ // No point in aborting it. We don't even report an error.
+ logger.warn(`tried to abort successful payment`);
return;
}
-
- const refreshCoinsMap: Record<string, CoinRefreshRequest> = {};
- for (const refundStatus of refunds) {
- const refundKey = getRefundKey(refundStatus);
- const existingRefundInfo = p.refunds[refundKey];
-
- const isPermanentFailure =
- refundStatus.type === "failure" &&
- refundStatus.exchange_status >= 400 &&
- refundStatus.exchange_status < 500;
-
- // Already failed.
- if (existingRefundInfo?.type === RefundState.Failed) {
- continue;
- }
-
- // Already applied.
- if (existingRefundInfo?.type === RefundState.Applied) {
- continue;
- }
-
- // Still pending.
- if (
- refundStatus.type === "failure" &&
- !isPermanentFailure &&
- existingRefundInfo?.type === RefundState.Pending
- ) {
- continue;
- }
-
- // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending)
-
- if (refundStatus.type === "success") {
- await applySuccessfulRefund(
- tx,
- p,
- refreshCoinsMap,
- refundStatus,
- ws.config.testing.denomselAllowLate,
- );
- } else if (isPermanentFailure) {
- await storeFailedRefund(
- tx,
- p,
- refreshCoinsMap,
- refundStatus,
- ws.config.testing.denomselAllowLate,
- );
- } else {
- await storePendingRefund(
- tx,
- p,
- refundStatus,
- ws.config.testing.denomselAllowLate,
- );
- }
+ if (oldStatus === PurchaseStatus.Paying) {
+ purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
}
-
- if (reason !== RefundReason.AbortRefund) {
- // For abort-refunds, the refresh group has already been
- // created before the refund was started.
- // For other refunds, we need to create it after we know
- // the amounts.
- const refreshCoinsPubs = Object.values(refreshCoinsMap);
- logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`);
- if (refreshCoinsPubs.length > 0) {
+ await tx.purchases.put(purchase);
+ if (oldStatus === PurchaseStatus.Paying) {
+ if (purchase.payInfo) {
+ const coinSel = purchase.payInfo.payCoinSelection;
+ const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (let i = 0; i < coinSel.coinPubs.length; i++) {
+ refreshCoins.push({
+ amount: coinSel.coinContributions[i],
+ coinPub: coinSel.coinPubs[i],
+ });
+ }
await createRefreshGroup(
ws,
tx,
- Amounts.currencyOf(refreshCoinsPubs[0].amount),
- refreshCoinsPubs,
- RefreshReason.Refund,
+ currency,
+ refreshCoins,
+ RefreshReason.AbortPay,
);
}
}
-
- // Are we done with querying yet, or do we need to do another round
- // after a retry delay?
- let queryDone = true;
-
- let numPendingRefunds = 0;
- for (const ri of Object.values(p.refunds)) {
- switch (ri.type) {
- case RefundState.Pending:
- numPendingRefunds++;
- break;
- }
- }
-
- if (numPendingRefunds > 0) {
- queryDone = false;
- }
-
- if (queryDone) {
- p.timestampLastRefundStatus = now;
- if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- p.purchaseStatus = PurchaseStatus.PaymentAbortFinished;
- } else if (p.purchaseStatus === PurchaseStatus.QueryingAutoRefund) {
- const autoRefundDeadline = p.autoRefundDeadline;
- checkDbInvariant(!!autoRefundDeadline);
- if (
- AbsoluteTime.isExpired(
- AbsoluteTime.fromTimestamp(autoRefundDeadline),
- )
- ) {
- p.purchaseStatus = PurchaseStatus.Paid;
- }
- } else if (p.purchaseStatus === PurchaseStatus.QueryingRefund) {
- p.purchaseStatus = PurchaseStatus.Paid;
- p.refundAmountAwaiting = undefined;
- }
- logger.trace("refund query done");
- ws.notify({
- type: NotificationType.RefundFinished,
- transactionId: makeTransactionId(
- TransactionType.Payment,
- p.proposalId,
- ),
- });
- } else {
- // No error, but we need to try again!
- p.timestampLastRefundStatus = now;
- logger.trace("refund query not done");
- }
-
- await tx.purchases.put(p);
+ await tx.operationRetries.delete(opId);
});
- ws.notify({
- type: NotificationType.RefundQueried,
- transactionId: makeTransactionId(TransactionType.Payment, proposalId),
- });
+ ws.workAvailable.trigger();
+}
+
+export function computePayMerchantTransactionState(
+ purchaseRecord: PurchaseRecord,
+): TransactionState {
+ switch (purchaseRecord.purchaseStatus) {
+ case PurchaseStatus.DownloadingProposal:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.Done:
+ return {
+ major: TransactionMajorState.Done,
+ };
+ case PurchaseStatus.AbortedIncompletePayment:
+ return {
+ major: TransactionMajorState.Aborted,
+ };
+ case PurchaseStatus.Proposed:
+ return {
+ major: TransactionMajorState.Dialog,
+ minor: TransactionMinorState.MerchantOrderProposed,
+ };
+ case PurchaseStatus.FailedClaim:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.ClaimProposal,
+ };
+ case PurchaseStatus.RepurchaseDetected:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Repurchase,
+ };
+ case PurchaseStatus.AbortingWithRefund:
+ return {
+ major: TransactionMajorState.Aborting,
+ };
+ case PurchaseStatus.Paying:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.Pay,
+ };
+ case PurchaseStatus.PayingReplay:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.RebindSession,
+ };
+ case PurchaseStatus.AbortedProposalRefused:
+ return {
+ major: TransactionMajorState.Failed,
+ minor: TransactionMinorState.Refused,
+ };
+ case PurchaseStatus.QueryingAutoRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AutoRefund,
+ };
+ case PurchaseStatus.QueryingRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.CheckRefunds,
+ };
+ case PurchaseStatus.PendingAcceptRefund:
+ return {
+ major: TransactionMajorState.Pending,
+ minor: TransactionMinorState.AcceptRefund,
+ };
+ }
}
-async function calculateRefundSummary(
+async function processPurchaseAutoRefund(
ws: InternalWalletState,
- p: PurchaseRecord,
-): Promise<RefundSummary> {
- const download = await expectProposalDownload(ws, p);
- let amountRefundGranted = Amounts.zeroOfAmount(download.contractData.amount);
- let amountRefundGone = Amounts.zeroOfAmount(download.contractData.amount);
+ purchase: PurchaseRecord,
+): Promise<OperationAttemptResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing auto-refund for proposal ${proposalId}`);
+
+ const taskId = constructTaskIdentifier({
+ tag: PendingTaskType.Purchase,
+ proposalId,
+ });
- let pendingAtExchange = false;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
- const payInfo = p.payInfo;
- if (!payInfo) {
- throw Error("can't calculate refund summary without payInfo");
+ // FIXME: Put this logic into runLongpollAsync?
+ if (ws.activeLongpoll[taskId]) {
+ return OperationAttemptResult.longpoll();
}
- Object.keys(p.refunds).forEach((rk) => {
- const refund = p.refunds[rk];
- if (refund.type === RefundState.Pending) {
- pendingAtExchange = true;
- }
+ const download = await expectProposalDownload(ws, purchase);
+
+ runLongpollAsync(ws, taskId, async (ct) => {
if (
- refund.type === RefundState.Applied ||
- refund.type === RefundState.Pending
+ !purchase.autoRefundDeadline ||
+ AbsoluteTime.isExpired(
+ AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
+ )
) {
- amountRefundGranted = Amounts.add(
- amountRefundGranted,
- Amounts.sub(
- refund.refundAmount,
- refund.refundFee,
- refund.totalRefreshCostBound,
- ).amount,
- ).amount;
- } else {
- amountRefundGone = Amounts.add(
- amountRefundGone,
- refund.refundAmount,
- ).amount;
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return {
+ ready: true,
+ };
}
- });
- return {
- amountEffectivePaid: Amounts.parseOrThrow(payInfo.totalPayCost),
- amountRefundGone,
- amountRefundGranted,
- pendingAtExchange,
- };
-}
-
-/**
- * Summary of the refund status of a purchase.
- */
-export interface RefundSummary {
- pendingAtExchange: boolean;
- amountEffectivePaid: AmountJson;
- amountRefundGranted: AmountJson;
- amountRefundGone: AmountJson;
-}
-/**
- * Accept a refund, return the contract hash for the contract
- * that was involved in the refund.
- */
-export async function applyRefund(
- ws: InternalWalletState,
- talerRefundUri: string,
-): Promise<ApplyRefundResponse> {
- const parseResult = parseRefundUri(talerRefundUri);
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}`,
+ download.contractData.merchantBaseUrl,
+ );
+ requestUrl.searchParams.set(
+ "h_contract",
+ download.contractData.contractTermsHash,
+ );
- logger.trace("applying refund", parseResult);
+ requestUrl.searchParams.set("timeout_ms", "1000");
+ requestUrl.searchParams.set("await_refund_obtained", "yes");
- if (!parseResult) {
- throw Error("invalid refund URI");
- }
+ const resp = await ws.http.fetch(requestUrl.href);
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.indexes.byUrlAndOrderId.get([
- parseResult.merchantBaseUrl,
- parseResult.orderId,
- ]);
- });
+ // FIXME: Check other status codes!
- if (!purchase) {
- throw Error(
- `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`,
+ const orderStatus = await readSuccessResponseJsonOrThrow(
+ resp,
+ codecForMerchantOrderStatusPaid(),
);
- }
- return applyRefundFromPurchaseId(ws, purchase.proposalId);
+ if (orderStatus.refund_pending) {
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.QueryingAutoRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return {
+ ready: true,
+ };
+ } else {
+ return {
+ ready: false,
+ };
+ }
+ });
+
+ return OperationAttemptResult.longpoll();
}
-export async function applyRefundFromPurchaseId(
+async function processPurchaseAbortingRefund(
ws: InternalWalletState,
- proposalId: string,
-): Promise<ApplyRefundResponse> {
- logger.trace("applying refund for purchase", proposalId);
+ purchase: PurchaseRecord,
+): Promise<OperationAttemptResult> {
+ const proposalId = purchase.proposalId;
+ const download = await expectProposalDownload(ws, purchase);
+ logger.trace(`processing aborting-refund for proposal ${proposalId}`);
- logger.info("processing purchase for refund");
- const success = await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.error("no purchase found for refund URL");
- return false;
- }
- if (p.purchaseStatus === PurchaseStatus.Paid) {
- p.purchaseStatus = PurchaseStatus.QueryingRefund;
- }
- await tx.purchases.put(p);
- return true;
- });
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/abort`,
+ download.contractData.merchantBaseUrl,
+ );
- if (success) {
- ws.notify({
- type: NotificationType.RefundStarted,
- });
- await processPurchaseQueryRefund(ws, proposalId, {
- forceNow: true,
- waitForAutoRefund: false,
- });
+ const abortingCoins: AbortingCoin[] = [];
+
+ const payCoinSelection = purchase.payInfo?.payCoinSelection;
+ if (!payCoinSelection) {
+ throw Error("can't abort, no coins selected");
}
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
+ await ws.db
+ .mktx((x) => [x.coins])
.runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
+ for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
+ const coinPub = payCoinSelection.coinPubs[i];
+ const coin = await tx.coins.get(coinPub);
+ checkDbInvariant(!!coin, "expected coin to be present");
+ abortingCoins.push({
+ coin_pub: coinPub,
+ contribution: Amounts.stringify(
+ payCoinSelection.coinContributions[i],
+ ),
+ exchange_url: coin.exchangeBaseUrl,
+ });
+ }
});
- if (!purchase) {
- throw Error("purchase no longer exists");
- }
+ const abortReq: AbortRequest = {
+ h_contract: download.contractData.contractTermsHash,
+ coins: abortingCoins,
+ };
- const summary = await calculateRefundSummary(ws, purchase);
- const download = await expectProposalDownload(ws, purchase);
+ logger.trace(`making order abort request to ${requestUrl.href}`);
- const lastExec = Object.values(purchase.refunds).reduce(
- (prev, cur) => {
- return TalerProtocolTimestamp.max(cur.executionTime, prev);
- },
- { t_s: 0 } as TalerProtocolTimestamp,
+ const request = await ws.http.postJson(requestUrl.href, abortReq);
+ const abortResp = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForAbortResponse(),
);
- const transactionId =
- lastExec.t_s === "never" || lastExec.t_s === 0
- ? makeTransactionId(TransactionType.Payment, proposalId)
- : makeTransactionId(
- TransactionType.Refund,
- proposalId,
- String(lastExec.t_s),
- );
+ const refunds: MerchantCoinRefundStatus[] = [];
- return {
- contractTermsHash: download.contractData.contractTermsHash,
- proposalId: purchase.proposalId,
- transactionId,
- amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
- amountRefundGone: Amounts.stringify(summary.amountRefundGone),
- amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
- pendingAtExchange: summary.pendingAtExchange,
- info: {
- contractTermsHash: download.contractData.contractTermsHash,
- merchant: download.contractData.merchant,
- orderId: download.contractData.orderId,
- products: download.contractData.products,
- summary: download.contractData.summary,
- fulfillmentMessage: download.contractData.fulfillmentMessage,
- summary_i18n: download.contractData.summaryI18n,
- fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n,
- },
- };
+ if (abortResp.refunds.length != abortingCoins.length) {
+ // FIXME: define error code!
+ throw Error("invalid order abort response");
+ }
+
+ for (let i = 0; i < abortResp.refunds.length; i++) {
+ const r = abortResp.refunds[i];
+ refunds.push({
+ ...r,
+ coin_pub: payCoinSelection.coinPubs[i],
+ refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
+ rtransaction_id: 0,
+ execution_time: AbsoluteTime.toTimestamp(
+ AbsoluteTime.addDuration(
+ AbsoluteTime.fromTimestamp(download.contractData.timestamp),
+ Duration.fromSpec({ seconds: 1 }),
+ ),
+ ),
+ });
+ }
+ return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);
}
-async function queryAndSaveAwaitingRefund(
+async function processPurchaseQueryRefund(
ws: InternalWalletState,
purchase: PurchaseRecord,
- waitForAutoRefund?: boolean,
-): Promise<AmountJson> {
+): Promise<OperationAttemptResult> {
+ const proposalId = purchase.proposalId;
+ logger.trace(`processing query-refund for proposal ${proposalId}`);
+
const download = await expectProposalDownload(ws, purchase);
+
const requestUrl = new URL(
`orders/${download.contractData.orderId}`,
download.contractData.merchantBaseUrl,
@@ -2204,32 +1956,45 @@ async function queryAndSaveAwaitingRefund(
"h_contract",
download.contractData.contractTermsHash,
);
- // Long-poll for one second
- if (waitForAutoRefund) {
- requestUrl.searchParams.set("timeout_ms", "1000");
- requestUrl.searchParams.set("await_refund_obtained", "yes");
- logger.trace("making long-polling request for auto-refund");
- }
- const resp = await ws.http.get(requestUrl.href);
+
+ const resp = await ws.http.fetch(requestUrl.href);
const orderStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantOrderStatusPaid(),
);
- if (!orderStatus.refunded) {
- // Wait for retry ...
- return Amounts.zeroOfAmount(download.contractData.amount);
- }
- const refundAwaiting = Amounts.sub(
- Amounts.parseOrThrow(orderStatus.refund_amount),
- Amounts.parseOrThrow(orderStatus.refund_taken),
- ).amount;
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId,
+ });
- if (
- purchase.refundAmountAwaiting === undefined ||
- Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0
- ) {
- await ws.db
+ if (!orderStatus.refund_pending) {
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(purchase.proposalId);
+ if (!p) {
+ logger.warn("purchase does not exist anymore");
+ return undefined;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) {
+ return undefined;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.Done;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ return OperationAttemptResult.finishedEmpty();
+ } else {
+ const refundAwaiting = Amounts.sub(
+ Amounts.parseOrThrow(orderStatus.refund_amount),
+ Amounts.parseOrThrow(orderStatus.refund_taken),
+ ).amount;
+
+ const transitionInfo = await ws.db
.mktx((x) => [x.purchases])
.runReadWrite(async (tx) => {
const p = await tx.purchases.get(purchase.proposalId);
@@ -2237,304 +2002,359 @@ async function queryAndSaveAwaitingRefund(
logger.warn("purchase does not exist anymore");
return;
}
+ if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
p.refundAmountAwaiting = Amounts.stringify(refundAwaiting);
+ p.purchaseStatus = PurchaseStatus.PendingAcceptRefund;
+ const newTxState = computePayMerchantTransactionState(p);
await tx.purchases.put(p);
+ return { oldTxState, newTxState };
});
+ notifyTransition(ws, transactionId, transitionInfo);
+ return OperationAttemptResult.finishedEmpty();
}
-
- return refundAwaiting;
}
-export async function processPurchaseQueryRefund(
+async function processPurchaseAcceptRefund(
ws: InternalWalletState,
- proposalId: string,
- options: {
- forceNow?: boolean;
- waitForAutoRefund?: boolean;
- } = {},
+ purchase: PurchaseRecord,
): Promise<OperationAttemptResult> {
- logger.trace(`processing refund query for proposal ${proposalId}`);
- const waitForAutoRefund = options.waitForAutoRefund ?? false;
- const purchase = await ws.db
- .mktx((x) => [x.purchases])
- .runReadOnly(async (tx) => {
- return tx.purchases.get(proposalId);
- });
- if (!purchase) {
- return OperationAttemptResult.finishedEmpty();
- }
-
- if (
- !(
- purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund ||
- purchase.purchaseStatus === PurchaseStatus.QueryingRefund ||
- purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund
- )
- ) {
- return OperationAttemptResult.finishedEmpty();
- }
+ const proposalId = purchase.proposalId;
const download = await expectProposalDownload(ws, purchase);
- if (purchase.timestampFirstSuccessfulPay) {
- if (
- !purchase.autoRefundDeadline ||
- !AbsoluteTime.isExpired(
- AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
- )
- ) {
- const awaitingAmount = await queryAndSaveAwaitingRefund(
- ws,
- purchase,
- waitForAutoRefund,
- );
- if (Amounts.isZero(awaitingAmount)) {
- // Maybe the user wanted to check for refund to find out
- // that there is no refund pending from merchant
- await ws.db
- .mktx((x) => [x.purchases])
- .runReadWrite(async (tx) => {
- const p = await tx.purchases.get(proposalId);
- if (!p) {
- logger.warn("purchase does not exist anymore");
- return;
- }
- p.purchaseStatus = PurchaseStatus.Paid;
- await tx.purchases.put(p);
- });
-
- // No new refunds, but we still need to notify
- // the wallet client that the query finished.
- ws.notify({
- type: NotificationType.RefundQueried,
- transactionId: makeTransactionId(TransactionType.Payment, proposalId),
- });
+ const requestUrl = new URL(
+ `orders/${download.contractData.orderId}/refund`,
+ download.contractData.merchantBaseUrl,
+ );
- return OperationAttemptResult.finishedEmpty();
- }
- }
+ logger.trace(`making refund request to ${requestUrl.href}`);
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/refund`,
- download.contractData.merchantBaseUrl,
- );
+ const request = await ws.http.postJson(requestUrl.href, {
+ h_contract: download.contractData.contractTermsHash,
+ });
- logger.trace(`making refund request to ${requestUrl.href}`);
+ const refundResponse = await readSuccessResponseJsonOrThrow(
+ request,
+ codecForMerchantOrderRefundPickupResponse(),
+ );
+ return await storeRefunds(
+ ws,
+ purchase,
+ refundResponse.refunds,
+ RefundReason.AbortRefund,
+ );
+}
- const request = await ws.http.postJson(requestUrl.href, {
- h_contract: download.contractData.contractTermsHash,
+export async function startRefundQueryForUri(
+ ws: InternalWalletState,
+ talerUri: string,
+): Promise<void> {
+ const parsedUri = parseTalerUri(talerUri);
+ if (!parsedUri) {
+ throw Error("invalid taler:// URI");
+ }
+ if (parsedUri.type !== TalerUriAction.Refund) {
+ throw Error("expected taler://refund URI");
+ }
+ const purchaseRecord = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadOnly(async (tx) => {
+ return tx.purchases.indexes.byUrlAndOrderId.get([
+ parsedUri.merchantBaseUrl,
+ parsedUri.orderId,
+ ]);
});
-
- const refundResponse = await readSuccessResponseJsonOrThrow(
- request,
- codecForMerchantOrderRefundPickupResponse(),
- );
-
- await acceptRefunds(
- ws,
- proposalId,
- refundResponse.refunds,
- RefundReason.NormalRefund,
- );
- } else if (purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund) {
- const requestUrl = new URL(
- `orders/${download.contractData.orderId}/abort`,
- download.contractData.merchantBaseUrl,
- );
-
- const abortingCoins: AbortingCoin[] = [];
-
- const payCoinSelection = purchase.payInfo?.payCoinSelection;
- if (!payCoinSelection) {
- throw Error("can't abort, no coins selected");
- }
-
- await ws.db
- .mktx((x) => [x.coins])
- .runReadOnly(async (tx) => {
- for (let i = 0; i < payCoinSelection.coinPubs.length; i++) {
- const coinPub = payCoinSelection.coinPubs[i];
- const coin = await tx.coins.get(coinPub);
- checkDbInvariant(!!coin, "expected coin to be present");
- abortingCoins.push({
- coin_pub: coinPub,
- contribution: Amounts.stringify(
- payCoinSelection.coinContributions[i],
- ),
- exchange_url: coin.exchangeBaseUrl,
- });
- }
- });
-
- const abortReq: AbortRequest = {
- h_contract: download.contractData.contractTermsHash,
- coins: abortingCoins,
- };
-
- logger.trace(`making order abort request to ${requestUrl.href}`);
-
- const request = await ws.http.postJson(requestUrl.href, abortReq);
- const abortResp = await readSuccessResponseJsonOrThrow(
- request,
- codecForAbortResponse(),
- );
-
- const refunds: MerchantCoinRefundStatus[] = [];
-
- if (abortResp.refunds.length != abortingCoins.length) {
- // FIXME: define error code!
- throw Error("invalid order abort response");
- }
-
- for (let i = 0; i < abortResp.refunds.length; i++) {
- const r = abortResp.refunds[i];
- refunds.push({
- ...r,
- coin_pub: payCoinSelection.coinPubs[i],
- refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]),
- rtransaction_id: 0,
- execution_time: AbsoluteTime.toTimestamp(
- AbsoluteTime.addDuration(
- AbsoluteTime.fromTimestamp(download.contractData.timestamp),
- Duration.fromSpec({ seconds: 1 }),
- ),
- ),
- });
- }
- await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund);
+ if (!purchaseRecord) {
+ throw Error("no purchase found, can't refund");
}
- return OperationAttemptResult.finishedEmpty();
+ return startQueryRefund(ws, purchaseRecord.proposalId);
}
-export async function abortPayMerchant(
+export async function startQueryRefund(
ws: InternalWalletState,
proposalId: string,
- cancelImmediately?: boolean,
): Promise<void> {
- const opId = constructTaskIdentifier({
- tag: PendingTaskType.Purchase,
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
proposalId,
});
- await ws.db
+ const transitionInfo = await ws.db
+ .mktx((x) => [x.purchases])
+ .runReadWrite(async (tx) => {
+ const p = await tx.purchases.get(proposalId);
+ if (!p) {
+ logger.warn(`purchase ${proposalId} does not exist anymore`);
+ return;
+ }
+ if (p.purchaseStatus !== PurchaseStatus.Done) {
+ return;
+ }
+ const oldTxState = computePayMerchantTransactionState(p);
+ p.purchaseStatus = PurchaseStatus.QueryingRefund;
+ const newTxState = computePayMerchantTransactionState(p);
+ await tx.purchases.put(p);
+ return { oldTxState, newTxState };
+ });
+ notifyTransition(ws, transactionId, transitionInfo);
+ ws.workAvailable.trigger();
+}
+
+/**
+ * Store refunds, possibly creating a new refund group.
+ */
+async function storeRefunds(
+ ws: InternalWalletState,
+ purchase: PurchaseRecord,
+ refunds: MerchantCoinRefundStatus[],
+ reason: RefundReason,
+): Promise<OperationAttemptResult> {
+ logger.info(`storing refunds: ${j2s(refunds)}`);
+
+ const transactionId = constructTransactionIdentifier({
+ tag: TransactionType.Payment,
+ proposalId: purchase.proposalId,
+ });
+
+ const newRefundGroupId = encodeCrock(randomBytes(32));
+ const now = TalerProtocolTimestamp.now();
+
+ const download = await expectProposalDownload(ws, purchase);
+ const currency = Amounts.currencyOf(download.contractData.amount);
+
+ const getItemStatus = (rf: MerchantCoinRefundStatus) => {
+ if (rf.type === "success") {
+ return RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ return RefundItemStatus.Pending;
+ } else {
+ return RefundItemStatus.Failed;
+ }
+ }
+ };
+
+ const result = await ws.db
.mktx((x) => [
x.purchases,
- x.refreshGroups,
+ x.refundGroups,
+ x.refundItems,
+ x.coins,
x.denominations,
x.coinAvailability,
- x.coins,
- x.operationRetries,
+ x.refreshGroups,
])
.runReadWrite(async (tx) => {
- const purchase = await tx.purchases.get(proposalId);
- if (!purchase) {
- throw Error("purchase not found");
+ const computeRefreshRequest = async (items: RefundItemRecord[]) => {
+ const refreshCoins: CoinRefreshRequest[] = [];
+ for (const item of items) {
+ const coin = await tx.coins.get(item.coinPub);
+ if (!coin) {
+ throw Error("coin not found");
+ }
+ const denomInfo = await ws.getDenomInfo(
+ ws,
+ tx,
+ coin.exchangeBaseUrl,
+ coin.denomPubHash,
+ );
+ if (!denomInfo) {
+ throw Error("denom not found");
+ }
+ if (item.status === RefundItemStatus.Done) {
+ const refundedAmount = Amounts.sub(
+ item.refundAmount,
+ denomInfo.feeRefund,
+ ).amount;
+ refreshCoins.push({
+ amount: Amounts.stringify(refundedAmount),
+ coinPub: item.coinPub,
+ });
+ }
+ }
+ return refreshCoins;
+ };
+
+ const myPurchase = await tx.purchases.get(purchase.proposalId);
+ if (!myPurchase) {
+ logger.warn("purchase group not found anymore");
+ return;
}
- const oldStatus = purchase.purchaseStatus;
- if (purchase.timestampFirstSuccessfulPay) {
- // No point in aborting it. We don't even report an error.
- logger.warn(`tried to abort successful payment`);
+ if (myPurchase.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) {
return;
}
- if (oldStatus === PurchaseStatus.Paying) {
- purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;
+
+ let newGroup: RefundGroupRecord | undefined = undefined;
+ // Pending, but not part of an aborted refund group.
+ let numPendingItemsTotal = 0;
+ const newGroupRefunds: RefundItemRecord[] = [];
+
+ for (const rf of refunds) {
+ const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([
+ rf.coin_pub,
+ rf.rtransaction_id,
+ ]);
+ if (oldItem) {
+ logger.info("already have refund in database");
+ if (oldItem.status === RefundItemStatus.Done) {
+ continue;
+ }
+ if (rf.type === "success") {
+ oldItem.status = RefundItemStatus.Done;
+ } else {
+ if (rf.exchange_status >= 500 && rf.exchange_status <= 599) {
+ oldItem.status = RefundItemStatus.Pending;
+ numPendingItemsTotal += 1;
+ } else {
+ oldItem.status = RefundItemStatus.Failed;
+ }
+ }
+ await tx.refundItems.put(oldItem);
+ } else {
+ // Put refund item into a new group!
+ if (!newGroup) {
+ newGroup = {
+ proposalId: purchase.proposalId,
+ refundGroupId: newRefundGroupId,
+ status: RefundGroupStatus.Pending,
+ timestampCreated: now,
+ amountEffective: Amounts.stringify(
+ Amounts.zeroOfCurrency(currency),
+ ),
+ amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)),
+ };
+ }
+ const status: RefundItemStatus = getItemStatus(rf);
+ const newItem: RefundItemRecord = {
+ coinPub: rf.coin_pub,
+ executionTime: rf.execution_time,
+ obtainedTime: now,
+ refundAmount: rf.refund_amount,
+ refundGroupId: newGroup.refundGroupId,
+ rtxid: rf.rtransaction_id,
+ status,
+ };
+ if (status === RefundItemStatus.Pending) {
+ numPendingItemsTotal += 1;
+ }
+ newGroupRefunds.push(newItem);
+ await tx.refundItems.put(newItem);
+ }
}
- if (
- cancelImmediately &&
- oldStatus === PurchaseStatus.AbortingWithRefund
- ) {
- purchase.purchaseStatus = PurchaseStatus.PaymentAbortFinished;
+
+ // Now that we know all the refunds for the new refund group,
+ // we can compute the raw/effective amounts.
+ if (newGroup) {
+ const amountsRaw = newGroupRefunds.map((x) => x.refundAmount);
+ const refreshCoins = await computeRefreshRequest(newGroupRefunds);
+ const outInfo = await calculateRefreshOutput(
+ ws,
+ tx,
+ currency,
+ refreshCoins,
+ );
+ newGroup.amountEffective = Amounts.stringify(
+ Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount,
+ );
+ newGroup.amountRaw = Amounts.stringify(
+ Amounts.sumOrZero(currency, amountsRaw).amount,
+ );
+ await tx.refundGroups.put(newGroup);
}
- await tx.purchases.put(purchase);
- if (oldStatus === PurchaseStatus.Paying) {
- if (purchase.payInfo) {
- const coinSel = purchase.payInfo.payCoinSelection;
- const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost);
- const refreshCoins: CoinRefreshRequest[] = [];
- for (let i = 0; i < coinSel.coinPubs.length; i++) {
- refreshCoins.push({
- amount: coinSel.coinContributions[i],
- coinPub: coinSel.coinPubs[i],
- });
+
+ const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll(
+ myPurchase.proposalId,
+ );
+
+ logger.info(
+ `refund groups for proposal ${myPurchase.proposalId}: ${j2s(
+ refundGroups,
+ )}`,
+ );
+
+ for (const refundGroup of refundGroups) {
+ if (refundGroup.status === RefundGroupStatus.Aborted) {
+ continue;
+ }
+ if (refundGroup.status === RefundGroupStatus.Done) {
+ continue;
+ }
+ const items = await tx.refundItems.indexes.byRefundGroupId.getAll(
+ refundGroup.refundGroupId,
+ );
+ let numPending = 0;
+ for (const item of items) {
+ if (item.status === RefundItemStatus.Pending) {
+ numPending++;
}
+ }
+ logger.info(`refund items pending for refund group: ${numPending}`);
+ if (numPending === 0) {
+ logger.info("refund group is done!");
+ // We're done for this refund group!
+ refundGroup.status = RefundGroupStatus.Done;
+ await tx.refundGroups.put(refundGroup);
+ const refreshCoins = await computeRefreshRequest(items);
await createRefreshGroup(
ws,
tx,
- currency,
+ Amounts.currencyOf(download.contractData.amount),
refreshCoins,
- RefreshReason.AbortPay,
+ RefreshReason.Refund,
);
}
}
- await tx.operationRetries.delete(opId);
- });
- runOperationWithErrorReporting(ws, opId, async () => {
- return await processPurchaseQueryRefund(ws, proposalId, {
- forceNow: true,
+ const oldTxState = computePayMerchantTransactionState(myPurchase);
+ if (numPendingItemsTotal === 0) {
+ myPurchase.purchaseStatus = PurchaseStatus.Done;
+ }
+ await tx.purchases.put(myPurchase);
+ const newTxState = computePayMerchantTransactionState(myPurchase);
+
+ return {
+ numPendingItemsTotal,
+ transitionInfo: {
+ oldTxState,
+ newTxState,
+ },
+ };
});
- });
+
+ if (!result) {
+ return OperationAttemptResult.finishedEmpty();
+ }
+
+ notifyTransition(ws, transactionId, result.transitionInfo);
+
+ if (result.numPendingItemsTotal > 0) {
+ return OperationAttemptResult.pendingEmpty();
+ }
+
+ return OperationAttemptResult.finishedEmpty();
}
-export function computePayMerchantTransactionState(
- purchaseRecord: PurchaseRecord,
+export function computeRefundTransactionState(
+ refundGroupRecord: RefundGroupRecord,
): TransactionState {
- switch (purchaseRecord.purchaseStatus) {
- case PurchaseStatus.DownloadingProposal:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.Paid:
- return {
- major: TransactionMajorState.Done,
- };
- case PurchaseStatus.PaymentAbortFinished:
+ switch (refundGroupRecord.status) {
+ case RefundGroupStatus.Aborted:
return {
major: TransactionMajorState.Aborted,
};
- case PurchaseStatus.Proposed:
- return {
- major: TransactionMajorState.Dialog,
- minor: TransactionMinorState.MerchantOrderProposed,
- };
- case PurchaseStatus.ProposalDownloadFailed:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.ClaimProposal,
- };
- case PurchaseStatus.RepurchaseDetected:
- return {
- major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Repurchase,
- };
- case PurchaseStatus.AbortingWithRefund:
+ case RefundGroupStatus.Done:
return {
- major: TransactionMajorState.Aborting,
- };
- case PurchaseStatus.Paying:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.Pay,
- };
- case PurchaseStatus.PayingReplay:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.RebindSession,
+ major: TransactionMajorState.Done,
};
- case PurchaseStatus.ProposalRefused:
+ case RefundGroupStatus.Failed:
return {
major: TransactionMajorState.Failed,
- minor: TransactionMinorState.Refused,
};
- case PurchaseStatus.QueryingAutoRefund:
+ case RefundGroupStatus.Pending:
return {
major: TransactionMajorState.Pending,
- minor: TransactionMinorState.AutoRefund,
- };
- case PurchaseStatus.QueryingRefund:
- return {
- major: TransactionMajorState.Pending,
- minor: TransactionMinorState.CheckRefunds,
- };
+ }
}
}