diff options
Diffstat (limited to 'packages/taler-wallet-core/src/operations')
6 files changed, 848 insertions, 1193 deletions
| diff --git a/packages/taler-wallet-core/src/operations/backup/export.ts b/packages/taler-wallet-core/src/operations/backup/export.ts index 68f8beb93..7b245a4eb 100644 --- a/packages/taler-wallet-core/src/operations/backup/export.ts +++ b/packages/taler-wallet-core/src/operations/backup/export.ts @@ -69,7 +69,6 @@ import {    DenominationRecord,    PurchaseStatus,    RefreshCoinStatus, -  RefundState,    WithdrawalGroupStatus,    WithdrawalRecordType,  } from "../../db.js"; @@ -384,34 +383,34 @@ export async function exportBackup(        await tx.purchases.iter().forEachAsync(async (purch) => {          const refunds: BackupRefundItem[] = [];          purchaseProposalIdSet.add(purch.proposalId); -        for (const refundKey of Object.keys(purch.refunds)) { -          const ri = purch.refunds[refundKey]; -          const common = { -            coin_pub: ri.coinPub, -            execution_time: ri.executionTime, -            obtained_time: ri.obtainedTime, -            refund_amount: Amounts.stringify(ri.refundAmount), -            rtransaction_id: ri.rtransactionId, -            total_refresh_cost_bound: Amounts.stringify( -              ri.totalRefreshCostBound, -            ), -          }; -          switch (ri.type) { -            case RefundState.Applied: -              refunds.push({ type: BackupRefundState.Applied, ...common }); -              break; -            case RefundState.Failed: -              refunds.push({ type: BackupRefundState.Failed, ...common }); -              break; -            case RefundState.Pending: -              refunds.push({ type: BackupRefundState.Pending, ...common }); -              break; -          } -        } +        // for (const refundKey of Object.keys(purch.refunds)) { +        //   const ri = purch.refunds[refundKey]; +        //   const common = { +        //     coin_pub: ri.coinPub, +        //     execution_time: ri.executionTime, +        //     obtained_time: ri.obtainedTime, +        //     refund_amount: Amounts.stringify(ri.refundAmount), +        //     rtransaction_id: ri.rtransactionId, +        //     total_refresh_cost_bound: Amounts.stringify( +        //       ri.totalRefreshCostBound, +        //     ), +        //   }; +        //   switch (ri.type) { +        //     case RefundState.Applied: +        //       refunds.push({ type: BackupRefundState.Applied, ...common }); +        //       break; +        //     case RefundState.Failed: +        //       refunds.push({ type: BackupRefundState.Failed, ...common }); +        //       break; +        //     case RefundState.Pending: +        //       refunds.push({ type: BackupRefundState.Pending, ...common }); +        //       break; +        //   } +        // }          let propStatus: BackupProposalStatus;          switch (purch.purchaseStatus) { -          case PurchaseStatus.Paid: +          case PurchaseStatus.Done:            case PurchaseStatus.QueryingAutoRefund:            case PurchaseStatus.QueryingRefund:              propStatus = BackupProposalStatus.Paid; @@ -422,19 +421,19 @@ export async function exportBackup(            case PurchaseStatus.Paying:              propStatus = BackupProposalStatus.Proposed;              break; -          case PurchaseStatus.ProposalDownloadFailed: -          case PurchaseStatus.PaymentAbortFinished: +          case PurchaseStatus.FailedClaim: +          case PurchaseStatus.AbortedIncompletePayment:              propStatus = BackupProposalStatus.PermanentlyFailed;              break;            case PurchaseStatus.AbortingWithRefund: -          case PurchaseStatus.ProposalRefused: +          case PurchaseStatus.AbortedProposalRefused:              propStatus = BackupProposalStatus.Refused;              break;            case PurchaseStatus.RepurchaseDetected:              propStatus = BackupProposalStatus.Repurchase;              break;            default: { -            const error: never = purch.purchaseStatus; +            const error = purch.purchaseStatus;              throw Error(`purchase status ${error} is not handled`);            }          } diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 296517162..5375a58bb 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -49,9 +49,7 @@ import {    PurchasePayInfo,    RefreshCoinStatus,    RefreshSessionRecord, -  RefundState,    WalletContractData, -  WalletRefundItem,    WalletStoresV1,    WgInfo,    WithdrawalGroupStatus, @@ -65,7 +63,6 @@ import { GetReadOnlyAccess, GetReadWriteAccess } from "../../util/query.js";  import {    makeCoinAvailable,    makeTombstoneId, -  makeTransactionId,    TombstoneTag,  } from "../common.js";  import { getExchangeDetails } from "../exchanges.js"; @@ -576,16 +573,16 @@ export async function importBackup(          let proposalStatus: PurchaseStatus;          switch (backupPurchase.proposal_status) {            case BackupProposalStatus.Paid: -            proposalStatus = PurchaseStatus.Paid; +            proposalStatus = PurchaseStatus.Done;              break;            case BackupProposalStatus.Proposed:              proposalStatus = PurchaseStatus.Proposed;              break;            case BackupProposalStatus.PermanentlyFailed: -            proposalStatus = PurchaseStatus.PaymentAbortFinished; +            proposalStatus = PurchaseStatus.AbortedIncompletePayment;              break;            case BackupProposalStatus.Refused: -            proposalStatus = PurchaseStatus.ProposalRefused; +            proposalStatus = PurchaseStatus.AbortedProposalRefused;              break;            case BackupProposalStatus.Repurchase:              proposalStatus = PurchaseStatus.RepurchaseDetected; @@ -596,48 +593,48 @@ export async function importBackup(            }          }          if (!existingPurchase) { -          const refunds: { [refundKey: string]: WalletRefundItem } = {}; -          for (const backupRefund of backupPurchase.refunds) { -            const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`; -            const coin = await tx.coins.get(backupRefund.coin_pub); -            checkBackupInvariant(!!coin); -            const denom = await tx.denominations.get([ -              coin.exchangeBaseUrl, -              coin.denomPubHash, -            ]); -            checkBackupInvariant(!!denom); -            const common = { -              coinPub: backupRefund.coin_pub, -              executionTime: backupRefund.execution_time, -              obtainedTime: backupRefund.obtained_time, -              refundAmount: Amounts.stringify(backupRefund.refund_amount), -              refundFee: Amounts.stringify(denom.fees.feeRefund), -              rtransactionId: backupRefund.rtransaction_id, -              totalRefreshCostBound: Amounts.stringify( -                backupRefund.total_refresh_cost_bound, -              ), -            }; -            switch (backupRefund.type) { -              case BackupRefundState.Applied: -                refunds[key] = { -                  type: RefundState.Applied, -                  ...common, -                }; -                break; -              case BackupRefundState.Failed: -                refunds[key] = { -                  type: RefundState.Failed, -                  ...common, -                }; -                break; -              case BackupRefundState.Pending: -                refunds[key] = { -                  type: RefundState.Pending, -                  ...common, -                }; -                break; -            } -          } +          //const refunds: { [refundKey: string]: WalletRefundItem } = {}; +          // for (const backupRefund of backupPurchase.refunds) { +          //   const key = `${backupRefund.coin_pub}-${backupRefund.rtransaction_id}`; +          //   const coin = await tx.coins.get(backupRefund.coin_pub); +          //   checkBackupInvariant(!!coin); +          //   const denom = await tx.denominations.get([ +          //     coin.exchangeBaseUrl, +          //     coin.denomPubHash, +          //   ]); +          //   checkBackupInvariant(!!denom); +          //   const common = { +          //     coinPub: backupRefund.coin_pub, +          //     executionTime: backupRefund.execution_time, +          //     obtainedTime: backupRefund.obtained_time, +          //     refundAmount: Amounts.stringify(backupRefund.refund_amount), +          //     refundFee: Amounts.stringify(denom.fees.feeRefund), +          //     rtransactionId: backupRefund.rtransaction_id, +          //     totalRefreshCostBound: Amounts.stringify( +          //       backupRefund.total_refresh_cost_bound, +          //     ), +          //   }; +          //   switch (backupRefund.type) { +          //     case BackupRefundState.Applied: +          //       refunds[key] = { +          //         type: RefundState.Applied, +          //         ...common, +          //       }; +          //       break; +          //     case BackupRefundState.Failed: +          //       refunds[key] = { +          //         type: RefundState.Failed, +          //         ...common, +          //       }; +          //       break; +          //     case BackupRefundState.Pending: +          //       refunds[key] = { +          //         type: RefundState.Pending, +          //         ...common, +          //       }; +          //       break; +          //   } +          // }            const parsedContractTerms = codecForMerchantContractTerms().decode(              backupPurchase.contract_terms_raw,            ); @@ -694,7 +691,7 @@ export async function importBackup(              posConfirmation: backupPurchase.pos_confirmation,              lastSessionId: undefined,              download, -            refunds, +            //refunds,              claimToken: backupPurchase.claim_token,              downloadSessionId: backupPurchase.download_session_id,              merchantBaseUrl: backupPurchase.merchant_base_url, diff --git a/packages/taler-wallet-core/src/operations/pay-merchant.ts b/packages/taler-wallet-core/src/operations/pay-merchant.ts index 6aad1d742..99b9a18d2 100644 --- a/packages/taler-wallet-core/src/operations/pay-merchant.ts +++ b/packages/taler-wallet-core/src/operations/pay-merchant.ts @@ -58,19 +58,23 @@ import {    MerchantCoinRefundSuccessStatus,    MerchantContractTerms,    MerchantPayResponse, +  MerchantRefundResponse,    NotificationType,    parsePayUri,    parseRefundUri, +  parseTalerUri,    PayCoinSelection,    PreparePayResult,    PreparePayResultType,    PrepareRefundResult, +  randomBytes,    RefreshReason,    TalerError,    TalerErrorCode,    TalerErrorDetail,    TalerProtocolTimestamp,    TalerProtocolViolationError, +  TalerUriAction,    TransactionMajorState,    TransactionMinorState,    TransactionState, @@ -93,11 +97,16 @@ import {    PurchaseRecord,    PurchaseStatus,    RefundReason, -  RefundState,    WalletContractData,    WalletStoresV1,  } from "../db.js"; -import { GetReadWriteAccess, PendingTaskType } from "../index.js"; +import { +  PendingTaskType, +  RefundGroupRecord, +  RefundGroupStatus, +  RefundItemRecord, +  RefundItemStatus, +} from "../index.js";  import {    EXCHANGE_COINS_LOCK,    InternalWalletState, @@ -116,10 +125,19 @@ import {  } from "../util/retries.js";  import {    makeTransactionId, +  runLongpollAsync,    runOperationWithErrorReporting,    spendCoins,  } from "./common.js"; -import { createRefreshGroup, getTotalRefreshCost } from "./refresh.js"; +import { +  calculateRefreshOutput, +  createRefreshGroup, +  getTotalRefreshCost, +} from "./refresh.js"; +import { +  constructTransactionIdentifier, +  notifyTransition, +} from "./transactions.js";  /**   * Logger. @@ -193,7 +211,7 @@ async function failProposalPermanently(        if (!p) {          return;        } -      p.purchaseStatus = PurchaseStatus.ProposalDownloadFailed; +      p.purchaseStatus = PurchaseStatus.FailedClaim;        await tx.purchases.put(p);      });  } @@ -601,7 +619,6 @@ async function startDownloadProposal(      merchantPaySig: undefined,      payInfo: undefined,      refundAmountAwaiting: undefined, -    refunds: {},      timestampAccept: undefined,      timestampFirstSuccessfulPay: undefined,      timestampLastRefundStatus: undefined, @@ -649,7 +666,7 @@ async function storeFirstPaySuccess(          return;        }        if (purchase.purchaseStatus === PurchaseStatus.Paying) { -        purchase.purchaseStatus = PurchaseStatus.Paid; +        purchase.purchaseStatus = PurchaseStatus.Done;        }        purchase.timestampFirstSuccessfulPay = now;        purchase.lastSessionId = sessionId; @@ -701,7 +718,7 @@ async function storePayReplaySuccess(          purchase.purchaseStatus === PurchaseStatus.Paying ||          purchase.purchaseStatus === PurchaseStatus.PayingReplay        ) { -        purchase.purchaseStatus = PurchaseStatus.Paid; +        purchase.purchaseStatus = PurchaseStatus.Done;        }        purchase.lastSessionId = sessionId;        await tx.purchases.put(purchase); @@ -899,6 +916,11 @@ export async function checkPaymentByProposalId(    proposalId = proposal.proposalId; +  const transactionId = constructTransactionIdentifier({ +    tag: TransactionType.Payment, +    proposalId, +  }); +    const talerUri = constructPayUri(      proposal.merchantBaseUrl,      proposal.orderId, @@ -937,6 +959,7 @@ export async function checkPaymentByProposalId(          status: PreparePayResultType.InsufficientBalance,          contractTerms: d.contractTermsRaw,          proposalId: proposal.proposalId, +        transactionId,          noncePriv: proposal.noncePriv,          amountRaw: Amounts.stringify(d.contractData.amount),          talerUri, @@ -951,6 +974,7 @@ export async function checkPaymentByProposalId(      return {        status: PreparePayResultType.PaymentPossible,        contractTerms: d.contractTermsRaw, +      transactionId,        proposalId: proposal.proposalId,        noncePriv: proposal.noncePriv,        amountEffective: Amounts.stringify(totalCost), @@ -961,7 +985,7 @@ export async function checkPaymentByProposalId(    }    if ( -    purchase.purchaseStatus === PurchaseStatus.Paid && +    purchase.purchaseStatus === PurchaseStatus.Done &&      purchase.lastSessionId !== sessionId    ) {      logger.trace( @@ -992,6 +1016,7 @@ export async function checkPaymentByProposalId(        paid: true,        amountRaw: Amounts.stringify(download.contractData.amount),        amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), +      transactionId,        proposalId,        talerUri,      }; @@ -1004,12 +1029,13 @@ export async function checkPaymentByProposalId(        paid: false,        amountRaw: Amounts.stringify(download.contractData.amount),        amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!), +      transactionId,        proposalId,        talerUri,      };    } else {      const paid = -      purchase.purchaseStatus === PurchaseStatus.Paid || +      purchase.purchaseStatus === PurchaseStatus.Done ||        purchase.purchaseStatus === PurchaseStatus.QueryingRefund ||        purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund;      const download = await expectProposalDownload(ws, purchase); @@ -1021,6 +1047,7 @@ export async function checkPaymentByProposalId(        amountRaw: Amounts.stringify(download.contractData.amount),        amountEffective: Amounts.stringify(purchase.payInfo?.totalPayCost!),        ...(paid ? { nextUrl: download.contractData.orderId } : {}), +      transactionId,        proposalId,        talerUri,      }; @@ -1244,7 +1271,7 @@ export async function confirmPay(        ) {          logger.trace(`changing session ID to ${sessionIdOverride}`);          purchase.lastSessionId = sessionIdOverride; -        if (purchase.purchaseStatus === PurchaseStatus.Paid) { +        if (purchase.purchaseStatus === PurchaseStatus.Done) {            purchase.purchaseStatus = PurchaseStatus.PayingReplay;          }          await tx.purchases.put(purchase); @@ -1331,7 +1358,7 @@ export async function confirmPay(              refreshReason: RefreshReason.PayMerchant,            });            break; -        case PurchaseStatus.Paid: +        case PurchaseStatus.Done:          case PurchaseStatus.Paying:          default:            break; @@ -1371,20 +1398,24 @@ export async function processPurchase(    switch (purchase.purchaseStatus) {      case PurchaseStatus.DownloadingProposal: -      return processDownloadProposal(ws, proposalId, options); +      return processDownloadProposal(ws, proposalId);      case PurchaseStatus.Paying:      case PurchaseStatus.PayingReplay: -      return processPurchasePay(ws, proposalId, options); +      return processPurchasePay(ws, proposalId);      case PurchaseStatus.QueryingRefund: +      return processPurchaseQueryRefund(ws, purchase);      case PurchaseStatus.QueryingAutoRefund: +      return processPurchaseAutoRefund(ws, purchase);      case PurchaseStatus.AbortingWithRefund: -      return processPurchaseQueryRefund(ws, proposalId, options); -    case PurchaseStatus.ProposalDownloadFailed: -    case PurchaseStatus.Paid: +      return processPurchaseAbortingRefund(ws, purchase); +    case PurchaseStatus.PendingAcceptRefund: +      return processPurchaseAcceptRefund(ws, purchase); +    case PurchaseStatus.FailedClaim: +    case PurchaseStatus.Done:      case PurchaseStatus.RepurchaseDetected:      case PurchaseStatus.Proposed: -    case PurchaseStatus.ProposalRefused: -    case PurchaseStatus.PaymentAbortFinished: +    case PurchaseStatus.AbortedProposalRefused: +    case PurchaseStatus.AbortedIncompletePayment:        return {          type: OperationAttemptResultType.Finished,          result: undefined, @@ -1588,7 +1619,7 @@ export async function refuseProposal(        if (proposal.purchaseStatus !== PurchaseStatus.Proposed) {          return false;        } -      proposal.purchaseStatus = PurchaseStatus.ProposalRefused; +      proposal.purchaseStatus = PurchaseStatus.AbortedProposalRefused;        await tx.purchases.put(proposal);        return true;      }); @@ -1599,603 +1630,324 @@ export async function refuseProposal(    }  } -export async function prepareRefund( -  ws: InternalWalletState, -  talerRefundUri: string, -): Promise<PrepareRefundResult> { -  const parseResult = parseRefundUri(talerRefundUri); - -  logger.trace("preparing refund offer", parseResult); - -  if (!parseResult) { -    throw Error("invalid refund URI"); -  } - -  const purchase = await ws.db -    .mktx((x) => [x.purchases]) -    .runReadOnly(async (tx) => { -      return tx.purchases.indexes.byUrlAndOrderId.get([ -        parseResult.merchantBaseUrl, -        parseResult.orderId, -      ]); -    }); - -  if (!purchase) { -    throw Error( -      `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, -    ); -  } - -  const awaiting = await queryAndSaveAwaitingRefund(ws, purchase); -  const summary = await calculateRefundSummary(ws, purchase); -  const proposalId = purchase.proposalId; - -  const { contractData: c } = await expectProposalDownload(ws, purchase); - -  return { -    proposalId, -    effectivePaid: Amounts.stringify(summary.amountEffectivePaid), -    gone: Amounts.stringify(summary.amountRefundGone), -    granted: Amounts.stringify(summary.amountRefundGranted), -    pending: summary.pendingAtExchange, -    awaiting: Amounts.stringify(awaiting), -    info: { -      contractTermsHash: c.contractTermsHash, -      merchant: c.merchant, -      orderId: c.orderId, -      products: c.products, -      summary: c.summary, -      fulfillmentMessage: c.fulfillmentMessage, -      summary_i18n: c.summaryI18n, -      fulfillmentMessage_i18n: c.fulfillmentMessageI18n, -    }, -  }; -} - -function getRefundKey(d: MerchantCoinRefundStatus): string { -  return `${d.coin_pub}-${d.rtransaction_id}`; -} - -async function applySuccessfulRefund( -  tx: GetReadWriteAccess<{ -    coins: typeof WalletStoresV1.coins; -    denominations: typeof WalletStoresV1.denominations; -  }>, -  p: PurchaseRecord, -  refreshCoinsMap: Record<string, CoinRefreshRequest>, -  r: MerchantCoinRefundSuccessStatus, -  denomselAllowLate: boolean, -): Promise<void> { -  // FIXME: check signature before storing it as valid! - -  const refundKey = getRefundKey(r); -  const coin = await tx.coins.get(r.coin_pub); -  if (!coin) { -    logger.warn("coin not found, can't apply refund"); -    return; -  } -  const denom = await tx.denominations.get([ -    coin.exchangeBaseUrl, -    coin.denomPubHash, -  ]); -  if (!denom) { -    throw Error("inconsistent database"); -  } -  const refundAmount = Amounts.parseOrThrow(r.refund_amount); -  const refundFee = denom.fees.feeRefund; -  const amountLeft = Amounts.sub(refundAmount, refundFee).amount; -  coin.status = CoinStatus.Dormant; -  await tx.coins.put(coin); - -  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl -    .iter(coin.exchangeBaseUrl) -    .toArray(); -  const totalRefreshCostBound = getTotalRefreshCost( -    allDenoms, -    DenominationRecord.toDenomInfo(denom), -    amountLeft, -    denomselAllowLate, -  ); - -  refreshCoinsMap[coin.coinPub] = { -    coinPub: coin.coinPub, -    amount: Amounts.stringify(amountLeft), -  }; - -  p.refunds[refundKey] = { -    type: RefundState.Applied, -    obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), -    executionTime: r.execution_time, -    refundAmount: Amounts.stringify(r.refund_amount), -    refundFee: Amounts.stringify(denom.fees.feeRefund), -    totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), -    coinPub: r.coin_pub, -    rtransactionId: r.rtransaction_id, -  }; -} - -async function storePendingRefund( -  tx: GetReadWriteAccess<{ -    denominations: typeof WalletStoresV1.denominations; -    coins: typeof WalletStoresV1.coins; -  }>, -  p: PurchaseRecord, -  r: MerchantCoinRefundFailureStatus, -  denomselAllowLate: boolean, -): Promise<void> { -  const refundKey = getRefundKey(r); - -  const coin = await tx.coins.get(r.coin_pub); -  if (!coin) { -    logger.warn("coin not found, can't apply refund"); -    return; -  } -  const denom = await tx.denominations.get([ -    coin.exchangeBaseUrl, -    coin.denomPubHash, -  ]); - -  if (!denom) { -    throw Error("inconsistent database"); -  } - -  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl -    .iter(coin.exchangeBaseUrl) -    .toArray(); - -  // Refunded amount after fees. -  const amountLeft = Amounts.sub( -    Amounts.parseOrThrow(r.refund_amount), -    denom.fees.feeRefund, -  ).amount; - -  const totalRefreshCostBound = getTotalRefreshCost( -    allDenoms, -    DenominationRecord.toDenomInfo(denom), -    amountLeft, -    denomselAllowLate, -  ); - -  p.refunds[refundKey] = { -    type: RefundState.Pending, -    obtainedTime: AbsoluteTime.toTimestamp(AbsoluteTime.now()), -    executionTime: r.execution_time, -    refundAmount: Amounts.stringify(r.refund_amount), -    refundFee: Amounts.stringify(denom.fees.feeRefund), -    totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), -    coinPub: r.coin_pub, -    rtransactionId: r.rtransaction_id, -  }; -} - -async function storeFailedRefund( -  tx: GetReadWriteAccess<{ -    coins: typeof WalletStoresV1.coins; -    denominations: typeof WalletStoresV1.denominations; -  }>, -  p: PurchaseRecord, -  refreshCoinsMap: Record<string, CoinRefreshRequest>, -  r: MerchantCoinRefundFailureStatus, -  denomselAllowLate: boolean, -): Promise<void> { -  const refundKey = getRefundKey(r); - -  const coin = await tx.coins.get(r.coin_pub); -  if (!coin) { -    logger.warn("coin not found, can't apply refund"); -    return; -  } -  const denom = await tx.denominations.get([ -    coin.exchangeBaseUrl, -    coin.denomPubHash, -  ]); - -  if (!denom) { -    throw Error("inconsistent database"); -  } - -  const allDenoms = await tx.denominations.indexes.byExchangeBaseUrl -    .iter(coin.exchangeBaseUrl) -    .toArray(); - -  const amountLeft = Amounts.sub( -    Amounts.parseOrThrow(r.refund_amount), -    denom.fees.feeRefund, -  ).amount; - -  const totalRefreshCostBound = getTotalRefreshCost( -    allDenoms, -    DenominationRecord.toDenomInfo(denom), -    amountLeft, -    denomselAllowLate, -  ); - -  p.refunds[refundKey] = { -    type: RefundState.Failed, -    obtainedTime: TalerProtocolTimestamp.now(), -    executionTime: r.execution_time, -    refundAmount: Amounts.stringify(r.refund_amount), -    refundFee: Amounts.stringify(denom.fees.feeRefund), -    totalRefreshCostBound: Amounts.stringify(totalRefreshCostBound), -    coinPub: r.coin_pub, -    rtransactionId: r.rtransaction_id, -  }; - -  if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) { -    // Refund failed because the merchant didn't even try to deposit -    // the coin yet, so we try to refresh. -    // FIXME: Is this case tested?! -    if (r.exchange_code === TalerErrorCode.EXCHANGE_REFUND_DEPOSIT_NOT_FOUND) { -      const coin = await tx.coins.get(r.coin_pub); -      if (!coin) { -        logger.warn("coin not found, can't apply refund"); -        return; -      } -      const denom = await tx.denominations.get([ -        coin.exchangeBaseUrl, -        coin.denomPubHash, -      ]); -      if (!denom) { -        logger.warn("denomination for coin missing"); -        return; -      } -      const payCoinSelection = p.payInfo?.payCoinSelection; -      if (!payCoinSelection) { -        logger.warn("no pay coin selection, can't apply refund"); -        return; -      } -      let contrib: AmountJson | undefined; -      for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { -        if (payCoinSelection.coinPubs[i] === r.coin_pub) { -          contrib = Amounts.parseOrThrow(payCoinSelection.coinContributions[i]); -        } -      } -      // FIXME: Is this case tested?! -      refreshCoinsMap[coin.coinPub] = { -        coinPub: coin.coinPub, -        amount: Amounts.stringify(amountLeft), -      }; -      await tx.coins.put(coin); -    } -  } -} - -async function acceptRefunds( +export async function abortPayMerchant(    ws: InternalWalletState,    proposalId: string, -  refunds: MerchantCoinRefundStatus[], -  reason: RefundReason,  ): Promise<void> { -  logger.trace("handling refunds", refunds); -  const now = TalerProtocolTimestamp.now(); - +  const opId = constructTaskIdentifier({ +    tag: PendingTaskType.Purchase, +    proposalId, +  });    await ws.db      .mktx((x) => [        x.purchases, -      x.coins, -      x.coinAvailability, -      x.denominations,        x.refreshGroups, +      x.denominations, +      x.coinAvailability, +      x.coins, +      x.operationRetries,      ])      .runReadWrite(async (tx) => { -      const p = await tx.purchases.get(proposalId); -      if (!p) { -        logger.error("purchase not found, not adding refunds"); +      const purchase = await tx.purchases.get(proposalId); +      if (!purchase) { +        throw Error("purchase not found"); +      } +      const oldStatus = purchase.purchaseStatus; +      if (purchase.timestampFirstSuccessfulPay) { +        // No point in aborting it.  We don't even report an error. +        logger.warn(`tried to abort successful payment`);          return;        } - -      const refreshCoinsMap: Record<string, CoinRefreshRequest> = {}; -      for (const refundStatus of refunds) { -        const refundKey = getRefundKey(refundStatus); -        const existingRefundInfo = p.refunds[refundKey]; - -        const isPermanentFailure = -          refundStatus.type === "failure" && -          refundStatus.exchange_status >= 400 && -          refundStatus.exchange_status < 500; - -        // Already failed. -        if (existingRefundInfo?.type === RefundState.Failed) { -          continue; -        } - -        // Already applied. -        if (existingRefundInfo?.type === RefundState.Applied) { -          continue; -        } - -        // Still pending. -        if ( -          refundStatus.type === "failure" && -          !isPermanentFailure && -          existingRefundInfo?.type === RefundState.Pending -        ) { -          continue; -        } - -        // Invariant: (!existingRefundInfo) || (existingRefundInfo === Pending) - -        if (refundStatus.type === "success") { -          await applySuccessfulRefund( -            tx, -            p, -            refreshCoinsMap, -            refundStatus, -            ws.config.testing.denomselAllowLate, -          ); -        } else if (isPermanentFailure) { -          await storeFailedRefund( -            tx, -            p, -            refreshCoinsMap, -            refundStatus, -            ws.config.testing.denomselAllowLate, -          ); -        } else { -          await storePendingRefund( -            tx, -            p, -            refundStatus, -            ws.config.testing.denomselAllowLate, -          ); -        } +      if (oldStatus === PurchaseStatus.Paying) { +        purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund;        } - -      if (reason !== RefundReason.AbortRefund) { -        // For abort-refunds, the refresh group has already been -        // created before the refund was started. -        // For other refunds, we need to create it after we know -        // the amounts. -        const refreshCoinsPubs = Object.values(refreshCoinsMap); -        logger.info(`refreshCoinMap ${j2s(refreshCoinsMap)}`); -        if (refreshCoinsPubs.length > 0) { +      await tx.purchases.put(purchase); +      if (oldStatus === PurchaseStatus.Paying) { +        if (purchase.payInfo) { +          const coinSel = purchase.payInfo.payCoinSelection; +          const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost); +          const refreshCoins: CoinRefreshRequest[] = []; +          for (let i = 0; i < coinSel.coinPubs.length; i++) { +            refreshCoins.push({ +              amount: coinSel.coinContributions[i], +              coinPub: coinSel.coinPubs[i], +            }); +          }            await createRefreshGroup(              ws,              tx, -            Amounts.currencyOf(refreshCoinsPubs[0].amount), -            refreshCoinsPubs, -            RefreshReason.Refund, +            currency, +            refreshCoins, +            RefreshReason.AbortPay,            );          }        } - -      // Are we done with querying yet, or do we need to do another round -      // after a retry delay? -      let queryDone = true; - -      let numPendingRefunds = 0; -      for (const ri of Object.values(p.refunds)) { -        switch (ri.type) { -          case RefundState.Pending: -            numPendingRefunds++; -            break; -        } -      } - -      if (numPendingRefunds > 0) { -        queryDone = false; -      } - -      if (queryDone) { -        p.timestampLastRefundStatus = now; -        if (p.purchaseStatus === PurchaseStatus.AbortingWithRefund) { -          p.purchaseStatus = PurchaseStatus.PaymentAbortFinished; -        } else if (p.purchaseStatus === PurchaseStatus.QueryingAutoRefund) { -          const autoRefundDeadline = p.autoRefundDeadline; -          checkDbInvariant(!!autoRefundDeadline); -          if ( -            AbsoluteTime.isExpired( -              AbsoluteTime.fromTimestamp(autoRefundDeadline), -            ) -          ) { -            p.purchaseStatus = PurchaseStatus.Paid; -          } -        } else if (p.purchaseStatus === PurchaseStatus.QueryingRefund) { -          p.purchaseStatus = PurchaseStatus.Paid; -          p.refundAmountAwaiting = undefined; -        } -        logger.trace("refund query done"); -        ws.notify({ -          type: NotificationType.RefundFinished, -          transactionId: makeTransactionId( -            TransactionType.Payment, -            p.proposalId, -          ), -        }); -      } else { -        // No error, but we need to try again! -        p.timestampLastRefundStatus = now; -        logger.trace("refund query not done"); -      } - -      await tx.purchases.put(p); +      await tx.operationRetries.delete(opId);      }); -  ws.notify({ -    type: NotificationType.RefundQueried, -    transactionId: makeTransactionId(TransactionType.Payment, proposalId), -  }); +  ws.workAvailable.trigger(); +} + +export function computePayMerchantTransactionState( +  purchaseRecord: PurchaseRecord, +): TransactionState { +  switch (purchaseRecord.purchaseStatus) { +    case PurchaseStatus.DownloadingProposal: +      return { +        major: TransactionMajorState.Pending, +        minor: TransactionMinorState.ClaimProposal, +      }; +    case PurchaseStatus.Done: +      return { +        major: TransactionMajorState.Done, +      }; +    case PurchaseStatus.AbortedIncompletePayment: +      return { +        major: TransactionMajorState.Aborted, +      }; +    case PurchaseStatus.Proposed: +      return { +        major: TransactionMajorState.Dialog, +        minor: TransactionMinorState.MerchantOrderProposed, +      }; +    case PurchaseStatus.FailedClaim: +      return { +        major: TransactionMajorState.Failed, +        minor: TransactionMinorState.ClaimProposal, +      }; +    case PurchaseStatus.RepurchaseDetected: +      return { +        major: TransactionMajorState.Failed, +        minor: TransactionMinorState.Repurchase, +      }; +    case PurchaseStatus.AbortingWithRefund: +      return { +        major: TransactionMajorState.Aborting, +      }; +    case PurchaseStatus.Paying: +      return { +        major: TransactionMajorState.Pending, +        minor: TransactionMinorState.Pay, +      }; +    case PurchaseStatus.PayingReplay: +      return { +        major: TransactionMajorState.Pending, +        minor: TransactionMinorState.RebindSession, +      }; +    case PurchaseStatus.AbortedProposalRefused: +      return { +        major: TransactionMajorState.Failed, +        minor: TransactionMinorState.Refused, +      }; +    case PurchaseStatus.QueryingAutoRefund: +      return { +        major: TransactionMajorState.Pending, +        minor: TransactionMinorState.AutoRefund, +      }; +    case PurchaseStatus.QueryingRefund: +      return { +        major: TransactionMajorState.Pending, +        minor: TransactionMinorState.CheckRefunds, +      }; +    case PurchaseStatus.PendingAcceptRefund: +      return { +        major: TransactionMajorState.Pending, +        minor: TransactionMinorState.AcceptRefund, +      }; +  }  } -async function calculateRefundSummary( +async function processPurchaseAutoRefund(    ws: InternalWalletState, -  p: PurchaseRecord, -): Promise<RefundSummary> { -  const download = await expectProposalDownload(ws, p); -  let amountRefundGranted = Amounts.zeroOfAmount(download.contractData.amount); -  let amountRefundGone = Amounts.zeroOfAmount(download.contractData.amount); +  purchase: PurchaseRecord, +): Promise<OperationAttemptResult> { +  const proposalId = purchase.proposalId; +  logger.trace(`processing auto-refund for proposal ${proposalId}`); + +  const taskId = constructTaskIdentifier({ +    tag: PendingTaskType.Purchase, +    proposalId, +  }); -  let pendingAtExchange = false; +  const transactionId = constructTransactionIdentifier({ +    tag: TransactionType.Payment, +    proposalId, +  }); -  const payInfo = p.payInfo; -  if (!payInfo) { -    throw Error("can't calculate refund summary without payInfo"); +  // FIXME: Put this logic into runLongpollAsync? +  if (ws.activeLongpoll[taskId]) { +    return OperationAttemptResult.longpoll();    } -  Object.keys(p.refunds).forEach((rk) => { -    const refund = p.refunds[rk]; -    if (refund.type === RefundState.Pending) { -      pendingAtExchange = true; -    } +  const download = await expectProposalDownload(ws, purchase); + +  runLongpollAsync(ws, taskId, async (ct) => {      if ( -      refund.type === RefundState.Applied || -      refund.type === RefundState.Pending +      !purchase.autoRefundDeadline || +      AbsoluteTime.isExpired( +        AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), +      )      ) { -      amountRefundGranted = Amounts.add( -        amountRefundGranted, -        Amounts.sub( -          refund.refundAmount, -          refund.refundFee, -          refund.totalRefreshCostBound, -        ).amount, -      ).amount; -    } else { -      amountRefundGone = Amounts.add( -        amountRefundGone, -        refund.refundAmount, -      ).amount; +      const transitionInfo = await ws.db +        .mktx((x) => [x.purchases]) +        .runReadWrite(async (tx) => { +          const p = await tx.purchases.get(purchase.proposalId); +          if (!p) { +            logger.warn("purchase does not exist anymore"); +            return; +          } +          if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { +            return; +          } +          const oldTxState = computePayMerchantTransactionState(p); +          p.purchaseStatus = PurchaseStatus.Done; +          const newTxState = computePayMerchantTransactionState(p); +          await tx.purchases.put(p); +          return { oldTxState, newTxState }; +        }); +      notifyTransition(ws, transactionId, transitionInfo); +      return { +        ready: true, +      };      } -  }); -  return { -    amountEffectivePaid: Amounts.parseOrThrow(payInfo.totalPayCost), -    amountRefundGone, -    amountRefundGranted, -    pendingAtExchange, -  }; -} - -/** - * Summary of the refund status of a purchase. - */ -export interface RefundSummary { -  pendingAtExchange: boolean; -  amountEffectivePaid: AmountJson; -  amountRefundGranted: AmountJson; -  amountRefundGone: AmountJson; -} -/** - * Accept a refund, return the contract hash for the contract - * that was involved in the refund. - */ -export async function applyRefund( -  ws: InternalWalletState, -  talerRefundUri: string, -): Promise<ApplyRefundResponse> { -  const parseResult = parseRefundUri(talerRefundUri); +    const requestUrl = new URL( +      `orders/${download.contractData.orderId}`, +      download.contractData.merchantBaseUrl, +    ); +    requestUrl.searchParams.set( +      "h_contract", +      download.contractData.contractTermsHash, +    ); -  logger.trace("applying refund", parseResult); +    requestUrl.searchParams.set("timeout_ms", "1000"); +    requestUrl.searchParams.set("await_refund_obtained", "yes"); -  if (!parseResult) { -    throw Error("invalid refund URI"); -  } +    const resp = await ws.http.fetch(requestUrl.href); -  const purchase = await ws.db -    .mktx((x) => [x.purchases]) -    .runReadOnly(async (tx) => { -      return tx.purchases.indexes.byUrlAndOrderId.get([ -        parseResult.merchantBaseUrl, -        parseResult.orderId, -      ]); -    }); +    // FIXME: Check other status codes! -  if (!purchase) { -    throw Error( -      `no purchase for the taler://refund/ URI (${talerRefundUri}) was found`, +    const orderStatus = await readSuccessResponseJsonOrThrow( +      resp, +      codecForMerchantOrderStatusPaid(),      ); -  } -  return applyRefundFromPurchaseId(ws, purchase.proposalId); +    if (orderStatus.refund_pending) { +      const transitionInfo = await ws.db +        .mktx((x) => [x.purchases]) +        .runReadWrite(async (tx) => { +          const p = await tx.purchases.get(purchase.proposalId); +          if (!p) { +            logger.warn("purchase does not exist anymore"); +            return; +          } +          if (p.purchaseStatus !== PurchaseStatus.QueryingAutoRefund) { +            return; +          } +          const oldTxState = computePayMerchantTransactionState(p); +          p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; +          const newTxState = computePayMerchantTransactionState(p); +          await tx.purchases.put(p); +          return { oldTxState, newTxState }; +        }); +      notifyTransition(ws, transactionId, transitionInfo); +      return { +        ready: true, +      }; +    } else { +      return { +        ready: false, +      }; +    } +  }); + +  return OperationAttemptResult.longpoll();  } -export async function applyRefundFromPurchaseId( +async function processPurchaseAbortingRefund(    ws: InternalWalletState, -  proposalId: string, -): Promise<ApplyRefundResponse> { -  logger.trace("applying refund for purchase", proposalId); +  purchase: PurchaseRecord, +): Promise<OperationAttemptResult> { +  const proposalId = purchase.proposalId; +  const download = await expectProposalDownload(ws, purchase); +  logger.trace(`processing aborting-refund for proposal ${proposalId}`); -  logger.info("processing purchase for refund"); -  const success = await ws.db -    .mktx((x) => [x.purchases]) -    .runReadWrite(async (tx) => { -      const p = await tx.purchases.get(proposalId); -      if (!p) { -        logger.error("no purchase found for refund URL"); -        return false; -      } -      if (p.purchaseStatus === PurchaseStatus.Paid) { -        p.purchaseStatus = PurchaseStatus.QueryingRefund; -      } -      await tx.purchases.put(p); -      return true; -    }); +  const requestUrl = new URL( +    `orders/${download.contractData.orderId}/abort`, +    download.contractData.merchantBaseUrl, +  ); -  if (success) { -    ws.notify({ -      type: NotificationType.RefundStarted, -    }); -    await processPurchaseQueryRefund(ws, proposalId, { -      forceNow: true, -      waitForAutoRefund: false, -    }); +  const abortingCoins: AbortingCoin[] = []; + +  const payCoinSelection = purchase.payInfo?.payCoinSelection; +  if (!payCoinSelection) { +    throw Error("can't abort, no coins selected");    } -  const purchase = await ws.db -    .mktx((x) => [x.purchases]) +  await ws.db +    .mktx((x) => [x.coins])      .runReadOnly(async (tx) => { -      return tx.purchases.get(proposalId); +      for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { +        const coinPub = payCoinSelection.coinPubs[i]; +        const coin = await tx.coins.get(coinPub); +        checkDbInvariant(!!coin, "expected coin to be present"); +        abortingCoins.push({ +          coin_pub: coinPub, +          contribution: Amounts.stringify( +            payCoinSelection.coinContributions[i], +          ), +          exchange_url: coin.exchangeBaseUrl, +        }); +      }      }); -  if (!purchase) { -    throw Error("purchase no longer exists"); -  } +  const abortReq: AbortRequest = { +    h_contract: download.contractData.contractTermsHash, +    coins: abortingCoins, +  }; -  const summary = await calculateRefundSummary(ws, purchase); -  const download = await expectProposalDownload(ws, purchase); +  logger.trace(`making order abort request to ${requestUrl.href}`); -  const lastExec = Object.values(purchase.refunds).reduce( -    (prev, cur) => { -      return TalerProtocolTimestamp.max(cur.executionTime, prev); -    }, -    { t_s: 0 } as TalerProtocolTimestamp, +  const request = await ws.http.postJson(requestUrl.href, abortReq); +  const abortResp = await readSuccessResponseJsonOrThrow( +    request, +    codecForAbortResponse(),    ); -  const transactionId = -    lastExec.t_s === "never" || lastExec.t_s === 0 -      ? makeTransactionId(TransactionType.Payment, proposalId) -      : makeTransactionId( -          TransactionType.Refund, -          proposalId, -          String(lastExec.t_s), -        ); +  const refunds: MerchantCoinRefundStatus[] = []; -  return { -    contractTermsHash: download.contractData.contractTermsHash, -    proposalId: purchase.proposalId, -    transactionId, -    amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid), -    amountRefundGone: Amounts.stringify(summary.amountRefundGone), -    amountRefundGranted: Amounts.stringify(summary.amountRefundGranted), -    pendingAtExchange: summary.pendingAtExchange, -    info: { -      contractTermsHash: download.contractData.contractTermsHash, -      merchant: download.contractData.merchant, -      orderId: download.contractData.orderId, -      products: download.contractData.products, -      summary: download.contractData.summary, -      fulfillmentMessage: download.contractData.fulfillmentMessage, -      summary_i18n: download.contractData.summaryI18n, -      fulfillmentMessage_i18n: download.contractData.fulfillmentMessageI18n, -    }, -  }; +  if (abortResp.refunds.length != abortingCoins.length) { +    // FIXME: define error code! +    throw Error("invalid order abort response"); +  } + +  for (let i = 0; i < abortResp.refunds.length; i++) { +    const r = abortResp.refunds[i]; +    refunds.push({ +      ...r, +      coin_pub: payCoinSelection.coinPubs[i], +      refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), +      rtransaction_id: 0, +      execution_time: AbsoluteTime.toTimestamp( +        AbsoluteTime.addDuration( +          AbsoluteTime.fromTimestamp(download.contractData.timestamp), +          Duration.fromSpec({ seconds: 1 }), +        ), +      ), +    }); +  } +  return await storeRefunds(ws, purchase, refunds, RefundReason.AbortRefund);  } -async function queryAndSaveAwaitingRefund( +async function processPurchaseQueryRefund(    ws: InternalWalletState,    purchase: PurchaseRecord, -  waitForAutoRefund?: boolean, -): Promise<AmountJson> { +): Promise<OperationAttemptResult> { +  const proposalId = purchase.proposalId; +  logger.trace(`processing query-refund for proposal ${proposalId}`); +    const download = await expectProposalDownload(ws, purchase); +    const requestUrl = new URL(      `orders/${download.contractData.orderId}`,      download.contractData.merchantBaseUrl, @@ -2204,32 +1956,45 @@ async function queryAndSaveAwaitingRefund(      "h_contract",      download.contractData.contractTermsHash,    ); -  // Long-poll for one second -  if (waitForAutoRefund) { -    requestUrl.searchParams.set("timeout_ms", "1000"); -    requestUrl.searchParams.set("await_refund_obtained", "yes"); -    logger.trace("making long-polling request for auto-refund"); -  } -  const resp = await ws.http.get(requestUrl.href); + +  const resp = await ws.http.fetch(requestUrl.href);    const orderStatus = await readSuccessResponseJsonOrThrow(      resp,      codecForMerchantOrderStatusPaid(),    ); -  if (!orderStatus.refunded) { -    // Wait for retry ... -    return Amounts.zeroOfAmount(download.contractData.amount); -  } -  const refundAwaiting = Amounts.sub( -    Amounts.parseOrThrow(orderStatus.refund_amount), -    Amounts.parseOrThrow(orderStatus.refund_taken), -  ).amount; +  const transactionId = constructTransactionIdentifier({ +    tag: TransactionType.Payment, +    proposalId, +  }); -  if ( -    purchase.refundAmountAwaiting === undefined || -    Amounts.cmp(refundAwaiting, purchase.refundAmountAwaiting) !== 0 -  ) { -    await ws.db +  if (!orderStatus.refund_pending) { +    const transitionInfo = await ws.db +      .mktx((x) => [x.purchases]) +      .runReadWrite(async (tx) => { +        const p = await tx.purchases.get(purchase.proposalId); +        if (!p) { +          logger.warn("purchase does not exist anymore"); +          return undefined; +        } +        if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { +          return undefined; +        } +        const oldTxState = computePayMerchantTransactionState(p); +        p.purchaseStatus = PurchaseStatus.Done; +        const newTxState = computePayMerchantTransactionState(p); +        await tx.purchases.put(p); +        return { oldTxState, newTxState }; +      }); +    notifyTransition(ws, transactionId, transitionInfo); +    return OperationAttemptResult.finishedEmpty(); +  } else { +    const refundAwaiting = Amounts.sub( +      Amounts.parseOrThrow(orderStatus.refund_amount), +      Amounts.parseOrThrow(orderStatus.refund_taken), +    ).amount; + +    const transitionInfo = await ws.db        .mktx((x) => [x.purchases])        .runReadWrite(async (tx) => {          const p = await tx.purchases.get(purchase.proposalId); @@ -2237,304 +2002,359 @@ async function queryAndSaveAwaitingRefund(            logger.warn("purchase does not exist anymore");            return;          } +        if (p.purchaseStatus !== PurchaseStatus.QueryingRefund) { +          return; +        } +        const oldTxState = computePayMerchantTransactionState(p);          p.refundAmountAwaiting = Amounts.stringify(refundAwaiting); +        p.purchaseStatus = PurchaseStatus.PendingAcceptRefund; +        const newTxState = computePayMerchantTransactionState(p);          await tx.purchases.put(p); +        return { oldTxState, newTxState };        }); +    notifyTransition(ws, transactionId, transitionInfo); +    return OperationAttemptResult.finishedEmpty();    } - -  return refundAwaiting;  } -export async function processPurchaseQueryRefund( +async function processPurchaseAcceptRefund(    ws: InternalWalletState, -  proposalId: string, -  options: { -    forceNow?: boolean; -    waitForAutoRefund?: boolean; -  } = {}, +  purchase: PurchaseRecord,  ): Promise<OperationAttemptResult> { -  logger.trace(`processing refund query for proposal ${proposalId}`); -  const waitForAutoRefund = options.waitForAutoRefund ?? false; -  const purchase = await ws.db -    .mktx((x) => [x.purchases]) -    .runReadOnly(async (tx) => { -      return tx.purchases.get(proposalId); -    }); -  if (!purchase) { -    return OperationAttemptResult.finishedEmpty(); -  } - -  if ( -    !( -      purchase.purchaseStatus === PurchaseStatus.QueryingAutoRefund || -      purchase.purchaseStatus === PurchaseStatus.QueryingRefund || -      purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund -    ) -  ) { -    return OperationAttemptResult.finishedEmpty(); -  } +  const proposalId = purchase.proposalId;    const download = await expectProposalDownload(ws, purchase); -  if (purchase.timestampFirstSuccessfulPay) { -    if ( -      !purchase.autoRefundDeadline || -      !AbsoluteTime.isExpired( -        AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), -      ) -    ) { -      const awaitingAmount = await queryAndSaveAwaitingRefund( -        ws, -        purchase, -        waitForAutoRefund, -      ); -      if (Amounts.isZero(awaitingAmount)) { -        // Maybe the user wanted to check for refund to find out -        // that there is no refund pending from merchant -        await ws.db -          .mktx((x) => [x.purchases]) -          .runReadWrite(async (tx) => { -            const p = await tx.purchases.get(proposalId); -            if (!p) { -              logger.warn("purchase does not exist anymore"); -              return; -            } -            p.purchaseStatus = PurchaseStatus.Paid; -            await tx.purchases.put(p); -          }); - -        // No new refunds, but we still need to notify -        // the wallet client that the query finished. -        ws.notify({ -          type: NotificationType.RefundQueried, -          transactionId: makeTransactionId(TransactionType.Payment, proposalId), -        }); +  const requestUrl = new URL( +    `orders/${download.contractData.orderId}/refund`, +    download.contractData.merchantBaseUrl, +  ); -        return OperationAttemptResult.finishedEmpty(); -      } -    } +  logger.trace(`making refund request to ${requestUrl.href}`); -    const requestUrl = new URL( -      `orders/${download.contractData.orderId}/refund`, -      download.contractData.merchantBaseUrl, -    ); +  const request = await ws.http.postJson(requestUrl.href, { +    h_contract: download.contractData.contractTermsHash, +  }); -    logger.trace(`making refund request to ${requestUrl.href}`); +  const refundResponse = await readSuccessResponseJsonOrThrow( +    request, +    codecForMerchantOrderRefundPickupResponse(), +  ); +  return await storeRefunds( +    ws, +    purchase, +    refundResponse.refunds, +    RefundReason.AbortRefund, +  ); +} -    const request = await ws.http.postJson(requestUrl.href, { -      h_contract: download.contractData.contractTermsHash, +export async function startRefundQueryForUri( +  ws: InternalWalletState, +  talerUri: string, +): Promise<void> { +  const parsedUri = parseTalerUri(talerUri); +  if (!parsedUri) { +    throw Error("invalid taler:// URI"); +  } +  if (parsedUri.type !== TalerUriAction.Refund) { +    throw Error("expected taler://refund URI"); +  } +  const purchaseRecord = await ws.db +    .mktx((x) => [x.purchases]) +    .runReadOnly(async (tx) => { +      return tx.purchases.indexes.byUrlAndOrderId.get([ +        parsedUri.merchantBaseUrl, +        parsedUri.orderId, +      ]);      }); - -    const refundResponse = await readSuccessResponseJsonOrThrow( -      request, -      codecForMerchantOrderRefundPickupResponse(), -    ); - -    await acceptRefunds( -      ws, -      proposalId, -      refundResponse.refunds, -      RefundReason.NormalRefund, -    ); -  } else if (purchase.purchaseStatus === PurchaseStatus.AbortingWithRefund) { -    const requestUrl = new URL( -      `orders/${download.contractData.orderId}/abort`, -      download.contractData.merchantBaseUrl, -    ); - -    const abortingCoins: AbortingCoin[] = []; - -    const payCoinSelection = purchase.payInfo?.payCoinSelection; -    if (!payCoinSelection) { -      throw Error("can't abort, no coins selected"); -    } - -    await ws.db -      .mktx((x) => [x.coins]) -      .runReadOnly(async (tx) => { -        for (let i = 0; i < payCoinSelection.coinPubs.length; i++) { -          const coinPub = payCoinSelection.coinPubs[i]; -          const coin = await tx.coins.get(coinPub); -          checkDbInvariant(!!coin, "expected coin to be present"); -          abortingCoins.push({ -            coin_pub: coinPub, -            contribution: Amounts.stringify( -              payCoinSelection.coinContributions[i], -            ), -            exchange_url: coin.exchangeBaseUrl, -          }); -        } -      }); - -    const abortReq: AbortRequest = { -      h_contract: download.contractData.contractTermsHash, -      coins: abortingCoins, -    }; - -    logger.trace(`making order abort request to ${requestUrl.href}`); - -    const request = await ws.http.postJson(requestUrl.href, abortReq); -    const abortResp = await readSuccessResponseJsonOrThrow( -      request, -      codecForAbortResponse(), -    ); - -    const refunds: MerchantCoinRefundStatus[] = []; - -    if (abortResp.refunds.length != abortingCoins.length) { -      // FIXME: define error code! -      throw Error("invalid order abort response"); -    } - -    for (let i = 0; i < abortResp.refunds.length; i++) { -      const r = abortResp.refunds[i]; -      refunds.push({ -        ...r, -        coin_pub: payCoinSelection.coinPubs[i], -        refund_amount: Amounts.stringify(payCoinSelection.coinContributions[i]), -        rtransaction_id: 0, -        execution_time: AbsoluteTime.toTimestamp( -          AbsoluteTime.addDuration( -            AbsoluteTime.fromTimestamp(download.contractData.timestamp), -            Duration.fromSpec({ seconds: 1 }), -          ), -        ), -      }); -    } -    await acceptRefunds(ws, proposalId, refunds, RefundReason.AbortRefund); +  if (!purchaseRecord) { +    throw Error("no purchase found, can't refund");    } -  return OperationAttemptResult.finishedEmpty(); +  return startQueryRefund(ws, purchaseRecord.proposalId);  } -export async function abortPayMerchant( +export async function startQueryRefund(    ws: InternalWalletState,    proposalId: string, -  cancelImmediately?: boolean,  ): Promise<void> { -  const opId = constructTaskIdentifier({ -    tag: PendingTaskType.Purchase, +  const transactionId = constructTransactionIdentifier({ +    tag: TransactionType.Payment,      proposalId,    }); -  await ws.db +  const transitionInfo = await ws.db +    .mktx((x) => [x.purchases]) +    .runReadWrite(async (tx) => { +      const p = await tx.purchases.get(proposalId); +      if (!p) { +        logger.warn(`purchase ${proposalId} does not exist anymore`); +        return; +      } +      if (p.purchaseStatus !== PurchaseStatus.Done) { +        return; +      } +      const oldTxState = computePayMerchantTransactionState(p); +      p.purchaseStatus = PurchaseStatus.QueryingRefund; +      const newTxState = computePayMerchantTransactionState(p); +      await tx.purchases.put(p); +      return { oldTxState, newTxState }; +    }); +  notifyTransition(ws, transactionId, transitionInfo); +  ws.workAvailable.trigger(); +} + +/** + * Store refunds, possibly creating a new refund group. + */ +async function storeRefunds( +  ws: InternalWalletState, +  purchase: PurchaseRecord, +  refunds: MerchantCoinRefundStatus[], +  reason: RefundReason, +): Promise<OperationAttemptResult> { +  logger.info(`storing refunds: ${j2s(refunds)}`); + +  const transactionId = constructTransactionIdentifier({ +    tag: TransactionType.Payment, +    proposalId: purchase.proposalId, +  }); + +  const newRefundGroupId = encodeCrock(randomBytes(32)); +  const now = TalerProtocolTimestamp.now(); + +  const download = await expectProposalDownload(ws, purchase); +  const currency = Amounts.currencyOf(download.contractData.amount); + +  const getItemStatus = (rf: MerchantCoinRefundStatus) => { +    if (rf.type === "success") { +      return RefundItemStatus.Done; +    } else { +      if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { +        return RefundItemStatus.Pending; +      } else { +        return RefundItemStatus.Failed; +      } +    } +  }; + +  const result = await ws.db      .mktx((x) => [        x.purchases, -      x.refreshGroups, +      x.refundGroups, +      x.refundItems, +      x.coins,        x.denominations,        x.coinAvailability, -      x.coins, -      x.operationRetries, +      x.refreshGroups,      ])      .runReadWrite(async (tx) => { -      const purchase = await tx.purchases.get(proposalId); -      if (!purchase) { -        throw Error("purchase not found"); +      const computeRefreshRequest = async (items: RefundItemRecord[]) => { +        const refreshCoins: CoinRefreshRequest[] = []; +        for (const item of items) { +          const coin = await tx.coins.get(item.coinPub); +          if (!coin) { +            throw Error("coin not found"); +          } +          const denomInfo = await ws.getDenomInfo( +            ws, +            tx, +            coin.exchangeBaseUrl, +            coin.denomPubHash, +          ); +          if (!denomInfo) { +            throw Error("denom not found"); +          } +          if (item.status === RefundItemStatus.Done) { +            const refundedAmount = Amounts.sub( +              item.refundAmount, +              denomInfo.feeRefund, +            ).amount; +            refreshCoins.push({ +              amount: Amounts.stringify(refundedAmount), +              coinPub: item.coinPub, +            }); +          } +        } +        return refreshCoins; +      }; + +      const myPurchase = await tx.purchases.get(purchase.proposalId); +      if (!myPurchase) { +        logger.warn("purchase group not found anymore"); +        return;        } -      const oldStatus = purchase.purchaseStatus; -      if (purchase.timestampFirstSuccessfulPay) { -        // No point in aborting it.  We don't even report an error. -        logger.warn(`tried to abort successful payment`); +      if (myPurchase.purchaseStatus !== PurchaseStatus.PendingAcceptRefund) {          return;        } -      if (oldStatus === PurchaseStatus.Paying) { -        purchase.purchaseStatus = PurchaseStatus.AbortingWithRefund; + +      let newGroup: RefundGroupRecord | undefined = undefined; +      // Pending, but not part of an aborted refund group. +      let numPendingItemsTotal = 0; +      const newGroupRefunds: RefundItemRecord[] = []; + +      for (const rf of refunds) { +        const oldItem = await tx.refundItems.indexes.byCoinPubAndRtxid.get([ +          rf.coin_pub, +          rf.rtransaction_id, +        ]); +        if (oldItem) { +          logger.info("already have refund in database"); +          if (oldItem.status === RefundItemStatus.Done) { +            continue; +          } +          if (rf.type === "success") { +            oldItem.status = RefundItemStatus.Done; +          } else { +            if (rf.exchange_status >= 500 && rf.exchange_status <= 599) { +              oldItem.status = RefundItemStatus.Pending; +              numPendingItemsTotal += 1; +            } else { +              oldItem.status = RefundItemStatus.Failed; +            } +          } +          await tx.refundItems.put(oldItem); +        } else { +          // Put refund item into a new group! +          if (!newGroup) { +            newGroup = { +              proposalId: purchase.proposalId, +              refundGroupId: newRefundGroupId, +              status: RefundGroupStatus.Pending, +              timestampCreated: now, +              amountEffective: Amounts.stringify( +                Amounts.zeroOfCurrency(currency), +              ), +              amountRaw: Amounts.stringify(Amounts.zeroOfCurrency(currency)), +            }; +          } +          const status: RefundItemStatus = getItemStatus(rf); +          const newItem: RefundItemRecord = { +            coinPub: rf.coin_pub, +            executionTime: rf.execution_time, +            obtainedTime: now, +            refundAmount: rf.refund_amount, +            refundGroupId: newGroup.refundGroupId, +            rtxid: rf.rtransaction_id, +            status, +          }; +          if (status === RefundItemStatus.Pending) { +            numPendingItemsTotal += 1; +          } +          newGroupRefunds.push(newItem); +          await tx.refundItems.put(newItem); +        }        } -      if ( -        cancelImmediately && -        oldStatus === PurchaseStatus.AbortingWithRefund -      ) { -        purchase.purchaseStatus = PurchaseStatus.PaymentAbortFinished; + +      // Now that we know all the refunds for the new refund group, +      // we can compute the raw/effective amounts. +      if (newGroup) { +        const amountsRaw = newGroupRefunds.map((x) => x.refundAmount); +        const refreshCoins = await computeRefreshRequest(newGroupRefunds); +        const outInfo = await calculateRefreshOutput( +          ws, +          tx, +          currency, +          refreshCoins, +        ); +        newGroup.amountEffective = Amounts.stringify( +          Amounts.sumOrZero(currency, outInfo.outputPerCoin).amount, +        ); +        newGroup.amountRaw = Amounts.stringify( +          Amounts.sumOrZero(currency, amountsRaw).amount, +        ); +        await tx.refundGroups.put(newGroup);        } -      await tx.purchases.put(purchase); -      if (oldStatus === PurchaseStatus.Paying) { -        if (purchase.payInfo) { -          const coinSel = purchase.payInfo.payCoinSelection; -          const currency = Amounts.currencyOf(purchase.payInfo.totalPayCost); -          const refreshCoins: CoinRefreshRequest[] = []; -          for (let i = 0; i < coinSel.coinPubs.length; i++) { -            refreshCoins.push({ -              amount: coinSel.coinContributions[i], -              coinPub: coinSel.coinPubs[i], -            }); + +      const refundGroups = await tx.refundGroups.indexes.byProposalId.getAll( +        myPurchase.proposalId, +      ); + +      logger.info( +        `refund groups for proposal ${myPurchase.proposalId}: ${j2s( +          refundGroups, +        )}`, +      ); + +      for (const refundGroup of refundGroups) { +        if (refundGroup.status === RefundGroupStatus.Aborted) { +          continue; +        } +        if (refundGroup.status === RefundGroupStatus.Done) { +          continue; +        } +        const items = await tx.refundItems.indexes.byRefundGroupId.getAll( +          refundGroup.refundGroupId, +        ); +        let numPending = 0; +        for (const item of items) { +          if (item.status === RefundItemStatus.Pending) { +            numPending++;            } +        } +        logger.info(`refund items pending for refund group: ${numPending}`); +        if (numPending === 0) { +          logger.info("refund group is done!"); +          // We're done for this refund group! +          refundGroup.status = RefundGroupStatus.Done; +          await tx.refundGroups.put(refundGroup); +          const refreshCoins = await computeRefreshRequest(items);            await createRefreshGroup(              ws,              tx, -            currency, +            Amounts.currencyOf(download.contractData.amount),              refreshCoins, -            RefreshReason.AbortPay, +            RefreshReason.Refund,            );          }        } -      await tx.operationRetries.delete(opId); -    }); -  runOperationWithErrorReporting(ws, opId, async () => { -    return await processPurchaseQueryRefund(ws, proposalId, { -      forceNow: true, +      const oldTxState = computePayMerchantTransactionState(myPurchase); +      if (numPendingItemsTotal === 0) { +        myPurchase.purchaseStatus = PurchaseStatus.Done; +      } +      await tx.purchases.put(myPurchase); +      const newTxState = computePayMerchantTransactionState(myPurchase); + +      return { +        numPendingItemsTotal, +        transitionInfo: { +          oldTxState, +          newTxState, +        }, +      };      }); -  }); + +  if (!result) { +    return OperationAttemptResult.finishedEmpty(); +  } + +  notifyTransition(ws, transactionId, result.transitionInfo); + +  if (result.numPendingItemsTotal > 0) { +    return OperationAttemptResult.pendingEmpty(); +  } + +  return OperationAttemptResult.finishedEmpty();  } -export function computePayMerchantTransactionState( -  purchaseRecord: PurchaseRecord, +export function computeRefundTransactionState( +  refundGroupRecord: RefundGroupRecord,  ): TransactionState { -  switch (purchaseRecord.purchaseStatus) { -    case PurchaseStatus.DownloadingProposal: -      return { -        major: TransactionMajorState.Pending, -        minor: TransactionMinorState.ClaimProposal, -      }; -    case PurchaseStatus.Paid: -      return { -        major: TransactionMajorState.Done, -      }; -    case PurchaseStatus.PaymentAbortFinished: +  switch (refundGroupRecord.status) { +    case RefundGroupStatus.Aborted:        return {          major: TransactionMajorState.Aborted,        }; -    case PurchaseStatus.Proposed: -      return { -        major: TransactionMajorState.Dialog, -        minor: TransactionMinorState.MerchantOrderProposed, -      }; -    case PurchaseStatus.ProposalDownloadFailed: -      return { -        major: TransactionMajorState.Failed, -        minor: TransactionMinorState.ClaimProposal, -      }; -    case PurchaseStatus.RepurchaseDetected: -      return { -        major: TransactionMajorState.Failed, -        minor: TransactionMinorState.Repurchase, -      }; -    case PurchaseStatus.AbortingWithRefund: +    case RefundGroupStatus.Done:        return { -        major: TransactionMajorState.Aborting, -      }; -    case PurchaseStatus.Paying: -      return { -        major: TransactionMajorState.Pending, -        minor: TransactionMinorState.Pay, -      }; -    case PurchaseStatus.PayingReplay: -      return { -        major: TransactionMajorState.Pending, -        minor: TransactionMinorState.RebindSession, +        major: TransactionMajorState.Done,        }; -    case PurchaseStatus.ProposalRefused: +    case RefundGroupStatus.Failed:        return {          major: TransactionMajorState.Failed, -        minor: TransactionMinorState.Refused,        }; -    case PurchaseStatus.QueryingAutoRefund: +    case RefundGroupStatus.Pending:        return {          major: TransactionMajorState.Pending, -        minor: TransactionMinorState.AutoRefund, -      }; -    case PurchaseStatus.QueryingRefund: -      return { -        major: TransactionMajorState.Pending, -        minor: TransactionMinorState.CheckRefunds, -      }; +      }    }  } diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index fda9a886a..843f37c8e 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -81,7 +81,7 @@ import {    readUnexpectedResponseDetails,  } from "@gnu-taler/taler-util/http";  import { checkDbInvariant } from "../util/invariants.js"; -import { GetReadWriteAccess } from "../util/query.js"; +import { GetReadOnlyAccess, GetReadWriteAccess } from "../util/query.js";  import {    constructTaskIdentifier,    OperationAttemptResult, @@ -874,18 +874,13 @@ async function processRefreshSession(    await refreshReveal(ws, refreshGroupId, coinIndex);  } -/** - * Create a refresh group for a list of coins. - * - * Refreshes the remaining amount on the coin, effectively capturing the remaining - * value in the refresh group. - * - * The caller must also ensure that the coins that should be refreshed exist - * in the current database transaction. - */ -export async function createRefreshGroup( +export interface RefreshOutputInfo { +  outputPerCoin: AmountJson[]; +} + +export async function calculateRefreshOutput(    ws: InternalWalletState, -  tx: GetReadWriteAccess<{ +  tx: GetReadOnlyAccess<{      denominations: typeof WalletStoresV1.denominations;      coins: typeof WalletStoresV1.coins;      refreshGroups: typeof WalletStoresV1.refreshGroups; @@ -893,12 +888,7 @@ export async function createRefreshGroup(    }>,    currency: string,    oldCoinPubs: CoinRefreshRequest[], -  reason: RefreshReason, -  reasonDetails?: RefreshReasonDetails, -): Promise<RefreshGroupId> { -  const refreshGroupId = encodeCrock(getRandomBytes(32)); - -  const inputPerCoin: AmountJson[] = []; +): Promise<RefreshOutputInfo> {    const estimatedOutputPerCoin: AmountJson[] = [];    const denomsPerExchange: Record<string, DenominationRecord[]> = {}; @@ -931,6 +921,47 @@ export async function createRefreshGroup(        !!denom,        "denomination for existing coin must be in database",      ); +    const refreshAmount = ocp.amount; +    const denoms = await getDenoms(coin.exchangeBaseUrl); +    const cost = getTotalRefreshCost( +      denoms, +      denom, +      Amounts.parseOrThrow(refreshAmount), +      ws.config.testing.denomselAllowLate, +    ); +    const output = Amounts.sub(refreshAmount, cost).amount; +    estimatedOutputPerCoin.push(output); +  } + +  return { +    outputPerCoin: estimatedOutputPerCoin, +  } +} + +async function applyRefresh( +  ws: InternalWalletState, +  tx: GetReadWriteAccess<{ +    denominations: typeof WalletStoresV1.denominations; +    coins: typeof WalletStoresV1.coins; +    refreshGroups: typeof WalletStoresV1.refreshGroups; +    coinAvailability: typeof WalletStoresV1.coinAvailability; +  }>, +  oldCoinPubs: CoinRefreshRequest[], +  refreshGroupId: string, +): Promise<void> { +  for (const ocp of oldCoinPubs) { +    const coin = await tx.coins.get(ocp.coinPub); +    checkDbInvariant(!!coin, "coin must be in database"); +    const denom = await ws.getDenomInfo( +      ws, +      tx, +      coin.exchangeBaseUrl, +      coin.denomPubHash, +    ); +    checkDbInvariant( +      !!denom, +      "denomination for existing coin must be in database", +    );      switch (coin.status) {        case CoinStatus.Dormant:          break; @@ -962,19 +993,39 @@ export async function createRefreshGroup(          id: `txn:refresh:${refreshGroupId}`,        };      } -    const refreshAmount = ocp.amount; -    inputPerCoin.push(Amounts.parseOrThrow(refreshAmount));      await tx.coins.put(coin); -    const denoms = await getDenoms(coin.exchangeBaseUrl); -    const cost = getTotalRefreshCost( -      denoms, -      denom, -      Amounts.parseOrThrow(refreshAmount), -      ws.config.testing.denomselAllowLate, -    ); -    const output = Amounts.sub(refreshAmount, cost).amount; -    estimatedOutputPerCoin.push(output);    } +} + +/** + * Create a refresh group for a list of coins. + * + * Refreshes the remaining amount on the coin, effectively capturing the remaining + * value in the refresh group. + * + * The caller must also ensure that the coins that should be refreshed exist + * in the current database transaction. + */ +export async function createRefreshGroup( +  ws: InternalWalletState, +  tx: GetReadWriteAccess<{ +    denominations: typeof WalletStoresV1.denominations; +    coins: typeof WalletStoresV1.coins; +    refreshGroups: typeof WalletStoresV1.refreshGroups; +    coinAvailability: typeof WalletStoresV1.coinAvailability; +  }>, +  currency: string, +  oldCoinPubs: CoinRefreshRequest[], +  reason: RefreshReason, +  reasonDetails?: RefreshReasonDetails, +): Promise<RefreshGroupId> { +  const refreshGroupId = encodeCrock(getRandomBytes(32)); + +  const outInfo = await calculateRefreshOutput(ws, tx, currency, oldCoinPubs); + +  const estimatedOutputPerCoin = outInfo.outputPerCoin; + +  await applyRefresh(ws, tx, oldCoinPubs, refreshGroupId);    const refreshGroup: RefreshGroupRecord = {      operationStatus: RefreshOperationStatus.Pending, @@ -987,7 +1038,7 @@ export async function createRefreshGroup(      reason,      refreshGroupId,      refreshSessionPerCoin: oldCoinPubs.map(() => undefined), -    inputPerCoin: inputPerCoin.map((x) => Amounts.stringify(x)), +    inputPerCoin: oldCoinPubs.map((x) => x.amount),      estimatedOutputPerCoin: estimatedOutputPerCoin.map((x) =>        Amounts.stringify(x),      ), diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index 74cf7b4f2..8341d2f26 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -45,7 +45,7 @@ import {    PreparePayResultType,  } from "@gnu-taler/taler-util";  import { InternalWalletState } from "../internal-wallet-state.js"; -import { applyRefund, confirmPay, preparePayForUri } from "./pay-merchant.js"; +import { confirmPay, preparePayForUri, startRefundQueryForUri } from "./pay-merchant.js";  import { getBalances } from "./balance.js";  import { checkLogicInvariant } from "../util/invariants.js";  import { acceptWithdrawalFromUri } from "./withdraw.js"; @@ -416,7 +416,7 @@ export async function runIntegrationTest(    logger.trace("refund URI", refundUri); -  await applyRefund(ws, refundUri); +  await startRefundQueryForUri(ws, refundUri);    logger.trace("integration test: applied refund"); @@ -512,7 +512,7 @@ export async function runIntegrationTest2(    logger.trace("refund URI", refundUri); -  await applyRefund(ws, refundUri); +  await startRefundQueryForUri(ws, refundUri);    logger.trace("integration test: applied refund"); diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 02f11d82d..d9778f0c2 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -19,7 +19,6 @@   */  import {    AbsoluteTime, -  AmountJson,    Amounts,    constructPayPullUri,    constructPayPushUri, @@ -51,9 +50,7 @@ import {    PeerPushPaymentInitiationRecord,    PurchaseStatus,    PurchaseRecord, -  RefundState,    TipRecord, -  WalletRefundItem,    WithdrawalGroupRecord,    WithdrawalRecordType,    WalletContractData, @@ -66,6 +63,7 @@ import {    PeerPushPaymentIncomingRecord,    PeerPushPaymentIncomingStatus,    PeerPullPaymentInitiationRecord, +  RefundGroupRecord,  } from "../db.js";  import { InternalWalletState } from "../internal-wallet-state.js";  import { PendingTaskType } from "../pending-types.js"; @@ -89,6 +87,7 @@ import { getExchangeDetails } from "./exchanges.js";  import {    abortPayMerchant,    computePayMerchantTransactionState, +  computeRefundTransactionState,    expectProposalDownload,    extractContractData,    processPurchasePay, @@ -205,40 +204,15 @@ export async function getTransactionById(        .runReadWrite(async (tx) => {          const purchase = await tx.purchases.get(proposalId);          if (!purchase) throw Error("not found"); - -        const filteredRefunds = await Promise.all( -          Object.values(purchase.refunds).map(async (r) => { -            const t = await tx.tombstones.get( -              makeTombstoneId( -                TombstoneTag.DeleteRefund, -                purchase.proposalId, -                `${r.executionTime.t_s}`, -              ), -            ); -            if (!t) return r; -            return undefined; -          }), -        ); -          const download = await expectProposalDownload(ws, purchase, tx); - -        const cleanRefunds = filteredRefunds.filter( -          (x): x is WalletRefundItem => !!x, -        ); -          const contractData = download.contractData; -        const refunds = mergeRefundByExecutionTime( -          cleanRefunds, -          Amounts.zeroOfAmount(contractData.amount), -        ); -          const payOpId = TaskIdentifiers.forPay(purchase);          const payRetryRecord = await tx.operationRetries.get(payOpId);          return buildTransactionForPurchase(            purchase,            contractData, -          refunds, +          [], // FIXME: Add refunds from refund group records here.            payRetryRecord,          );        }); @@ -272,66 +246,8 @@ export async function getTransactionById(          return buildTransactionForDeposit(depositRecord, retries);        });    } else if (type === TransactionType.Refund) { -    const proposalId = rest[0]; -    const executionTimeStr = rest[1]; - -    return await ws.db -      .mktx((x) => [ -        x.operationRetries, -        x.purchases, -        x.tombstones, -        x.contractTerms, -      ]) -      .runReadWrite(async (tx) => { -        const purchase = await tx.purchases.get(proposalId); -        if (!purchase) throw Error("not found"); - -        const t = await tx.tombstones.get( -          makeTombstoneId( -            TombstoneTag.DeleteRefund, -            purchase.proposalId, -            executionTimeStr, -          ), -        ); -        if (t) throw Error("deleted"); - -        const filteredRefunds = await Promise.all( -          Object.values(purchase.refunds).map(async (r) => { -            const t = await tx.tombstones.get( -              makeTombstoneId( -                TombstoneTag.DeleteRefund, -                purchase.proposalId, -                `${r.executionTime.t_s}`, -              ), -            ); -            if (!t) return r; -            return undefined; -          }), -        ); - -        const cleanRefunds = filteredRefunds.filter( -          (x): x is WalletRefundItem => !!x, -        ); - -        const download = await expectProposalDownload(ws, purchase, tx); -        const contractData = download.contractData; -        const refunds = mergeRefundByExecutionTime( -          cleanRefunds, -          Amounts.zeroOfAmount(contractData.amount), -        ); - -        const theRefund = refunds.find( -          (r) => `${r.executionTime.t_s}` === executionTimeStr, -        ); -        if (!theRefund) throw Error("not found"); - -        return buildTransactionForRefund( -          purchase, -          contractData, -          theRefund, -          undefined, -        ); -      }); +    // FIXME! +    throw Error("not implemented");    } else if (type === TransactionType.PeerPullDebit) {      const peerPullPaymentIncomingId = rest[0];      return await ws.db @@ -730,6 +646,29 @@ function buildTransactionForManualWithdraw(    };  } +function buildTransactionForRefund( +  refundRecord: RefundGroupRecord, +): Transaction { +  return { +    type: TransactionType.Refund, +    amountEffective: refundRecord.amountEffective, +    amountRaw: refundRecord.amountEffective, +    refundedTransactionId: constructTransactionIdentifier({ +      tag: TransactionType.Payment, +      proposalId:  refundRecord.proposalId +    }), +    timestamp: refundRecord.timestampCreated, +    transactionId: constructTransactionIdentifier({ +      tag: TransactionType.Refund, +      refundGroupId: refundRecord.refundGroupId, +    }), +    txState: computeRefundTransactionState(refundRecord), +    extendedStatus: ExtendedStatus.Done, +    frozen: false, +    pending: false, +  } +} +  function buildTransactionForRefresh(    refreshGroupRecord: RefreshGroupRecord,    ort?: OperationRetryRecord, @@ -850,113 +789,11 @@ function buildTransactionForTip(    };  } -/** - * For a set of refund with the same executionTime. - */ -interface MergedRefundInfo { -  executionTime: TalerProtocolTimestamp; -  amountAppliedRaw: AmountJson; -  amountAppliedEffective: AmountJson; -  firstTimestamp: TalerProtocolTimestamp; -} - -function mergeRefundByExecutionTime( -  rs: WalletRefundItem[], -  zero: AmountJson, -): MergedRefundInfo[] { -  const refundByExecTime = rs.reduce((prev, refund) => { -    const key = `${refund.executionTime.t_s}`; - -    // refunds count if applied -    const effective = -      refund.type === RefundState.Applied -        ? Amounts.sub( -            refund.refundAmount, -            refund.refundFee, -            refund.totalRefreshCostBound, -          ).amount -        : zero; -    const raw = -      refund.type === RefundState.Applied ? refund.refundAmount : zero; - -    const v = prev.get(key); -    if (!v) { -      prev.set(key, { -        executionTime: refund.executionTime, -        amountAppliedEffective: effective, -        amountAppliedRaw: Amounts.parseOrThrow(raw), -        firstTimestamp: refund.obtainedTime, -      }); -    } else { -      //v.executionTime is the same -      v.amountAppliedEffective = Amounts.add( -        v.amountAppliedEffective, -        effective, -      ).amount; -      v.amountAppliedRaw = Amounts.add( -        v.amountAppliedRaw, -        refund.refundAmount, -      ).amount; -      v.firstTimestamp = TalerProtocolTimestamp.min( -        v.firstTimestamp, -        refund.obtainedTime, -      ); -    } -    return prev; -  }, new Map<string, MergedRefundInfo>()); - -  return Array.from(refundByExecTime.values()); -} - -async function buildTransactionForRefund( -  purchaseRecord: PurchaseRecord, -  contractData: WalletContractData, -  refundInfo: MergedRefundInfo, -  ort?: OperationRetryRecord, -): Promise<Transaction> { -  const info: OrderShortInfo = { -    merchant: contractData.merchant, -    orderId: contractData.orderId, -    products: contractData.products, -    summary: contractData.summary, -    summary_i18n: contractData.summaryI18n, -    contractTermsHash: contractData.contractTermsHash, -  }; -  if (contractData.fulfillmentUrl !== "") { -    info.fulfillmentUrl = contractData.fulfillmentUrl; -  } - -  return { -    type: TransactionType.Refund, -    txState: mkTxStateUnknown(), -    info, -    refundedTransactionId: makeTransactionId( -      TransactionType.Payment, -      purchaseRecord.proposalId, -    ), -    transactionId: makeTransactionId( -      TransactionType.Refund, -      purchaseRecord.proposalId, -      `${refundInfo.executionTime.t_s}`, -    ), -    timestamp: refundInfo.firstTimestamp, -    amountEffective: Amounts.stringify(refundInfo.amountAppliedEffective), -    amountRaw: Amounts.stringify(refundInfo.amountAppliedRaw), -    refundPending: -      purchaseRecord.refundAmountAwaiting === undefined -        ? undefined -        : Amounts.stringify(purchaseRecord.refundAmountAwaiting), -    extendedStatus: ExtendedStatus.Done, -    pending: false, -    frozen: false, -    ...(ort?.lastError ? { error: ort.lastError } : {}), -  }; -}  async function buildTransactionForPurchase(    purchaseRecord: PurchaseRecord,    contractData: WalletContractData, -  refundsInfo: MergedRefundInfo[], +  refundsInfo: RefundGroupRecord[],    ort?: OperationRetryRecord,  ): Promise<Transaction> {    const zero = Amounts.zeroOfAmount(contractData.amount); @@ -974,30 +811,7 @@ async function buildTransactionForPurchase(      info.fulfillmentUrl = contractData.fulfillmentUrl;    } -  const totalRefund = refundsInfo.reduce( -    (prev, cur) => { -      return { -        raw: Amounts.add(prev.raw, cur.amountAppliedRaw).amount, -        effective: Amounts.add(prev.effective, cur.amountAppliedEffective) -          .amount, -      }; -    }, -    { -      raw: zero, -      effective: zero, -    } as { raw: AmountJson; effective: AmountJson }, -  ); - -  const refunds: RefundInfoShort[] = refundsInfo.map((r) => ({ -    amountEffective: Amounts.stringify(r.amountAppliedEffective), -    amountRaw: Amounts.stringify(r.amountAppliedRaw), -    timestamp: r.executionTime, -    transactionId: makeTransactionId( -      TransactionType.Refund, -      purchaseRecord.proposalId, -      `${r.executionTime.t_s}`, -    ), -  })); +  const refunds: RefundInfoShort[] = [];    const timestamp = purchaseRecord.timestampAccept;    checkDbInvariant(!!timestamp); @@ -1008,7 +822,7 @@ async function buildTransactionForPurchase(      case PurchaseStatus.AbortingWithRefund:        status = ExtendedStatus.Aborting;        break; -    case PurchaseStatus.Paid: +    case PurchaseStatus.Done:      case PurchaseStatus.RepurchaseDetected:        status = ExtendedStatus.Done;        break; @@ -1018,10 +832,10 @@ async function buildTransactionForPurchase(      case PurchaseStatus.Paying:        status = ExtendedStatus.Pending;        break; -    case PurchaseStatus.ProposalDownloadFailed: +    case PurchaseStatus.FailedClaim:        status = ExtendedStatus.Failed;        break; -    case PurchaseStatus.PaymentAbortFinished: +    case PurchaseStatus.AbortedIncompletePayment:        status = ExtendedStatus.Aborted;        break;      default: @@ -1034,8 +848,8 @@ async function buildTransactionForPurchase(      txState: computePayMerchantTransactionState(purchaseRecord),      amountRaw: Amounts.stringify(contractData.amount),      amountEffective: Amounts.stringify(purchaseRecord.payInfo.totalPayCost), -    totalRefundRaw: Amounts.stringify(totalRefund.raw), -    totalRefundEffective: Amounts.stringify(totalRefund.effective), +    totalRefundRaw: Amounts.stringify(zero), // FIXME! +    totalRefundEffective: Amounts.stringify(zero), // FIXME!      refundPending:        purchaseRecord.refundAmountAwaiting === undefined          ? undefined @@ -1057,7 +871,7 @@ async function buildTransactionForPurchase(      refundQueryActive:        purchaseRecord.purchaseStatus === PurchaseStatus.QueryingRefund,      frozen: -      purchaseRecord.purchaseStatus === PurchaseStatus.PaymentAbortFinished ?? +      purchaseRecord.purchaseStatus === PurchaseStatus.AbortedIncompletePayment ??        false,      ...(ort?.lastError ? { error: ort.lastError } : {}),    }; @@ -1092,6 +906,7 @@ export async function getTransactions(        x.tombstones,        x.withdrawalGroups,        x.refreshGroups, +      x.refundGroups,      ])      .runReadOnly(async (tx) => {        tx.peerPushPaymentInitiations.iter().forEachAsync(async (pi) => { @@ -1202,6 +1017,14 @@ export async function getTransactions(          );        }); +      tx.refundGroups.iter().forEachAsync(async (refundGroup) => { +        const currency = Amounts.currencyOf(refundGroup.amountRaw); +        if (shouldSkipCurrency(transactionsRequest, currency)) { +          return; +        } +        transactions.push(buildTransactionForRefund(refundGroup)) +      }); +        tx.refreshGroups.iter().forEachAsync(async (rg) => {          if (shouldSkipCurrency(transactionsRequest, rg.currency)) {            return; @@ -1318,47 +1141,13 @@ export async function getTransactions(            download.contractTermsMerchantSig,          ); -        const filteredRefunds = await Promise.all( -          Object.values(purchase.refunds).map(async (r) => { -            const t = await tx.tombstones.get( -              makeTombstoneId( -                TombstoneTag.DeleteRefund, -                purchase.proposalId, -                `${r.executionTime.t_s}`, -              ), -            ); -            if (!t) return r; -            return undefined; -          }), -        ); - -        const cleanRefunds = filteredRefunds.filter( -          (x): x is WalletRefundItem => !!x, -        ); - -        const refunds = mergeRefundByExecutionTime( -          cleanRefunds, -          Amounts.zeroOfCurrency(download.currency), -        ); - -        refunds.forEach(async (refundInfo) => { -          transactions.push( -            await buildTransactionForRefund( -              purchase, -              contractData, -              refundInfo, -              undefined, -            ), -          ); -        }); -          const payOpId = TaskIdentifiers.forPay(purchase);          const payRetryRecord = await tx.operationRetries.get(payOpId);          transactions.push(            await buildTransactionForPurchase(              purchase,              contractData, -            refunds, +            [], // FIXME!              payRetryRecord,            ),          ); @@ -1425,7 +1214,7 @@ export type ParsedTransactionIdentifier =    | { tag: TransactionType.PeerPushCredit; peerPushPaymentIncomingId: string }    | { tag: TransactionType.PeerPushDebit; pursePub: string }    | { tag: TransactionType.Refresh; refreshGroupId: string } -  | { tag: TransactionType.Refund; proposalId: string; executionTime: string } +  | { tag: TransactionType.Refund; refundGroupId: string }    | { tag: TransactionType.Tip; walletTipId: string }    | { tag: TransactionType.Withdrawal; withdrawalGroupId: string }; @@ -1448,7 +1237,7 @@ export function constructTransactionIdentifier(      case TransactionType.Refresh:        return `txn:${pTxId.tag}:${pTxId.refreshGroupId}`;      case TransactionType.Refund: -      return `txn:${pTxId.tag}:${pTxId.proposalId}:${pTxId.executionTime}`; +      return `txn:${pTxId.tag}:${pTxId.refundGroupId}`;      case TransactionType.Tip:        return `txn:${pTxId.tag}:${pTxId.walletTipId}`;      case TransactionType.Withdrawal: @@ -1490,8 +1279,7 @@ export function parseTransactionIdentifier(      case TransactionType.Refund:        return {          tag: TransactionType.Refund, -        proposalId: rest[0], -        executionTime: rest[1], +        refundGroupId: rest[0],        };      case TransactionType.Tip:        return { | 
