feat: awaiting refund
This commit is contained in:
parent
c02dbc833b
commit
e4ea201943
@ -888,18 +888,18 @@ export type BlindedDenominationSignature =
|
||||
| RsaBlindedDenominationSignature
|
||||
| CSBlindedDenominationSignature;
|
||||
|
||||
export const codecForBlindedDenominationSignature = () =>
|
||||
buildCodecForUnion<BlindedDenominationSignature>()
|
||||
.discriminateOn("cipher")
|
||||
.alternative(DenomKeyType.Rsa, codecForRsaBlindedDenominationSignature())
|
||||
.build("BlindedDenominationSignature");
|
||||
|
||||
export const codecForRsaBlindedDenominationSignature = () =>
|
||||
buildCodecForObject<RsaBlindedDenominationSignature>()
|
||||
.property("cipher", codecForConstString(DenomKeyType.Rsa))
|
||||
.property("blinded_rsa_signature", codecForString())
|
||||
.build("RsaBlindedDenominationSignature");
|
||||
|
||||
export const codecForBlindedDenominationSignature = () =>
|
||||
buildCodecForUnion<BlindedDenominationSignature>()
|
||||
.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<MerchantOrderStatusPaid> =>
|
||||
buildCodecForObject<MerchantOrderStatusPaid>()
|
||||
.property("refund_amount", codecForString())
|
||||
.property("refund_taken", codecForString())
|
||||
.property("refund_pending", codecForBoolean())
|
||||
.property("refunded", codecForBoolean())
|
||||
.build("MerchantOrderStatusPaid");
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<PostOrderResponse> =>
|
||||
.property("token", codecOptional(codecForString()))
|
||||
.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 =
|
||||
(): Codec<CheckPaymentPaidResponse> =>
|
||||
buildCodecForObject<CheckPaymentPaidResponse>()
|
||||
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -443,6 +443,7 @@ async function recordConfirmPay(
|
||||
refundQueryRequested: false,
|
||||
timestampFirstSuccessfulPay: undefined,
|
||||
autoRefundDeadline: undefined,
|
||||
refundAwaiting: undefined,
|
||||
paymentSubmitPending: true,
|
||||
refunds: {},
|
||||
merchantPaySig: undefined,
|
||||
@ -987,7 +988,6 @@ 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);
|
||||
@ -999,7 +999,6 @@ async function storeFirstPaySuccess(
|
||||
AbsoluteTime.addDuration(AbsoluteTime.now(), ar),
|
||||
);
|
||||
}
|
||||
}
|
||||
await tx.purchases.put(purchase);
|
||||
});
|
||||
}
|
||||
|
@ -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<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(
|
||||
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(
|
||||
|
@ -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<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
2
packages/taler-wallet-webextension/compile_core.sh
Executable file
2
packages/taler-wallet-webextension/compile_core.sh
Executable file
@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
pnpm run --filter @gnu-taler/taler-wallet-core compile
|
3
packages/taler-wallet-webextension/compile_util.sh
Executable file
3
packages/taler-wallet-webextension/compile_util.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
pnpm run --filter @gnu-taler/taler-util compile
|
||||
|
@ -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: [
|
||||
{
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
<Part
|
||||
big
|
||||
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} />}
|
||||
kind="negative"
|
||||
/>
|
||||
@ -108,9 +113,9 @@ export function View({ state }: ViewProps): VNode {
|
||||
<ProductList products={state.products} />
|
||||
</section>
|
||||
) : undefined}
|
||||
<section>
|
||||
{/* <section>
|
||||
<ProgressBar value={state.progress} />
|
||||
</section>
|
||||
</section> */}
|
||||
</WalletAction>
|
||||
);
|
||||
}
|
||||
@ -128,6 +133,14 @@ export function View({ state }: ViewProps): VNode {
|
||||
<i18n.Translate>this refund is already accepted.</i18n.Translate>
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Total to refunded</i18n.Translate>}
|
||||
text={<Amount value={state.granted} />}
|
||||
kind="negative"
|
||||
/>
|
||||
</section>
|
||||
</WalletAction>
|
||||
);
|
||||
}
|
||||
@ -150,9 +163,23 @@ export function View({ state }: ViewProps): VNode {
|
||||
<section>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Total to refund</i18n.Translate>}
|
||||
title={<i18n.Translate>Order amount</i18n.Translate>}
|
||||
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>
|
||||
{state.products && state.products.length ? (
|
||||
@ -164,9 +191,6 @@ export function View({ state }: ViewProps): VNode {
|
||||
<ButtonSuccess onClick={state.accept.onClick}>
|
||||
<i18n.Translate>Confirm refund</i18n.Translate>
|
||||
</ButtonSuccess>
|
||||
<Button onClick={state.ignore.onClick}>
|
||||
<i18n.Translate>Ignore</i18n.Translate>
|
||||
</Button>
|
||||
</section>
|
||||
</WalletAction>
|
||||
);
|
||||
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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 (
|
||||
<TransactionTemplate>
|
||||
<SubTitle>
|
||||
@ -360,6 +371,8 @@ export function TransactionView({
|
||||
text={<Amount value={transaction.amountEffective} />}
|
||||
kind="negative"
|
||||
/>
|
||||
{Amounts.isNonZero(fee) && (
|
||||
<Fragment>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Purchase amount</i18n.Translate>}
|
||||
@ -367,11 +380,45 @@ export function TransactionView({
|
||||
kind="neutral"
|
||||
/>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Fee</i18n.Translate>}
|
||||
title={<i18n.Translate>Purchase Fee</i18n.Translate>}
|
||||
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
|
||||
title={<i18n.Translate>Merchant</i18n.Translate>}
|
||||
text={transaction.info.merchant.name}
|
||||
@ -447,6 +494,8 @@ export function TransactionView({
|
||||
text={<Amount value={transaction.amountEffective} />}
|
||||
kind="neutral"
|
||||
/>
|
||||
{Amounts.isNonZero(fee) && (
|
||||
<Fragment>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Deposit amount</i18n.Translate>}
|
||||
@ -459,6 +508,8 @@ export function TransactionView({
|
||||
text={<Amount value={fee} />}
|
||||
kind="negative"
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
{payto && <PartPayto big payto={payto} kind="neutral" />}
|
||||
</TransactionTemplate>
|
||||
);
|
||||
@ -485,6 +536,8 @@ export function TransactionView({
|
||||
text={<Amount value={transaction.amountEffective} />}
|
||||
kind="negative"
|
||||
/>
|
||||
{Amounts.isNonZero(fee) && (
|
||||
<Fragment>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Refresh amount</i18n.Translate>}
|
||||
@ -497,6 +550,8 @@ export function TransactionView({
|
||||
text={<Amount value={fee} />}
|
||||
kind="negative"
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</TransactionTemplate>
|
||||
);
|
||||
}
|
||||
@ -522,6 +577,8 @@ export function TransactionView({
|
||||
text={<Amount value={transaction.amountRaw} />}
|
||||
kind="positive"
|
||||
/>
|
||||
{Amounts.isNonZero(fee) && (
|
||||
<Fragment>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Received amount</i18n.Translate>}
|
||||
@ -534,6 +591,8 @@ export function TransactionView({
|
||||
text={<Amount value={fee} />}
|
||||
kind="negative"
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</TransactionTemplate>
|
||||
);
|
||||
}
|
||||
@ -559,6 +618,8 @@ export function TransactionView({
|
||||
text={<Amount value={transaction.amountEffective} />}
|
||||
kind="positive"
|
||||
/>
|
||||
{Amounts.isNonZero(fee) && (
|
||||
<Fragment>
|
||||
<Part
|
||||
big
|
||||
title={<i18n.Translate>Refund amount</i18n.Translate>}
|
||||
@ -571,25 +632,28 @@ export function TransactionView({
|
||||
text={<Amount value={fee} />}
|
||||
kind="negative"
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
<Part
|
||||
title={<i18n.Translate>Merchant</i18n.Translate>}
|
||||
text={transaction.info.merchant.name}
|
||||
kind="neutral"
|
||||
/>
|
||||
|
||||
<Part
|
||||
title={<i18n.Translate>Purchase</i18n.Translate>}
|
||||
text={
|
||||
transaction.info.fulfillmentUrl ? (
|
||||
<a
|
||||
href={transaction.info.fulfillmentUrl}
|
||||
target="_bank"
|
||||
rel="noreferrer"
|
||||
href={Pages.balance_transaction.replace(
|
||||
":tid",
|
||||
transaction.refundedTransactionId,
|
||||
)}
|
||||
// href={transaction.info.fulfillmentUrl}
|
||||
// target="_bank"
|
||||
// rel="noreferrer"
|
||||
>
|
||||
{transaction.info.summary}
|
||||
</a>
|
||||
) : (
|
||||
transaction.info.summary
|
||||
)
|
||||
}
|
||||
kind="neutral"
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user