diff --git a/packages/taler-util/src/talerTypes.ts b/packages/taler-util/src/talerTypes.ts index b21c6caec..d9213ef5d 100644 --- a/packages/taler-util/src/talerTypes.ts +++ b/packages/taler-util/src/talerTypes.ts @@ -562,8 +562,8 @@ export interface MerchantAbortPayRefundDetails { refund_amount: string; /** - * Fee for the refund. - */ + * Fee for the refund. + */ refund_fee: string; /** @@ -888,18 +888,18 @@ export type BlindedDenominationSignature = | RsaBlindedDenominationSignature | CSBlindedDenominationSignature; -export const codecForBlindedDenominationSignature = () => - buildCodecForUnion() - .discriminateOn("cipher") - .alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature()) - .build("BlindedDenominationSignature"); - export const codecForRsaBlindedDenominationSignature = () => buildCodecForObject() .property("cipher", codecForConstString(DenomKeyType.Rsa)) .property("blinded_rsa_signature", codecForString()) .build("RsaBlindedDenominationSignature"); +export const codecForBlindedDenominationSignature = () => + buildCodecForUnion() + .discriminateOn("cipher") + .alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature()) + .build("BlindedDenominationSignature"); + export class WithdrawResponse { ev_sig: BlindedDenominationSignature; } @@ -1024,15 +1024,17 @@ export interface ExchangeRevealResponse { } interface MerchantOrderStatusPaid { - /** - * Was the payment refunded (even partially, via refund or abort)? - */ + // Was the payment refunded (even partially, via refund or abort)? refunded: boolean; - /** - * Amount that was refunded in total. - */ + // Is any amount of the refund still waiting to be picked up (even partially)? + refund_pending: boolean; + + // Amount that was refunded in total. refund_amount: AmountString; + + // Amount that already taken by the wallet. + refund_taken: AmountString; } interface MerchantOrderRefundResponse { @@ -1528,6 +1530,8 @@ export const codecForMerchantOrderStatusPaid = (): Codec => buildCodecForObject() .property("refund_amount", codecForString()) + .property("refund_taken", codecForString()) + .property("refund_pending", codecForBoolean()) .property("refunded", codecForBoolean()) .build("MerchantOrderStatusPaid"); diff --git a/packages/taler-util/src/transactionsTypes.ts b/packages/taler-util/src/transactionsTypes.ts index b9a227b68..37c1c7ef1 100644 --- a/packages/taler-util/src/transactionsTypes.ts +++ b/packages/taler-util/src/transactionsTypes.ts @@ -228,6 +228,21 @@ export interface TransactionPayment extends TransactionCommon { * Amount that was paid, including deposit, wire and refresh fees. */ amountEffective: AmountString; + + /** + * Amount that has been refunded by the merchant + */ + totalRefundRaw: AmountString; + + /** + * Amount will be added to the wallet's balance after fees and refreshing + */ + totalRefundEffective: AmountString; + + /** + * Amount pending to be picked up + */ + refundPending: AmountString | undefined; } export interface OrderShortInfo { @@ -287,6 +302,11 @@ export interface TransactionRefund extends TransactionCommon { // Additional information about the refunded payment info: OrderShortInfo; + /** + * Amount pending to be picked up + */ + refundPending: AmountString | undefined; + // Amount that has been refunded by the merchant amountRaw: AmountString; diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index a8946fbbb..fa884c414 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -279,11 +279,11 @@ export class ReturnCoinsRequest { export interface PrepareRefundResult { proposalId: string; - applied: number; - failed: number; - total: number; - - amountEffectivePaid: AmountString; + effectivePaid: AmountString; + gone: AmountString; + granted: AmountString; + pending: boolean; + awaiting: AmountString; info: OrderShortInfo; } diff --git a/packages/taler-wallet-cli/src/harness/merchantApiTypes.ts b/packages/taler-wallet-cli/src/harness/merchantApiTypes.ts index 35062b579..8b10bb749 100644 --- a/packages/taler-wallet-cli/src/harness/merchantApiTypes.ts +++ b/packages/taler-wallet-cli/src/harness/merchantApiTypes.ts @@ -43,6 +43,8 @@ import { EddsaPublicKeyString, codecForAmountString, TalerProtocolDuration, + codecForTimestamp, + TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; export interface PostOrderRequest { @@ -80,6 +82,15 @@ export const codecForPostOrderResponse = (): Codec => .property("token", codecOptional(codecForString())) .build("PostOrderResponse"); + +export const codecForRefundDetails = (): Codec => + buildCodecForObject() + .property("reason", codecForString()) + .property("pending", codecForBoolean()) + .property("amount", codecForString()) + .property("timestamp", codecForTimestamp) + .build("PostOrderResponse"); + export const codecForCheckPaymentPaidResponse = (): Codec => buildCodecForObject() @@ -200,7 +211,10 @@ export interface RefundDetails { reason: string; // when was the refund approved - timestamp: AbsoluteTime; + timestamp: TalerProtocolTimestamp; + + // has not been taken yet + pending: boolean; // Total amount that was refunded (minus a refund fee). amount: AmountString; diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index e8c46c7e3..8fe1937aa 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -1288,6 +1288,12 @@ export interface PurchaseRecord { */ autoRefundDeadline: TalerProtocolTimestamp | undefined; + /** + * How much merchant has refund to be taken but the wallet + * did not picked up yet + */ + refundAwaiting: AmountJson | undefined; + /** * Is the payment frozen? I.e. did we encounter * an error where it doesn't make sense to retry. diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 37e97fbc8..a0a603ca3 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -345,7 +345,7 @@ export async function importBackup( } const denomPubHash = cryptoComp.rsaDenomPubToHash[ - backupDenomination.denom_pub.rsa_public_key + backupDenomination.denom_pub.rsa_public_key ]; checkLogicInvariant(!!denomPubHash); const existingDenom = await tx.denominations.get([ @@ -560,7 +560,7 @@ export async function importBackup( const amount = Amounts.parseOrThrow(parsedContractTerms.amount); const contractTermsHash = cryptoComp.proposalIdToContractTermsHash[ - backupProposal.proposal_id + backupProposal.proposal_id ]; let maxWireFee: AmountJson; if (parsedContractTerms.max_wire_fee) { @@ -704,7 +704,7 @@ export async function importBackup( const amount = Amounts.parseOrThrow(parsedContractTerms.amount); const contractTermsHash = cryptoComp.proposalIdToContractTermsHash[ - backupPurchase.proposal_id + backupPurchase.proposal_id ]; let maxWireFee: AmountJson; if (parsedContractTerms.max_wire_fee) { @@ -755,6 +755,7 @@ export async function importBackup( autoRefundDeadline: TalerProtocolTimestamp.never(), refundStatusRetryInfo: resetRetryInfo(), lastRefundStatusError: undefined, + refundAwaiting: undefined, timestampAccept: backupPurchase.timestamp_accept, timestampFirstSuccessfulPay: backupPurchase.timestamp_first_successful_pay, diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index db157257a..325d07bd1 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -443,6 +443,7 @@ async function recordConfirmPay( refundQueryRequested: false, timestampFirstSuccessfulPay: undefined, autoRefundDeadline: undefined, + refundAwaiting: undefined, paymentSubmitPending: true, refunds: {}, merchantPaySig: undefined, @@ -987,18 +988,16 @@ async function storeFirstPaySuccess( purchase.lastSessionId = sessionId; purchase.payRetryInfo = resetRetryInfo(); purchase.merchantPaySig = paySig; - if (isFirst) { - const protoAr = purchase.download.contractData.autoRefund; - if (protoAr) { - const ar = Duration.fromTalerProtocolDuration(protoAr); - logger.info("auto_refund present"); - purchase.refundQueryRequested = true; - purchase.refundStatusRetryInfo = resetRetryInfo(); - purchase.lastRefundStatusError = undefined; - purchase.autoRefundDeadline = AbsoluteTime.toTimestamp( - AbsoluteTime.addDuration(AbsoluteTime.now(), ar), - ); - } + const protoAr = purchase.download.contractData.autoRefund; + if (protoAr) { + const ar = Duration.fromTalerProtocolDuration(protoAr); + logger.info("auto_refund present"); + purchase.refundQueryRequested = true; + purchase.refundStatusRetryInfo = resetRetryInfo(); + purchase.lastRefundStatusError = undefined; + purchase.autoRefundDeadline = AbsoluteTime.toTimestamp( + AbsoluteTime.addDuration(AbsoluteTime.now(), ar), + ); } await tx.purchases.put(purchase); }); diff --git a/packages/taler-wallet-core/src/operations/refund.ts b/packages/taler-wallet-core/src/operations/refund.ts index dad8c6001..e5ce37a83 100644 --- a/packages/taler-wallet-core/src/operations/refund.ts +++ b/packages/taler-wallet-core/src/operations/refund.ts @@ -101,29 +101,19 @@ export async function prepareRefund( ); } + const awaiting = await queryAndSaveAwaitingRefund(ws, purchase) + const summary = calculateRefundSummary(purchase) const proposalId = purchase.proposalId; - const rfs = Object.values(purchase.refunds) - - let applied = 0; - let failed = 0; - const total = rfs.length; - rfs.forEach((refund) => { - if (refund.type === RefundState.Failed) { - failed = failed + 1; - } - if (refund.type === RefundState.Applied) { - applied = applied + 1; - } - }); const { contractData: c } = purchase.download return { proposalId, - amountEffectivePaid: Amounts.stringify(purchase.totalPayCost), - applied, - failed, - total, + 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, @@ -533,6 +523,44 @@ async function acceptRefunds( }); } + +function calculateRefundSummary(p: PurchaseRecord): RefundSummary { + let amountRefundGranted = Amounts.getZero( + p.download.contractData.amount.currency, + ); + let amountRefundGone = Amounts.getZero( + p.download.contractData.amount.currency, + ); + + let pendingAtExchange = false; + + Object.keys(p.refunds).forEach((rk) => { + const refund = p.refunds[rk]; + if (refund.type === RefundState.Pending) { + pendingAtExchange = true; + } + if ( + refund.type === RefundState.Applied || + refund.type === RefundState.Pending + ) { + amountRefundGranted = Amounts.add( + amountRefundGranted, + Amounts.sub( + refund.refundAmount, + refund.refundFee, + refund.totalRefreshCostBound, + ).amount, + ).amount; + } else { + amountRefundGone = Amounts.add( + amountRefundGone, + refund.refundAmount, + ).amount; + } + }); + return { amountEffectivePaid: p.totalPayCost, amountRefundGone, amountRefundGranted, pendingAtExchange } +} + /** * Summary of the refund status of a purchase. */ @@ -618,49 +646,15 @@ export async function applyRefund( throw Error("purchase no longer exists"); } - const p = purchase; - - let amountRefundGranted = Amounts.getZero( - purchase.download.contractData.amount.currency, - ); - let amountRefundGone = Amounts.getZero( - purchase.download.contractData.amount.currency, - ); - - let pendingAtExchange = false; - - Object.keys(purchase.refunds).forEach((rk) => { - const refund = p.refunds[rk]; - if (refund.type === RefundState.Pending) { - pendingAtExchange = true; - } - if ( - refund.type === RefundState.Applied || - refund.type === RefundState.Pending - ) { - amountRefundGranted = Amounts.add( - amountRefundGranted, - Amounts.sub( - refund.refundAmount, - refund.refundFee, - refund.totalRefreshCostBound, - ).amount, - ).amount; - } else { - amountRefundGone = Amounts.add( - amountRefundGone, - refund.refundAmount, - ).amount; - } - }); + const summary = calculateRefundSummary(purchase) return { contractTermsHash: purchase.download.contractData.contractTermsHash, proposalId: purchase.proposalId, - amountEffectivePaid: Amounts.stringify(purchase.totalPayCost), - amountRefundGone: Amounts.stringify(amountRefundGone), - amountRefundGranted: Amounts.stringify(amountRefundGranted), - pendingAtExchange, + amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid), + amountRefundGone: Amounts.stringify(summary.amountRefundGone), + amountRefundGranted: Amounts.stringify(summary.amountRefundGranted), + pendingAtExchange: summary.pendingAtExchange, info: { contractTermsHash: purchase.download.contractData.contractTermsHash, merchant: purchase.download.contractData.merchant, @@ -691,6 +685,59 @@ export async function processPurchaseQueryRefund( ); } +async function queryAndSaveAwaitingRefund( + ws: InternalWalletState, + purchase: PurchaseRecord, + waitForAutoRefund?: boolean): Promise { + const requestUrl = new URL( + `orders/${purchase.download.contractData.orderId}`, + purchase.download.contractData.merchantBaseUrl, + ); + requestUrl.searchParams.set( + "h_contract", + purchase.download.contractData.contractTermsHash, + ); + // Long-poll for one second + if (waitForAutoRefund) { + requestUrl.searchParams.set("timeout_ms", "1000"); + requestUrl.searchParams.set("await_refund_obtained", "yes"); + logger.trace("making long-polling request for auto-refund"); + } + const resp = await ws.http.get(requestUrl.href); + const orderStatus = await readSuccessResponseJsonOrThrow( + resp, + codecForMerchantOrderStatusPaid(), + ); + if (!orderStatus.refunded) { + // Wait for retry ... + return Amounts.getZero(purchase.totalPayCost.currency); + } + + const refundAwaiting = Amounts.sub( + Amounts.parseOrThrow(orderStatus.refund_amount), + Amounts.parseOrThrow(orderStatus.refund_taken) + ).amount + + console.log("refund waiting found, ", refundAwaiting, orderStatus, purchase.refundAwaiting, purchase.refundAwaiting && Amounts.cmp(refundAwaiting, purchase.refundAwaiting)) + + if (purchase.refundAwaiting === undefined || Amounts.cmp(refundAwaiting, purchase.refundAwaiting) !== 0) { + await ws.db + .mktx((x) => ({ purchases: x.purchases })) + .runReadWrite(async (tx) => { + const p = await tx.purchases.get(purchase.proposalId); + if (!p) { + logger.warn("purchase does not exist anymore"); + return; + } + p.refundAwaiting = refundAwaiting + await tx.purchases.put(p); + }); + } + + return refundAwaiting; +} + + async function processPurchaseQueryRefundImpl( ws: InternalWalletState, proposalId: string, @@ -719,33 +766,13 @@ async function processPurchaseQueryRefundImpl( if (purchase.timestampFirstSuccessfulPay) { if ( - waitForAutoRefund && - purchase.autoRefundDeadline && + !purchase.autoRefundDeadline || !AbsoluteTime.isExpired( AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), ) ) { - const requestUrl = new URL( - `orders/${purchase.download.contractData.orderId}`, - purchase.download.contractData.merchantBaseUrl, - ); - requestUrl.searchParams.set( - "h_contract", - purchase.download.contractData.contractTermsHash, - ); - // Long-poll for one second - requestUrl.searchParams.set("timeout_ms", "1000"); - requestUrl.searchParams.set("await_refund_obtained", "yes"); - logger.trace("making long-polling request for auto-refund"); - const resp = await ws.http.get(requestUrl.href); - const orderStatus = await readSuccessResponseJsonOrThrow( - resp, - codecForMerchantOrderStatusPaid(), - ); - if (!orderStatus.refunded) { - // Wait for retry ... - return; - } + const awaitingAmount = await queryAndSaveAwaitingRefund(ws, purchase, waitForAutoRefund) + if (Amounts.isZero(awaitingAmount)) return; } const requestUrl = new URL( diff --git a/packages/taler-wallet-core/src/operations/transactions.ts b/packages/taler-wallet-core/src/operations/transactions.ts index 0a3549451..87b109d98 100644 --- a/packages/taler-wallet-core/src/operations/transactions.ts +++ b/packages/taler-wallet-core/src/operations/transactions.ts @@ -49,6 +49,16 @@ import { processWithdrawGroup } from "./withdraw.js"; const logger = new Logger("taler-wallet-core:transactions.ts"); +export enum TombstoneTag { + DeleteWithdrawalGroup = "delete-withdrawal-group", + DeleteReserve = "delete-reserve", + DeletePayment = "delete-payment", + DeleteTip = "delete-tip", + DeleteRefreshGroup = "delete-refresh-group", + DeleteDepositGroup = "delete-deposit-group", + DeleteRefund = "delete-refund", +} + /** * Create an event ID from the type and the primary key for the event. */ @@ -286,25 +296,6 @@ export async function getTransactions( TransactionType.Payment, pr.proposalId, ); - const err = pr.lastPayError ?? pr.lastRefundStatusError; - transactions.push({ - type: TransactionType.Payment, - amountRaw: Amounts.stringify(contractData.amount), - amountEffective: Amounts.stringify(pr.totalPayCost), - status: pr.timestampFirstSuccessfulPay - ? PaymentStatus.Paid - : PaymentStatus.Accepted, - pending: - !pr.timestampFirstSuccessfulPay && - pr.abortStatus === AbortStatus.None, - timestamp: pr.timestampAccept, - transactionId: paymentTransactionId, - proposalId: pr.proposalId, - info: info, - frozen: pr.payFrozen ?? false, - ...(err ? { error: err } : {}), - }); - const refundGroupKeys = new Set(); for (const rk of Object.keys(pr.refunds)) { @@ -313,6 +304,9 @@ export async function getTransactions( refundGroupKeys.add(groupKey); } + let totalRefundRaw = Amounts.getZero(contractData.amount.currency); + let totalRefundEffective = Amounts.getZero(contractData.amount.currency); + for (const groupKey of refundGroupKeys.values()) { const refundTombstoneId = makeEventId( TombstoneTag.DeleteRefund, @@ -356,6 +350,10 @@ export async function getTransactions( if (!r0) { throw Error("invariant violated"); } + + totalRefundRaw = Amounts.add(totalRefundRaw, amountRaw).amount; + totalRefundEffective = Amounts.add(totalRefundEffective, amountEffective).amount; + transactions.push({ type: TransactionType.Refund, info, @@ -364,10 +362,34 @@ export async function getTransactions( timestamp: r0.obtainedTime, amountEffective: Amounts.stringify(amountEffective), amountRaw: Amounts.stringify(amountRaw), + refundPending: pr.refundAwaiting === undefined ? undefined : Amounts.stringify(pr.refundAwaiting), pending: false, frozen: false, }); } + + const err = pr.lastPayError ?? pr.lastRefundStatusError; + transactions.push({ + type: TransactionType.Payment, + amountRaw: Amounts.stringify(contractData.amount), + amountEffective: Amounts.stringify(pr.totalPayCost), + totalRefundRaw: Amounts.stringify(totalRefundRaw), + totalRefundEffective: Amounts.stringify(totalRefundEffective), + refundPending: pr.refundAwaiting === undefined ? undefined : Amounts.stringify(pr.refundAwaiting), + status: pr.timestampFirstSuccessfulPay + ? PaymentStatus.Paid + : PaymentStatus.Accepted, + pending: + !pr.timestampFirstSuccessfulPay && + pr.abortStatus === AbortStatus.None, + timestamp: pr.timestampAccept, + transactionId: paymentTransactionId, + proposalId: pr.proposalId, + info: info, + frozen: pr.payFrozen ?? false, + ...(err ? { error: err } : {}), + }); + }); tx.tips.iter().forEachAsync(async (tipRecord) => { @@ -419,16 +441,6 @@ export async function getTransactions( return { transactions: [...txNotPending, ...txPending] }; } -export enum TombstoneTag { - DeleteWithdrawalGroup = "delete-withdrawal-group", - DeleteReserve = "delete-reserve", - DeletePayment = "delete-payment", - DeleteTip = "delete-tip", - DeleteRefreshGroup = "delete-refresh-group", - DeleteDepositGroup = "delete-deposit-group", - DeleteRefund = "delete-refund", -} - /** * Immediately retry the underlying operation * of a transaction. @@ -442,28 +454,33 @@ export async function retryTransaction( const [type, ...rest] = transactionId.split(":"); switch (type) { - case TransactionType.Deposit: + case TransactionType.Deposit: { const depositGroupId = rest[0]; processDepositGroup(ws, depositGroupId, { forceNow: true, }); break; - case TransactionType.Withdrawal: + } + case TransactionType.Withdrawal: { const withdrawalGroupId = rest[0]; await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true }); break; - case TransactionType.Payment: + } + case TransactionType.Payment: { const proposalId = rest[0]; await processPurchasePay(ws, proposalId, { forceNow: true }); break; - case TransactionType.Tip: + } + case TransactionType.Tip: { const walletTipId = rest[0]; await processTip(ws, walletTipId, { forceNow: true }); break; - case TransactionType.Refresh: + } + case TransactionType.Refresh: { const refreshGroupId = rest[0]; await processRefreshGroup(ws, refreshGroupId, { forceNow: true }); break; + } default: break; } diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 053a0763b..905d9220a 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -1235,10 +1235,10 @@ class InternalWalletStateImpl implements InternalWalletState { const key = `${exchangeBaseUrl}:${denomPubHash}`; const cached = this.denomCache[key]; if (cached) { - logger.info("using cached denom"); + logger.trace("using cached denom"); return cached; } - logger.info("looking up denom denom"); + logger.trace("looking up denom denom"); const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); if (d) { this.denomCache[key] = d; diff --git a/packages/taler-wallet-webextension/compile_core.sh b/packages/taler-wallet-webextension/compile_core.sh new file mode 100755 index 000000000..b93d543f0 --- /dev/null +++ b/packages/taler-wallet-webextension/compile_core.sh @@ -0,0 +1,2 @@ +#!/bin/bash +pnpm run --filter @gnu-taler/taler-wallet-core compile diff --git a/packages/taler-wallet-webextension/compile_util.sh b/packages/taler-wallet-webextension/compile_util.sh new file mode 100755 index 000000000..df2c4125a --- /dev/null +++ b/packages/taler-wallet-webextension/compile_util.sh @@ -0,0 +1,3 @@ +#!/bin/bash +pnpm run --filter @gnu-taler/taler-util compile + diff --git a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx index 6b7cf4621..bc2939896 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx @@ -33,6 +33,7 @@ export const Complete = createExample(TestedComponent, { state: { status: "completed", amount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:1"), hook: undefined, merchantName: "the merchant", products: undefined, @@ -44,9 +45,10 @@ export const InProgress = createExample(TestedComponent, { status: "in-progress", hook: undefined, amount: Amounts.parseOrThrow("USD:1"), + awaitingAmount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:0"), merchantName: "the merchant", products: undefined, - progress: 0.5, }, }); @@ -58,6 +60,8 @@ export const Ready = createExample(TestedComponent, { ignore: {}, amount: Amounts.parseOrThrow("USD:1"), + awaitingAmount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:0"), merchantName: "the merchant", products: [], orderId: "abcdef", @@ -73,6 +77,8 @@ export const WithAProductList = createExample(TestedComponent, { accept: {}, ignore: {}, amount: Amounts.parseOrThrow("USD:1"), + awaitingAmount: Amounts.parseOrThrow("USD:1"), + granted: Amounts.parseOrThrow("USD:0"), merchantName: "the merchant", products: [ { diff --git a/packages/taler-wallet-webextension/src/cta/Refund.test.ts b/packages/taler-wallet-webextension/src/cta/Refund.test.ts index e77f8e682..864b4f12c 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.test.ts +++ b/packages/taler-wallet-webextension/src/cta/Refund.test.ts @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts, NotificationType, PrepareRefundResult } from "@gnu-taler/taler-util"; +import { AmountJson, Amounts, NotificationType, PrepareRefundResult } from "@gnu-taler/taler-util"; import { expect } from "chai"; import { mountHook } from "../test-utils.js"; import { SubsHandler } from "./Pay.test.js"; @@ -62,10 +62,12 @@ describe("Refund CTA states", () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => useComponentState("taler://refund/asdasdas", { prepareRefund: async () => ({ - total: 0, - applied: 0, - failed: 0, - amountEffectivePaid: 'EUR:2', + effectivePaid: 'EUR:2', + awaiting: 'EUR:2', + gone: 'EUR:0', + granted: 'EUR:0', + pending: false, + proposalId: '1', info: { contractTermsHash: '123', merchant: { @@ -107,10 +109,12 @@ describe("Refund CTA states", () => { const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => useComponentState("taler://refund/asdasdas", { prepareRefund: async () => ({ - total: 0, - applied: 0, - failed: 0, - amountEffectivePaid: 'EUR:2', + effectivePaid: 'EUR:2', + awaiting: 'EUR:2', + gone: 'EUR:0', + granted: 'EUR:0', + pending: false, + proposalId: '1', info: { contractTermsHash: '123', merchant: { @@ -161,21 +165,30 @@ describe("Refund CTA states", () => { }); it("should be in progress when doing refresh", async () => { - let numApplied = 1; + let granted = Amounts.getZero('EUR') + const unit: AmountJson = { currency: 'EUR', value: 1, fraction: 0 } + const refunded: AmountJson = { currency: 'EUR', value: 2, fraction: 0 } + let awaiting: AmountJson = refunded + let pending = true; + const subscriptions = new SubsHandler(); function notifyMelt(): void { - numApplied++; + granted = Amounts.add(granted, unit).amount; + pending = granted.value < refunded.value; + awaiting = Amounts.sub(refunded, granted).amount; subscriptions.notifyEvent(NotificationType.RefreshMelted) } const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => useComponentState("taler://refund/asdasdas", { prepareRefund: async () => ({ - total: 3, - applied: numApplied, - failed: 0, - amountEffectivePaid: 'EUR:2', + awaiting: Amounts.stringify(awaiting), + effectivePaid: 'EUR:2', + gone: 'EUR:0', + granted: Amounts.stringify(granted), + pending, + proposalId: '1', info: { contractTermsHash: '123', merchant: { @@ -201,12 +214,12 @@ describe("Refund CTA states", () => { { const state = getLastResultOrThrow() - if (state.status !== 'in-progress') expect.fail(); + if (state.status !== 'in-progress') expect.fail('1'); if (state.hook) expect.fail(); expect(state.merchantName).eq('the merchant name'); expect(state.products).undefined; expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")) - expect(state.progress).closeTo(1 / 3, 0.01) + // expect(state.progress).closeTo(1 / 3, 0.01) notifyMelt() } @@ -216,12 +229,12 @@ describe("Refund CTA states", () => { { const state = getLastResultOrThrow() - if (state.status !== 'in-progress') expect.fail(); + if (state.status !== 'in-progress') expect.fail('2'); if (state.hook) expect.fail(); expect(state.merchantName).eq('the merchant name'); expect(state.products).undefined; expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")) - expect(state.progress).closeTo(2 / 3, 0.01) + // expect(state.progress).closeTo(2 / 3, 0.01) notifyMelt() } @@ -231,7 +244,7 @@ describe("Refund CTA states", () => { { const state = getLastResultOrThrow() - if (state.status !== 'completed') expect.fail(); + if (state.status !== 'completed') expect.fail('3'); if (state.hook) expect.fail(); expect(state.merchantName).eq('the merchant name'); expect(state.products).undefined; diff --git a/packages/taler-wallet-webextension/src/cta/Refund.tsx b/packages/taler-wallet-webextension/src/cta/Refund.tsx index f69fc4311..5387a1782 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.tsx +++ b/packages/taler-wallet-webextension/src/cta/Refund.tsx @@ -34,7 +34,6 @@ import { LoadingError } from "../components/LoadingError.js"; import { LogoHeader } from "../components/LogoHeader.js"; import { Part } from "../components/Part.js"; import { - Button, ButtonSuccess, SubTitle, WalletAction, @@ -99,6 +98,12 @@ export function View({ state }: ViewProps): VNode { Total to refund} + text={} + kind="negative" + /> + Refunded} text={} kind="negative" /> @@ -108,9 +113,9 @@ export function View({ state }: ViewProps): VNode { ) : undefined} -
+ {/*
-
+
*/} ); } @@ -128,6 +133,14 @@ export function View({ state }: ViewProps): VNode { this refund is already accepted.

+
+ Total to refunded} + text={} + kind="negative" + /> +
); } @@ -150,9 +163,23 @@ export function View({ state }: ViewProps): VNode {
Total to refund} + title={Order amount} text={} - kind="negative" + kind="neutral" + /> + {Amounts.isNonZero(state.granted) && ( + Already refunded} + text={} + kind="neutral" + /> + )} + Refund offered} + text={} + kind="positive" />
{state.products && state.products.length ? ( @@ -164,9 +191,6 @@ export function View({ state }: ViewProps): VNode { Confirm refund - ); @@ -184,6 +208,8 @@ interface Ready { merchantName: string; products: Product[] | undefined; amount: AmountJson; + awaitingAmount: AmountJson; + granted: AmountJson; accept: ButtonHandler; ignore: ButtonHandler; orderId: string; @@ -199,7 +225,8 @@ interface InProgress { merchantName: string; products: Product[] | undefined; amount: AmountJson; - progress: number; + awaitingAmount: AmountJson; + granted: AmountJson; } interface Completed { status: "completed"; @@ -207,6 +234,7 @@ interface Completed { merchantName: string; products: Product[] | undefined; amount: AmountJson; + granted: AmountJson; } export function useComponentState( @@ -253,25 +281,27 @@ export function useComponentState( }; } - const pending = refund.total > refund.applied + refund.failed; - const completed = refund.total > 0 && refund.applied === refund.total; + const awaitingAmount = Amounts.parseOrThrow(refund.awaiting); - if (pending) { - return { - status: "in-progress", - hook: undefined, - amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid), - merchantName: info.response.refund.info.merchant.name, - products: info.response.refund.info.products, - progress: (refund.applied + refund.failed) / refund.total, - }; - } - - if (completed) { + if (Amounts.isZero(awaitingAmount)) { return { status: "completed", hook: undefined, - amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid), + amount: Amounts.parseOrThrow(info.response.refund.effectivePaid), + granted: Amounts.parseOrThrow(info.response.refund.granted), + merchantName: info.response.refund.info.merchant.name, + products: info.response.refund.info.products, + }; + } + + if (refund.pending) { + return { + status: "in-progress", + hook: undefined, + awaitingAmount, + amount: Amounts.parseOrThrow(info.response.refund.effectivePaid), + granted: Amounts.parseOrThrow(info.response.refund.granted), + merchantName: info.response.refund.info.merchant.name, products: info.response.refund.info.products, }; @@ -280,7 +310,9 @@ export function useComponentState( return { status: "ready", hook: undefined, - amount: Amounts.parseOrThrow(info.response.refund.amountEffectivePaid), + amount: Amounts.parseOrThrow(info.response.refund.effectivePaid), + granted: Amounts.parseOrThrow(info.response.refund.granted), + awaitingAmount, merchantName: info.response.refund.info.merchant.name, products: info.response.refund.info.products, orderId: info.response.refund.info.orderId, diff --git a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx index 92f1dea1b..3080a866e 100644 --- a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx @@ -78,6 +78,9 @@ const exampleData = { summary: "the summary", fulfillmentMessage: "", }, + refundPending: undefined, + totalRefundEffective: "USD:0", + totalRefundRaw: "USD:0", proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", status: PaymentStatus.Accepted, } as TransactionPayment, @@ -112,6 +115,7 @@ const exampleData = { summary: "the summary", fulfillmentMessage: "", }, + refundPending: undefined, } as TransactionRefund, }; diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx index b4dfb6ce0..f162543ae 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx @@ -83,6 +83,9 @@ const exampleData = { summary: "Essay: Why the Devil's Advocate Doesn't Help Reach the Truth", fulfillmentMessage: "", }, + refundPending: undefined, + totalRefundEffective: "USD:0", + totalRefundRaw: "USD:0", proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", status: PaymentStatus.Accepted, } as TransactionPayment, @@ -117,6 +120,7 @@ const exampleData = { summary: "the summary", fulfillmentMessage: "", }, + refundPending: undefined, } as TransactionRefund, }; diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index bcf6114a1..3377f98c7 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -40,7 +40,6 @@ import { ButtonPrimary, CenteredDialog, InfoBox, - LargeText, ListOfProducts, Overlay, RowBorderGray, @@ -51,6 +50,7 @@ import { import { Time } from "../components/Time.js"; import { useTranslationContext } from "../context/translation.js"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; +import { Pages } from "../NavigationBar.js"; import * as wxApi from "../wxApi.js"; interface Props { @@ -344,6 +344,17 @@ export function TransactionView({ Amounts.parseOrThrow(transaction.amountRaw), ).amount; + const refundFee = Amounts.sub( + Amounts.parseOrThrow(transaction.totalRefundRaw), + Amounts.parseOrThrow(transaction.totalRefundEffective), + ).amount; + const refunded = Amounts.isNonZero( + Amounts.parseOrThrow(transaction.totalRefundRaw), + ); + const pendingRefund = + transaction.refundPending === undefined + ? undefined + : Amounts.parseOrThrow(transaction.refundPending); return ( @@ -360,18 +371,54 @@ export function TransactionView({ text={} kind="negative" /> - Purchase amount} - text={} - kind="neutral" - /> - Fee} - text={} - kind="negative" - /> + {Amounts.isNonZero(fee) && ( + + Purchase amount} + text={} + kind="neutral" + /> + Purchase Fee} + text={} + kind="negative" + /> + + )} + {refunded && ( + + Total refunded} + text={} + kind="positive" + /> + {Amounts.isNonZero(refundFee) && ( + + Refund amount} + text={} + kind="neutral" + /> + Refund fee} + text={} + kind="negative" + /> + + )} + + )} + {pendingRefund !== undefined && Amounts.isNonZero(pendingRefund) && ( + Refund pending} + text={} + kind="positive" + /> + )} Merchant} text={transaction.info.merchant.name} @@ -447,18 +494,22 @@ export function TransactionView({ text={} kind="neutral" /> - Deposit amount} - text={} - kind="positive" - /> - Fee} - text={} - kind="negative" - /> + {Amounts.isNonZero(fee) && ( + + Deposit amount} + text={} + kind="positive" + /> + Fee} + text={} + kind="negative" + /> + + )} {payto && } ); @@ -485,18 +536,22 @@ export function TransactionView({ text={} kind="negative" /> - Refresh amount} - text={} - kind="neutral" - /> - Fee} - text={} - kind="negative" - /> + {Amounts.isNonZero(fee) && ( + + Refresh amount} + text={} + kind="neutral" + /> + Fee} + text={} + kind="negative" + /> + + )} ); } @@ -522,18 +577,22 @@ export function TransactionView({ text={} kind="positive" /> - Received amount} - text={} - kind="neutral" - /> - Fee} - text={} - kind="negative" - /> + {Amounts.isNonZero(fee) && ( + + Received amount} + text={} + kind="neutral" + /> + Fee} + text={} + kind="negative" + /> + + )} ); } @@ -559,37 +618,42 @@ export function TransactionView({ text={} kind="positive" /> - Refund amount} - text={} - kind="neutral" - /> - Fee} - text={} - kind="negative" - /> + {Amounts.isNonZero(fee) && ( + + Refund amount} + text={} + kind="neutral" + /> + Fee} + text={} + kind="negative" + /> + + )} Merchant} text={transaction.info.merchant.name} kind="neutral" /> + Purchase} text={ - transaction.info.fulfillmentUrl ? ( - - {transaction.info.summary} - - ) : ( - transaction.info.summary - ) + + {transaction.info.summary} + } kind="neutral" />