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;
/**
* Fee for the refund.
*/
* Fee for the refund.
*/
refund_fee: string;
/**
@ -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");

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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.

View File

@ -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,

View File

@ -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);
});

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 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(

View File

@ -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;
}

View File

@ -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;

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: {
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: [
{

View File

@ -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;

View File

@ -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,

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,18 +371,54 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />}
kind="negative"
/>
<Part
big
title={<i18n.Translate>Purchase amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
{Amounts.isNonZero(fee) && (
<Fragment>
<Part
big
title={<i18n.Translate>Purchase amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="neutral"
/>
<Part
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,18 +494,22 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Deposit amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="positive"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
{Amounts.isNonZero(fee) && (
<Fragment>
<Part
big
title={<i18n.Translate>Deposit amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="positive"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
</Fragment>
)}
{payto && <PartPayto big payto={payto} kind="neutral" />}
</TransactionTemplate>
);
@ -485,18 +536,22 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />}
kind="negative"
/>
<Part
big
title={<i18n.Translate>Refresh amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
{Amounts.isNonZero(fee) && (
<Fragment>
<Part
big
title={<i18n.Translate>Refresh amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
</Fragment>
)}
</TransactionTemplate>
);
}
@ -522,18 +577,22 @@ export function TransactionView({
text={<Amount value={transaction.amountRaw} />}
kind="positive"
/>
<Part
big
title={<i18n.Translate>Received amount</i18n.Translate>}
text={<Amount value={transaction.amountEffective} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
{Amounts.isNonZero(fee) && (
<Fragment>
<Part
big
title={<i18n.Translate>Received amount</i18n.Translate>}
text={<Amount value={transaction.amountEffective} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
</Fragment>
)}
</TransactionTemplate>
);
}
@ -559,37 +618,42 @@ export function TransactionView({
text={<Amount value={transaction.amountEffective} />}
kind="positive"
/>
<Part
big
title={<i18n.Translate>Refund amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
text={<Amount value={fee} />}
kind="negative"
/>
{Amounts.isNonZero(fee) && (
<Fragment>
<Part
big
title={<i18n.Translate>Refund amount</i18n.Translate>}
text={<Amount value={transaction.amountRaw} />}
kind="neutral"
/>
<Part
big
title={<i18n.Translate>Fee</i18n.Translate>}
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"
>
{transaction.info.summary}
</a>
) : (
transaction.info.summary
)
<a
href={Pages.balance_transaction.replace(
":tid",
transaction.refundedTransactionId,
)}
// href={transaction.info.fulfillmentUrl}
// target="_bank"
// rel="noreferrer"
>
{transaction.info.summary}
</a>
}
kind="neutral"
/>