feat: awaiting refund

This commit is contained in:
Sebastian 2022-05-14 18:09:33 -03:00
parent c02dbc833b
commit e4ea201943
No known key found for this signature in database
GPG Key ID: BE4FF68352439FC1
18 changed files with 484 additions and 268 deletions

View File

@ -562,8 +562,8 @@ export interface MerchantAbortPayRefundDetails {
refund_amount: string; refund_amount: string;
/** /**
* Fee for the refund. * Fee for the refund.
*/ */
refund_fee: string; refund_fee: string;
/** /**
@ -888,18 +888,18 @@ export type BlindedDenominationSignature =
| RsaBlindedDenominationSignature | RsaBlindedDenominationSignature
| CSBlindedDenominationSignature; | CSBlindedDenominationSignature;
export const codecForBlindedDenominationSignature = () =>
buildCodecForUnion<BlindedDenominationSignature>()
.discriminateOn("cipher")
.alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature())
.build("BlindedDenominationSignature");
export const codecForRsaBlindedDenominationSignature = () => export const codecForRsaBlindedDenominationSignature = () =>
buildCodecForObject<RsaBlindedDenominationSignature>() buildCodecForObject<RsaBlindedDenominationSignature>()
.property("cipher", codecForConstString(DenomKeyType.Rsa)) .property("cipher", codecForConstString(DenomKeyType.Rsa))
.property("blinded_rsa_signature", codecForString()) .property("blinded_rsa_signature", codecForString())
.build("RsaBlindedDenominationSignature"); .build("RsaBlindedDenominationSignature");
export const codecForBlindedDenominationSignature = () =>
buildCodecForUnion<BlindedDenominationSignature>()
.discriminateOn("cipher")
.alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature())
.build("BlindedDenominationSignature");
export class WithdrawResponse { export class WithdrawResponse {
ev_sig: BlindedDenominationSignature; ev_sig: BlindedDenominationSignature;
} }
@ -1024,15 +1024,17 @@ export interface ExchangeRevealResponse {
} }
interface MerchantOrderStatusPaid { interface MerchantOrderStatusPaid {
/** // Was the payment refunded (even partially, via refund or abort)?
* Was the payment refunded (even partially, via refund or abort)?
*/
refunded: boolean; refunded: boolean;
/** // Is any amount of the refund still waiting to be picked up (even partially)?
* Amount that was refunded in total. refund_pending: boolean;
*/
// Amount that was refunded in total.
refund_amount: AmountString; refund_amount: AmountString;
// Amount that already taken by the wallet.
refund_taken: AmountString;
} }
interface MerchantOrderRefundResponse { interface MerchantOrderRefundResponse {
@ -1528,6 +1530,8 @@ export const codecForMerchantOrderStatusPaid =
(): Codec<MerchantOrderStatusPaid> => (): Codec<MerchantOrderStatusPaid> =>
buildCodecForObject<MerchantOrderStatusPaid>() buildCodecForObject<MerchantOrderStatusPaid>()
.property("refund_amount", codecForString()) .property("refund_amount", codecForString())
.property("refund_taken", codecForString())
.property("refund_pending", codecForBoolean())
.property("refunded", codecForBoolean()) .property("refunded", codecForBoolean())
.build("MerchantOrderStatusPaid"); .build("MerchantOrderStatusPaid");

View File

@ -228,6 +228,21 @@ export interface TransactionPayment extends TransactionCommon {
* Amount that was paid, including deposit, wire and refresh fees. * Amount that was paid, including deposit, wire and refresh fees.
*/ */
amountEffective: AmountString; 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 { export interface OrderShortInfo {
@ -287,6 +302,11 @@ export interface TransactionRefund extends TransactionCommon {
// Additional information about the refunded payment // Additional information about the refunded payment
info: OrderShortInfo; info: OrderShortInfo;
/**
* Amount pending to be picked up
*/
refundPending: AmountString | undefined;
// Amount that has been refunded by the merchant // Amount that has been refunded by the merchant
amountRaw: AmountString; amountRaw: AmountString;

View File

@ -279,11 +279,11 @@ export class ReturnCoinsRequest {
export interface PrepareRefundResult { export interface PrepareRefundResult {
proposalId: string; proposalId: string;
applied: number; effectivePaid: AmountString;
failed: number; gone: AmountString;
total: number; granted: AmountString;
pending: boolean;
amountEffectivePaid: AmountString; awaiting: AmountString;
info: OrderShortInfo; info: OrderShortInfo;
} }

View File

@ -43,6 +43,8 @@ import {
EddsaPublicKeyString, EddsaPublicKeyString,
codecForAmountString, codecForAmountString,
TalerProtocolDuration, TalerProtocolDuration,
codecForTimestamp,
TalerProtocolTimestamp,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
export interface PostOrderRequest { export interface PostOrderRequest {
@ -80,6 +82,15 @@ export const codecForPostOrderResponse = (): Codec<PostOrderResponse> =>
.property("token", codecOptional(codecForString())) .property("token", codecOptional(codecForString()))
.build("PostOrderResponse"); .build("PostOrderResponse");
export const codecForRefundDetails = (): Codec<RefundDetails> =>
buildCodecForObject<RefundDetails>()
.property("reason", codecForString())
.property("pending", codecForBoolean())
.property("amount", codecForString())
.property("timestamp", codecForTimestamp)
.build("PostOrderResponse");
export const codecForCheckPaymentPaidResponse = export const codecForCheckPaymentPaidResponse =
(): Codec<CheckPaymentPaidResponse> => (): Codec<CheckPaymentPaidResponse> =>
buildCodecForObject<CheckPaymentPaidResponse>() buildCodecForObject<CheckPaymentPaidResponse>()
@ -200,7 +211,10 @@ export interface RefundDetails {
reason: string; reason: string;
// when was the refund approved // 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). // Total amount that was refunded (minus a refund fee).
amount: AmountString; amount: AmountString;

View File

@ -1288,6 +1288,12 @@ export interface PurchaseRecord {
*/ */
autoRefundDeadline: TalerProtocolTimestamp | undefined; 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 * Is the payment frozen? I.e. did we encounter
* an error where it doesn't make sense to retry. * an error where it doesn't make sense to retry.

View File

@ -345,7 +345,7 @@ export async function importBackup(
} }
const denomPubHash = const denomPubHash =
cryptoComp.rsaDenomPubToHash[ cryptoComp.rsaDenomPubToHash[
backupDenomination.denom_pub.rsa_public_key backupDenomination.denom_pub.rsa_public_key
]; ];
checkLogicInvariant(!!denomPubHash); checkLogicInvariant(!!denomPubHash);
const existingDenom = await tx.denominations.get([ const existingDenom = await tx.denominations.get([
@ -560,7 +560,7 @@ export async function importBackup(
const amount = Amounts.parseOrThrow(parsedContractTerms.amount); const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
const contractTermsHash = const contractTermsHash =
cryptoComp.proposalIdToContractTermsHash[ cryptoComp.proposalIdToContractTermsHash[
backupProposal.proposal_id backupProposal.proposal_id
]; ];
let maxWireFee: AmountJson; let maxWireFee: AmountJson;
if (parsedContractTerms.max_wire_fee) { if (parsedContractTerms.max_wire_fee) {
@ -704,7 +704,7 @@ export async function importBackup(
const amount = Amounts.parseOrThrow(parsedContractTerms.amount); const amount = Amounts.parseOrThrow(parsedContractTerms.amount);
const contractTermsHash = const contractTermsHash =
cryptoComp.proposalIdToContractTermsHash[ cryptoComp.proposalIdToContractTermsHash[
backupPurchase.proposal_id backupPurchase.proposal_id
]; ];
let maxWireFee: AmountJson; let maxWireFee: AmountJson;
if (parsedContractTerms.max_wire_fee) { if (parsedContractTerms.max_wire_fee) {
@ -755,6 +755,7 @@ export async function importBackup(
autoRefundDeadline: TalerProtocolTimestamp.never(), autoRefundDeadline: TalerProtocolTimestamp.never(),
refundStatusRetryInfo: resetRetryInfo(), refundStatusRetryInfo: resetRetryInfo(),
lastRefundStatusError: undefined, lastRefundStatusError: undefined,
refundAwaiting: undefined,
timestampAccept: backupPurchase.timestamp_accept, timestampAccept: backupPurchase.timestamp_accept,
timestampFirstSuccessfulPay: timestampFirstSuccessfulPay:
backupPurchase.timestamp_first_successful_pay, backupPurchase.timestamp_first_successful_pay,

View File

@ -443,6 +443,7 @@ async function recordConfirmPay(
refundQueryRequested: false, refundQueryRequested: false,
timestampFirstSuccessfulPay: undefined, timestampFirstSuccessfulPay: undefined,
autoRefundDeadline: undefined, autoRefundDeadline: undefined,
refundAwaiting: undefined,
paymentSubmitPending: true, paymentSubmitPending: true,
refunds: {}, refunds: {},
merchantPaySig: undefined, merchantPaySig: undefined,
@ -987,18 +988,16 @@ async function storeFirstPaySuccess(
purchase.lastSessionId = sessionId; purchase.lastSessionId = sessionId;
purchase.payRetryInfo = resetRetryInfo(); purchase.payRetryInfo = resetRetryInfo();
purchase.merchantPaySig = paySig; purchase.merchantPaySig = paySig;
if (isFirst) { const protoAr = purchase.download.contractData.autoRefund;
const protoAr = purchase.download.contractData.autoRefund; if (protoAr) {
if (protoAr) { const ar = Duration.fromTalerProtocolDuration(protoAr);
const ar = Duration.fromTalerProtocolDuration(protoAr); logger.info("auto_refund present");
logger.info("auto_refund present"); purchase.refundQueryRequested = true;
purchase.refundQueryRequested = true; purchase.refundStatusRetryInfo = resetRetryInfo();
purchase.refundStatusRetryInfo = resetRetryInfo(); purchase.lastRefundStatusError = undefined;
purchase.lastRefundStatusError = undefined; purchase.autoRefundDeadline = AbsoluteTime.toTimestamp(
purchase.autoRefundDeadline = AbsoluteTime.toTimestamp( AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
AbsoluteTime.addDuration(AbsoluteTime.now(), ar), );
);
}
} }
await tx.purchases.put(purchase); await tx.purchases.put(purchase);
}); });

View File

@ -101,29 +101,19 @@ export async function prepareRefund(
); );
} }
const awaiting = await queryAndSaveAwaitingRefund(ws, purchase)
const summary = calculateRefundSummary(purchase)
const proposalId = purchase.proposalId; 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 const { contractData: c } = purchase.download
return { return {
proposalId, proposalId,
amountEffectivePaid: Amounts.stringify(purchase.totalPayCost), effectivePaid: Amounts.stringify(summary.amountEffectivePaid),
applied, gone: Amounts.stringify(summary.amountRefundGone),
failed, granted: Amounts.stringify(summary.amountRefundGranted),
total, pending: summary.pendingAtExchange,
awaiting: Amounts.stringify(awaiting),
info: { info: {
contractTermsHash: c.contractTermsHash, contractTermsHash: c.contractTermsHash,
merchant: c.merchant, 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. * Summary of the refund status of a purchase.
*/ */
@ -618,49 +646,15 @@ export async function applyRefund(
throw Error("purchase no longer exists"); throw Error("purchase no longer exists");
} }
const p = purchase; const summary = calculateRefundSummary(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;
}
});
return { return {
contractTermsHash: purchase.download.contractData.contractTermsHash, contractTermsHash: purchase.download.contractData.contractTermsHash,
proposalId: purchase.proposalId, proposalId: purchase.proposalId,
amountEffectivePaid: Amounts.stringify(purchase.totalPayCost), amountEffectivePaid: Amounts.stringify(summary.amountEffectivePaid),
amountRefundGone: Amounts.stringify(amountRefundGone), amountRefundGone: Amounts.stringify(summary.amountRefundGone),
amountRefundGranted: Amounts.stringify(amountRefundGranted), amountRefundGranted: Amounts.stringify(summary.amountRefundGranted),
pendingAtExchange, pendingAtExchange: summary.pendingAtExchange,
info: { info: {
contractTermsHash: purchase.download.contractData.contractTermsHash, contractTermsHash: purchase.download.contractData.contractTermsHash,
merchant: purchase.download.contractData.merchant, merchant: purchase.download.contractData.merchant,
@ -691,6 +685,59 @@ export async function processPurchaseQueryRefund(
); );
} }
async function queryAndSaveAwaitingRefund(
ws: InternalWalletState,
purchase: PurchaseRecord,
waitForAutoRefund?: boolean): Promise<AmountJson> {
const requestUrl = new URL(
`orders/${purchase.download.contractData.orderId}`,
purchase.download.contractData.merchantBaseUrl,
);
requestUrl.searchParams.set(
"h_contract",
purchase.download.contractData.contractTermsHash,
);
// Long-poll for one second
if (waitForAutoRefund) {
requestUrl.searchParams.set("timeout_ms", "1000");
requestUrl.searchParams.set("await_refund_obtained", "yes");
logger.trace("making long-polling request for auto-refund");
}
const resp = await ws.http.get(requestUrl.href);
const orderStatus = await readSuccessResponseJsonOrThrow(
resp,
codecForMerchantOrderStatusPaid(),
);
if (!orderStatus.refunded) {
// Wait for retry ...
return Amounts.getZero(purchase.totalPayCost.currency);
}
const refundAwaiting = Amounts.sub(
Amounts.parseOrThrow(orderStatus.refund_amount),
Amounts.parseOrThrow(orderStatus.refund_taken)
).amount
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( async function processPurchaseQueryRefundImpl(
ws: InternalWalletState, ws: InternalWalletState,
proposalId: string, proposalId: string,
@ -719,33 +766,13 @@ async function processPurchaseQueryRefundImpl(
if (purchase.timestampFirstSuccessfulPay) { if (purchase.timestampFirstSuccessfulPay) {
if ( if (
waitForAutoRefund && !purchase.autoRefundDeadline ||
purchase.autoRefundDeadline &&
!AbsoluteTime.isExpired( !AbsoluteTime.isExpired(
AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline), AbsoluteTime.fromTimestamp(purchase.autoRefundDeadline),
) )
) { ) {
const requestUrl = new URL( const awaitingAmount = await queryAndSaveAwaitingRefund(ws, purchase, waitForAutoRefund)
`orders/${purchase.download.contractData.orderId}`, if (Amounts.isZero(awaitingAmount)) return;
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 requestUrl = new URL( const requestUrl = new URL(

View File

@ -49,6 +49,16 @@ import { processWithdrawGroup } from "./withdraw.js";
const logger = new Logger("taler-wallet-core:transactions.ts"); 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. * Create an event ID from the type and the primary key for the event.
*/ */
@ -286,25 +296,6 @@ export async function getTransactions(
TransactionType.Payment, TransactionType.Payment,
pr.proposalId, 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<string>(); const refundGroupKeys = new Set<string>();
for (const rk of Object.keys(pr.refunds)) { for (const rk of Object.keys(pr.refunds)) {
@ -313,6 +304,9 @@ export async function getTransactions(
refundGroupKeys.add(groupKey); refundGroupKeys.add(groupKey);
} }
let totalRefundRaw = Amounts.getZero(contractData.amount.currency);
let totalRefundEffective = Amounts.getZero(contractData.amount.currency);
for (const groupKey of refundGroupKeys.values()) { for (const groupKey of refundGroupKeys.values()) {
const refundTombstoneId = makeEventId( const refundTombstoneId = makeEventId(
TombstoneTag.DeleteRefund, TombstoneTag.DeleteRefund,
@ -356,6 +350,10 @@ export async function getTransactions(
if (!r0) { if (!r0) {
throw Error("invariant violated"); throw Error("invariant violated");
} }
totalRefundRaw = Amounts.add(totalRefundRaw, amountRaw).amount;
totalRefundEffective = Amounts.add(totalRefundEffective, amountEffective).amount;
transactions.push({ transactions.push({
type: TransactionType.Refund, type: TransactionType.Refund,
info, info,
@ -364,10 +362,34 @@ export async function getTransactions(
timestamp: r0.obtainedTime, timestamp: r0.obtainedTime,
amountEffective: Amounts.stringify(amountEffective), amountEffective: Amounts.stringify(amountEffective),
amountRaw: Amounts.stringify(amountRaw), amountRaw: Amounts.stringify(amountRaw),
refundPending: pr.refundAwaiting === undefined ? undefined : Amounts.stringify(pr.refundAwaiting),
pending: false, pending: false,
frozen: 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) => { tx.tips.iter().forEachAsync(async (tipRecord) => {
@ -419,16 +441,6 @@ export async function getTransactions(
return { transactions: [...txNotPending, ...txPending] }; 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 * Immediately retry the underlying operation
* of a transaction. * of a transaction.
@ -442,28 +454,33 @@ export async function retryTransaction(
const [type, ...rest] = transactionId.split(":"); const [type, ...rest] = transactionId.split(":");
switch (type) { switch (type) {
case TransactionType.Deposit: case TransactionType.Deposit: {
const depositGroupId = rest[0]; const depositGroupId = rest[0];
processDepositGroup(ws, depositGroupId, { processDepositGroup(ws, depositGroupId, {
forceNow: true, forceNow: true,
}); });
break; break;
case TransactionType.Withdrawal: }
case TransactionType.Withdrawal: {
const withdrawalGroupId = rest[0]; const withdrawalGroupId = rest[0];
await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true }); await processWithdrawGroup(ws, withdrawalGroupId, { forceNow: true });
break; break;
case TransactionType.Payment: }
case TransactionType.Payment: {
const proposalId = rest[0]; const proposalId = rest[0];
await processPurchasePay(ws, proposalId, { forceNow: true }); await processPurchasePay(ws, proposalId, { forceNow: true });
break; break;
case TransactionType.Tip: }
case TransactionType.Tip: {
const walletTipId = rest[0]; const walletTipId = rest[0];
await processTip(ws, walletTipId, { forceNow: true }); await processTip(ws, walletTipId, { forceNow: true });
break; break;
case TransactionType.Refresh: }
case TransactionType.Refresh: {
const refreshGroupId = rest[0]; const refreshGroupId = rest[0];
await processRefreshGroup(ws, refreshGroupId, { forceNow: true }); await processRefreshGroup(ws, refreshGroupId, { forceNow: true });
break; break;
}
default: default:
break; break;
} }

View File

@ -1235,10 +1235,10 @@ class InternalWalletStateImpl implements InternalWalletState {
const key = `${exchangeBaseUrl}:${denomPubHash}`; const key = `${exchangeBaseUrl}:${denomPubHash}`;
const cached = this.denomCache[key]; const cached = this.denomCache[key];
if (cached) { if (cached) {
logger.info("using cached denom"); logger.trace("using cached denom");
return cached; return cached;
} }
logger.info("looking up denom denom"); logger.trace("looking up denom denom");
const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]); const d = await tx.denominations.get([exchangeBaseUrl, denomPubHash]);
if (d) { if (d) {
this.denomCache[key] = d; this.denomCache[key] = d;

View File

@ -0,0 +1,2 @@
#!/bin/bash
pnpm run --filter @gnu-taler/taler-wallet-core compile

View File

@ -0,0 +1,3 @@
#!/bin/bash
pnpm run --filter @gnu-taler/taler-util compile

View File

@ -33,6 +33,7 @@ export const Complete = createExample(TestedComponent, {
state: { state: {
status: "completed", status: "completed",
amount: Amounts.parseOrThrow("USD:1"), amount: Amounts.parseOrThrow("USD:1"),
granted: Amounts.parseOrThrow("USD:1"),
hook: undefined, hook: undefined,
merchantName: "the merchant", merchantName: "the merchant",
products: undefined, products: undefined,
@ -44,9 +45,10 @@ export const InProgress = createExample(TestedComponent, {
status: "in-progress", status: "in-progress",
hook: undefined, hook: undefined,
amount: Amounts.parseOrThrow("USD:1"), amount: Amounts.parseOrThrow("USD:1"),
awaitingAmount: Amounts.parseOrThrow("USD:1"),
granted: Amounts.parseOrThrow("USD:0"),
merchantName: "the merchant", merchantName: "the merchant",
products: undefined, products: undefined,
progress: 0.5,
}, },
}); });
@ -58,6 +60,8 @@ export const Ready = createExample(TestedComponent, {
ignore: {}, ignore: {},
amount: Amounts.parseOrThrow("USD:1"), amount: Amounts.parseOrThrow("USD:1"),
awaitingAmount: Amounts.parseOrThrow("USD:1"),
granted: Amounts.parseOrThrow("USD:0"),
merchantName: "the merchant", merchantName: "the merchant",
products: [], products: [],
orderId: "abcdef", orderId: "abcdef",
@ -73,6 +77,8 @@ export const WithAProductList = createExample(TestedComponent, {
accept: {}, accept: {},
ignore: {}, ignore: {},
amount: Amounts.parseOrThrow("USD:1"), amount: Amounts.parseOrThrow("USD:1"),
awaitingAmount: Amounts.parseOrThrow("USD:1"),
granted: Amounts.parseOrThrow("USD:0"),
merchantName: "the merchant", merchantName: "the merchant",
products: [ products: [
{ {

View File

@ -19,7 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm) * @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 { expect } from "chai";
import { mountHook } from "../test-utils.js"; import { mountHook } from "../test-utils.js";
import { SubsHandler } from "./Pay.test.js"; import { SubsHandler } from "./Pay.test.js";
@ -62,10 +62,12 @@ describe("Refund CTA states", () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://refund/asdasdas", { useComponentState("taler://refund/asdasdas", {
prepareRefund: async () => ({ prepareRefund: async () => ({
total: 0, effectivePaid: 'EUR:2',
applied: 0, awaiting: 'EUR:2',
failed: 0, gone: 'EUR:0',
amountEffectivePaid: 'EUR:2', granted: 'EUR:0',
pending: false,
proposalId: '1',
info: { info: {
contractTermsHash: '123', contractTermsHash: '123',
merchant: { merchant: {
@ -107,10 +109,12 @@ describe("Refund CTA states", () => {
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://refund/asdasdas", { useComponentState("taler://refund/asdasdas", {
prepareRefund: async () => ({ prepareRefund: async () => ({
total: 0, effectivePaid: 'EUR:2',
applied: 0, awaiting: 'EUR:2',
failed: 0, gone: 'EUR:0',
amountEffectivePaid: 'EUR:2', granted: 'EUR:0',
pending: false,
proposalId: '1',
info: { info: {
contractTermsHash: '123', contractTermsHash: '123',
merchant: { merchant: {
@ -161,21 +165,30 @@ describe("Refund CTA states", () => {
}); });
it("should be in progress when doing refresh", async () => { 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(); const subscriptions = new SubsHandler();
function notifyMelt(): void { 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) subscriptions.notifyEvent(NotificationType.RefreshMelted)
} }
const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() => const { getLastResultOrThrow, waitNextUpdate, assertNoPendingUpdate } = mountHook(() =>
useComponentState("taler://refund/asdasdas", { useComponentState("taler://refund/asdasdas", {
prepareRefund: async () => ({ prepareRefund: async () => ({
total: 3, awaiting: Amounts.stringify(awaiting),
applied: numApplied, effectivePaid: 'EUR:2',
failed: 0, gone: 'EUR:0',
amountEffectivePaid: 'EUR:2', granted: Amounts.stringify(granted),
pending,
proposalId: '1',
info: { info: {
contractTermsHash: '123', contractTermsHash: '123',
merchant: { merchant: {
@ -201,12 +214,12 @@ describe("Refund CTA states", () => {
{ {
const state = getLastResultOrThrow() const state = getLastResultOrThrow()
if (state.status !== 'in-progress') expect.fail(); if (state.status !== 'in-progress') expect.fail('1');
if (state.hook) expect.fail(); if (state.hook) expect.fail();
expect(state.merchantName).eq('the merchant name'); expect(state.merchantName).eq('the merchant name');
expect(state.products).undefined; expect(state.products).undefined;
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")) 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() notifyMelt()
} }
@ -216,12 +229,12 @@ describe("Refund CTA states", () => {
{ {
const state = getLastResultOrThrow() const state = getLastResultOrThrow()
if (state.status !== 'in-progress') expect.fail(); if (state.status !== 'in-progress') expect.fail('2');
if (state.hook) expect.fail(); if (state.hook) expect.fail();
expect(state.merchantName).eq('the merchant name'); expect(state.merchantName).eq('the merchant name');
expect(state.products).undefined; expect(state.products).undefined;
expect(state.amount).deep.eq(Amounts.parseOrThrow("EUR:2")) 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() notifyMelt()
} }
@ -231,7 +244,7 @@ describe("Refund CTA states", () => {
{ {
const state = getLastResultOrThrow() const state = getLastResultOrThrow()
if (state.status !== 'completed') expect.fail(); if (state.status !== 'completed') expect.fail('3');
if (state.hook) expect.fail(); if (state.hook) expect.fail();
expect(state.merchantName).eq('the merchant name'); expect(state.merchantName).eq('the merchant name');
expect(state.products).undefined; expect(state.products).undefined;

View File

@ -34,7 +34,6 @@ import { LoadingError } from "../components/LoadingError.js";
import { LogoHeader } from "../components/LogoHeader.js"; import { LogoHeader } from "../components/LogoHeader.js";
import { Part } from "../components/Part.js"; import { Part } from "../components/Part.js";
import { import {
Button,
ButtonSuccess, ButtonSuccess,
SubTitle, SubTitle,
WalletAction, WalletAction,
@ -99,6 +98,12 @@ export function View({ state }: ViewProps): VNode {
<Part <Part
big big
title={<i18n.Translate>Total to refund</i18n.Translate>} title={<i18n.Translate>Total to refund</i18n.Translate>}
text={<Amount value={state.awaitingAmount} />}
kind="negative"
/>
<Part
big
title={<i18n.Translate>Refunded</i18n.Translate>}
text={<Amount value={state.amount} />} text={<Amount value={state.amount} />}
kind="negative" kind="negative"
/> />
@ -108,9 +113,9 @@ export function View({ state }: ViewProps): VNode {
<ProductList products={state.products} /> <ProductList products={state.products} />
</section> </section>
) : undefined} ) : undefined}
<section> {/* <section>
<ProgressBar value={state.progress} /> <ProgressBar value={state.progress} />
</section> </section> */}
</WalletAction> </WalletAction>
); );
} }
@ -128,6 +133,14 @@ export function View({ state }: ViewProps): VNode {
<i18n.Translate>this refund is already accepted.</i18n.Translate> <i18n.Translate>this refund is already accepted.</i18n.Translate>
</p> </p>
</section> </section>
<section>
<Part
big
title={<i18n.Translate>Total to refunded</i18n.Translate>}
text={<Amount value={state.granted} />}
kind="negative"
/>
</section>
</WalletAction> </WalletAction>
); );
} }
@ -150,9 +163,23 @@ export function View({ state }: ViewProps): VNode {
<section> <section>
<Part <Part
big big
title={<i18n.Translate>Total to refund</i18n.Translate>} title={<i18n.Translate>Order amount</i18n.Translate>}
text={<Amount value={state.amount} />} text={<Amount value={state.amount} />}
kind="negative" kind="neutral"
/>
{Amounts.isNonZero(state.granted) && (
<Part
big
title={<i18n.Translate>Already refunded</i18n.Translate>}
text={<Amount value={state.granted} />}
kind="neutral"
/>
)}
<Part
big
title={<i18n.Translate>Refund offered</i18n.Translate>}
text={<Amount value={state.awaitingAmount} />}
kind="positive"
/> />
</section> </section>
{state.products && state.products.length ? ( {state.products && state.products.length ? (
@ -164,9 +191,6 @@ export function View({ state }: ViewProps): VNode {
<ButtonSuccess onClick={state.accept.onClick}> <ButtonSuccess onClick={state.accept.onClick}>
<i18n.Translate>Confirm refund</i18n.Translate> <i18n.Translate>Confirm refund</i18n.Translate>
</ButtonSuccess> </ButtonSuccess>
<Button onClick={state.ignore.onClick}>
<i18n.Translate>Ignore</i18n.Translate>
</Button>
</section> </section>
</WalletAction> </WalletAction>
); );
@ -184,6 +208,8 @@ interface Ready {
merchantName: string; merchantName: string;
products: Product[] | undefined; products: Product[] | undefined;
amount: AmountJson; amount: AmountJson;
awaitingAmount: AmountJson;
granted: AmountJson;
accept: ButtonHandler; accept: ButtonHandler;
ignore: ButtonHandler; ignore: ButtonHandler;
orderId: string; orderId: string;
@ -199,7 +225,8 @@ interface InProgress {
merchantName: string; merchantName: string;
products: Product[] | undefined; products: Product[] | undefined;
amount: AmountJson; amount: AmountJson;
progress: number; awaitingAmount: AmountJson;
granted: AmountJson;
} }
interface Completed { interface Completed {
status: "completed"; status: "completed";
@ -207,6 +234,7 @@ interface Completed {
merchantName: string; merchantName: string;
products: Product[] | undefined; products: Product[] | undefined;
amount: AmountJson; amount: AmountJson;
granted: AmountJson;
} }
export function useComponentState( export function useComponentState(
@ -253,25 +281,27 @@ export function useComponentState(
}; };
} }
const pending = refund.total > refund.applied + refund.failed; const awaitingAmount = Amounts.parseOrThrow(refund.awaiting);
const completed = refund.total > 0 && refund.applied === refund.total;
if (pending) { if (Amounts.isZero(awaitingAmount)) {
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) {
return { return {
status: "completed", status: "completed",
hook: undefined, 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, merchantName: info.response.refund.info.merchant.name,
products: info.response.refund.info.products, products: info.response.refund.info.products,
}; };
@ -280,7 +310,9 @@ export function useComponentState(
return { return {
status: "ready", status: "ready",
hook: undefined, 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, merchantName: info.response.refund.info.merchant.name,
products: info.response.refund.info.products, products: info.response.refund.info.products,
orderId: info.response.refund.info.orderId, orderId: info.response.refund.info.orderId,

View File

@ -78,6 +78,9 @@ const exampleData = {
summary: "the summary", summary: "the summary",
fulfillmentMessage: "", fulfillmentMessage: "",
}, },
refundPending: undefined,
totalRefundEffective: "USD:0",
totalRefundRaw: "USD:0",
proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0",
status: PaymentStatus.Accepted, status: PaymentStatus.Accepted,
} as TransactionPayment, } as TransactionPayment,
@ -112,6 +115,7 @@ const exampleData = {
summary: "the summary", summary: "the summary",
fulfillmentMessage: "", fulfillmentMessage: "",
}, },
refundPending: undefined,
} as TransactionRefund, } as TransactionRefund,
}; };

View File

@ -83,6 +83,9 @@ const exampleData = {
summary: "Essay: Why the Devil's Advocate Doesn't Help Reach the Truth", summary: "Essay: Why the Devil's Advocate Doesn't Help Reach the Truth",
fulfillmentMessage: "", fulfillmentMessage: "",
}, },
refundPending: undefined,
totalRefundEffective: "USD:0",
totalRefundRaw: "USD:0",
proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0",
status: PaymentStatus.Accepted, status: PaymentStatus.Accepted,
} as TransactionPayment, } as TransactionPayment,
@ -117,6 +120,7 @@ const exampleData = {
summary: "the summary", summary: "the summary",
fulfillmentMessage: "", fulfillmentMessage: "",
}, },
refundPending: undefined,
} as TransactionRefund, } as TransactionRefund,
}; };

View File

@ -40,7 +40,6 @@ import {
ButtonPrimary, ButtonPrimary,
CenteredDialog, CenteredDialog,
InfoBox, InfoBox,
LargeText,
ListOfProducts, ListOfProducts,
Overlay, Overlay,
RowBorderGray, RowBorderGray,
@ -51,6 +50,7 @@ import {
import { Time } from "../components/Time.js"; import { Time } from "../components/Time.js";
import { useTranslationContext } from "../context/translation.js"; import { useTranslationContext } from "../context/translation.js";
import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js";
import { Pages } from "../NavigationBar.js";
import * as wxApi from "../wxApi.js"; import * as wxApi from "../wxApi.js";
interface Props { interface Props {
@ -344,6 +344,17 @@ export function TransactionView({
Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountRaw),
).amount; ).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 ( return (
<TransactionTemplate> <TransactionTemplate>
<SubTitle> <SubTitle>
@ -360,18 +371,54 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />} text={<Amount value={transaction.amountEffective} />}
kind="negative" kind="negative"
/> />
<Part {Amounts.isNonZero(fee) && (
big <Fragment>
title={<i18n.Translate>Purchase amount</i18n.Translate>} <Part
text={<Amount value={transaction.amountRaw} />} big
kind="neutral" title={<i18n.Translate>Purchase amount</i18n.Translate>}
/> text={<Amount value={transaction.amountRaw} />}
<Part kind="neutral"
big />
title={<i18n.Translate>Fee</i18n.Translate>} <Part
text={<Amount value={fee} />} title={<i18n.Translate>Purchase Fee</i18n.Translate>}
kind="negative" text={<Amount value={fee} />}
/> kind="negative"
/>
</Fragment>
)}
{refunded && (
<Fragment>
<Part
big
title={<i18n.Translate>Total refunded</i18n.Translate>}
text={<Amount value={transaction.totalRefundEffective} />}
kind="positive"
/>
{Amounts.isNonZero(refundFee) && (
<Fragment>
<Part
big
title={<i18n.Translate>Refund amount</i18n.Translate>}
text={<Amount value={transaction.totalRefundRaw} />}
kind="neutral"
/>
<Part
title={<i18n.Translate>Refund fee</i18n.Translate>}
text={<Amount value={refundFee} />}
kind="negative"
/>
</Fragment>
)}
</Fragment>
)}
{pendingRefund !== undefined && Amounts.isNonZero(pendingRefund) && (
<Part
big
title={<i18n.Translate>Refund pending</i18n.Translate>}
text={<Amount value={pendingRefund} />}
kind="positive"
/>
)}
<Part <Part
title={<i18n.Translate>Merchant</i18n.Translate>} title={<i18n.Translate>Merchant</i18n.Translate>}
text={transaction.info.merchant.name} text={transaction.info.merchant.name}
@ -447,18 +494,22 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />} text={<Amount value={transaction.amountEffective} />}
kind="neutral" kind="neutral"
/> />
<Part {Amounts.isNonZero(fee) && (
big <Fragment>
title={<i18n.Translate>Deposit amount</i18n.Translate>} <Part
text={<Amount value={transaction.amountRaw} />} big
kind="positive" title={<i18n.Translate>Deposit amount</i18n.Translate>}
/> text={<Amount value={transaction.amountRaw} />}
<Part kind="positive"
big />
title={<i18n.Translate>Fee</i18n.Translate>} <Part
text={<Amount value={fee} />} big
kind="negative" title={<i18n.Translate>Fee</i18n.Translate>}
/> text={<Amount value={fee} />}
kind="negative"
/>
</Fragment>
)}
{payto && <PartPayto big payto={payto} kind="neutral" />} {payto && <PartPayto big payto={payto} kind="neutral" />}
</TransactionTemplate> </TransactionTemplate>
); );
@ -485,18 +536,22 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />} text={<Amount value={transaction.amountEffective} />}
kind="negative" kind="negative"
/> />
<Part {Amounts.isNonZero(fee) && (
big <Fragment>
title={<i18n.Translate>Refresh amount</i18n.Translate>} <Part
text={<Amount value={transaction.amountRaw} />} big
kind="neutral" title={<i18n.Translate>Refresh amount</i18n.Translate>}
/> text={<Amount value={transaction.amountRaw} />}
<Part kind="neutral"
big />
title={<i18n.Translate>Fee</i18n.Translate>} <Part
text={<Amount value={fee} />} big
kind="negative" title={<i18n.Translate>Fee</i18n.Translate>}
/> text={<Amount value={fee} />}
kind="negative"
/>
</Fragment>
)}
</TransactionTemplate> </TransactionTemplate>
); );
} }
@ -522,18 +577,22 @@ export function TransactionView({
text={<Amount value={transaction.amountRaw} />} text={<Amount value={transaction.amountRaw} />}
kind="positive" kind="positive"
/> />
<Part {Amounts.isNonZero(fee) && (
big <Fragment>
title={<i18n.Translate>Received amount</i18n.Translate>} <Part
text={<Amount value={transaction.amountEffective} />} big
kind="neutral" title={<i18n.Translate>Received amount</i18n.Translate>}
/> text={<Amount value={transaction.amountEffective} />}
<Part kind="neutral"
big />
title={<i18n.Translate>Fee</i18n.Translate>} <Part
text={<Amount value={fee} />} big
kind="negative" title={<i18n.Translate>Fee</i18n.Translate>}
/> text={<Amount value={fee} />}
kind="negative"
/>
</Fragment>
)}
</TransactionTemplate> </TransactionTemplate>
); );
} }
@ -559,37 +618,42 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />} text={<Amount value={transaction.amountEffective} />}
kind="positive" kind="positive"
/> />
<Part {Amounts.isNonZero(fee) && (
big <Fragment>
title={<i18n.Translate>Refund amount</i18n.Translate>} <Part
text={<Amount value={transaction.amountRaw} />} big
kind="neutral" title={<i18n.Translate>Refund amount</i18n.Translate>}
/> text={<Amount value={transaction.amountRaw} />}
<Part kind="neutral"
big />
title={<i18n.Translate>Fee</i18n.Translate>} <Part
text={<Amount value={fee} />} big
kind="negative" title={<i18n.Translate>Fee</i18n.Translate>}
/> text={<Amount value={fee} />}
kind="negative"
/>
</Fragment>
)}
<Part <Part
title={<i18n.Translate>Merchant</i18n.Translate>} title={<i18n.Translate>Merchant</i18n.Translate>}
text={transaction.info.merchant.name} text={transaction.info.merchant.name}
kind="neutral" kind="neutral"
/> />
<Part <Part
title={<i18n.Translate>Purchase</i18n.Translate>} title={<i18n.Translate>Purchase</i18n.Translate>}
text={ text={
transaction.info.fulfillmentUrl ? ( <a
<a href={Pages.balance_transaction.replace(
href={transaction.info.fulfillmentUrl} ":tid",
target="_bank" transaction.refundedTransactionId,
rel="noreferrer" )}
> // href={transaction.info.fulfillmentUrl}
{transaction.info.summary} // target="_bank"
</a> // rel="noreferrer"
) : ( >
transaction.info.summary {transaction.info.summary}
) </a>
} }
kind="neutral" kind="neutral"
/> />