/* This file is part of GNU Taler (C) 2022 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ import { AbsoluteTime, AmountJson, Amounts, Location, MerchantInfo, NotificationType, OrderShortInfo, parsePaytoUri, PaytoUri, stringifyPaytoUri, TalerProtocolTimestamp, Transaction, TransactionDeposit, TransactionRefresh, TransactionRefund, TransactionTip, TransactionType, WithdrawalType, } from "@gnu-taler/taler-util"; import { styled } from "@linaria/react"; import { differenceInSeconds } from "date-fns"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import emptyImg from "../../static/img/empty.png"; import { Amount } from "../components/Amount.js"; import { BankDetailsByPaytoType } from "../components/BankDetailsByPaytoType.js"; import { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; import { Loading } from "../components/Loading.js"; import { LoadingError } from "../components/LoadingError.js"; import { Kind, Part, PartCollapsible, PartPayto } from "../components/Part.js"; import { QR } from "../components/QR.js"; import { ShowFullContractTermPopup } from "../components/ShowFullContractTermPopup.js"; import { CenteredDialog, InfoBox, ListOfProducts, Overlay, Row, SmallLightText, SubTitle, WarningBox, } from "../components/styled/index.js"; import { Time } from "../components/Time.js"; import { useTranslationContext } from "../context/translation.js"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import { Button } from "../mui/Button.js"; import { Pages } from "../NavigationBar.js"; import * as wxApi from "../wxApi.js"; interface Props { tid: string; goToWalletHistory: (currency?: string) => Promise; } async function getTransaction(tid: string): Promise { const res = await wxApi.getTransactionById(tid); return res; } export function TransactionPage({ tid, goToWalletHistory }: Props): VNode { const { i18n } = useTranslationContext(); const state = useAsyncAsHook(() => getTransaction(tid), [tid]); useEffect(() => { return wxApi.onUpdateNotification( [NotificationType.WithdrawGroupFinished], () => { state?.retry(); }, ); }); if (!state) { return ; } if (state.hasError) { return ( Could not load the transaction information } error={state} /> ); } const currency = Amounts.parse(state.response.amountRaw)?.currency; return ( { null; }} onDelete={() => wxApi.deleteTransaction(tid).then(() => goToWalletHistory(currency)) } onRetry={() => wxApi.retryTransaction(tid).then(() => goToWalletHistory(currency)) } onRefund={(id) => wxApi.applyRefundFromPurchaseId(id).then()} onBack={() => goToWalletHistory(currency)} /> ); } export interface WalletTransactionProps { transaction: Transaction; onSend: () => Promise; onDelete: () => Promise; onRetry: () => Promise; onRefund: (id: string) => Promise; onBack: () => Promise; } const PurchaseDetailsTable = styled.table` width: 100%; & > tr > td:nth-child(2n) { text-align: right; } `; export function TransactionView({ transaction, onDelete, onRetry, onSend, onRefund, }: WalletTransactionProps): VNode { const [confirmBeforeForget, setConfirmBeforeForget] = useState(false); async function doCheckBeforeForget(): Promise { if ( transaction.pending && transaction.type === TransactionType.Withdrawal ) { setConfirmBeforeForget(true); } else { onDelete(); } } const SHOWING_RETRY_THRESHOLD_SECS = 30; const { i18n } = useTranslationContext(); function TransactionTemplate({ children, }: { children: ComponentChildren; }): VNode { const showSend = false; // (transaction.type === TransactionType.PeerPullCredit || // transaction.type === TransactionType.PeerPushDebit) && // !transaction.info.completed; const showRetry = transaction.error !== undefined || transaction.timestamp.t_s === "never" || (transaction.pending && differenceInSeconds(new Date(), transaction.timestamp.t_s * 1000) > SHOWING_RETRY_THRESHOLD_SECS); return (
There was an error trying to complete the transaction } error={transaction?.error} /> {transaction.pending && ( This transaction is not completed )}
{children}
{showSend ? ( ) : null}
{showRetry ? ( ) : null}
); } if (transaction.type === TransactionType.Withdrawal) { const total = Amounts.parseOrThrow(transaction.amountEffective); const chosen = Amounts.parseOrThrow(transaction.amountRaw); return ( {confirmBeforeForget ? (
Caution!
If you have already wired money to the exchange you will loose the chance to get the coins form it.
) : undefined}
{transaction.exchangeBaseUrl}
{!transaction.pending ? undefined : transaction.withdrawalDetails .type === WithdrawalType.ManualTransfer ? ( Make sure to use the correct subject, otherwise the money will not arrive in this wallet. ) : ( {!transaction.withdrawalDetails.confirmed && transaction.withdrawalDetails.bankConfirmationUrl ? (
The bank did not yet confirmed the wire transfer. Go to the {` `} bank site {" "} and check there is no pending step.
) : undefined} {transaction.withdrawalDetails.confirmed && ( Bank has confirmed the wire transfer. Waiting for the exchange to send the coins )}
)} Details} text={ } />
); } if (transaction.type === TransactionType.Payment) { const pendingRefund = transaction.refundPending === undefined ? undefined : Amounts.parseOrThrow(transaction.refundPending); const price = { raw: Amounts.parseOrThrow(transaction.amountRaw), effective: Amounts.parseOrThrow(transaction.amountEffective), }; const refund = { raw: Amounts.parseOrThrow(transaction.totalRefundRaw), effective: Amounts.parseOrThrow(transaction.totalRefundEffective), }; const total = Amounts.sub(price.effective, refund.effective).amount; return (
{transaction.info.fulfillmentUrl ? ( {transaction.info.summary} ) : ( transaction.info.summary )}

{transaction.refunds.length > 0 ? ( Refunds} text={ {transaction.refunds.map((r, i) => { return ( ); })}
{}{" "} was refunded {" "} on{" "} {
} kind="neutral" /> ) : undefined} {pendingRefund !== undefined && Amounts.isNonZero(pendingRefund) && ( Merchant created a refund for this order but was not automatically picked up. Offer} text={} kind="positive" />
)} Merchant} text={} kind="neutral" /> Invoice ID} text={transaction.info.orderId} kind="neutral" /> Details} text={ } kind="neutral" /> ); } if (transaction.type === TransactionType.Deposit) { const total = Amounts.parseOrThrow(transaction.amountRaw); const payto = parsePaytoUri(transaction.targetPaytoUri); return (
{!payto ? transaction.targetPaytoUri : }
{payto && } Details} text={} kind="neutral" />
); } if (transaction.type === TransactionType.Refresh) { const total = Amounts.sub( Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountEffective), ).amount; return (
{transaction.exchangeBaseUrl}
Details} text={} />
); } if (transaction.type === TransactionType.Tip) { const total = Amounts.parseOrThrow(transaction.amountEffective); return (
{transaction.merchantBaseUrl}
{/* Merchant} text={} kind="neutral" /> */} Details} text={} />
); } if (transaction.type === TransactionType.Refund) { const total = Amounts.parseOrThrow(transaction.amountEffective); return (
{transaction.info.summary}
Merchant} text={transaction.info.merchant.name} kind="neutral" /> Original order ID} text={ {transaction.info.orderId} } kind="neutral" /> Purchase summary} text={transaction.info.summary} kind="neutral" /> Details} text={} />
); } function ShowQrWithCopy({ text }: { text: string }): VNode { const [showing, setShowing] = useState(false); async function copy(): Promise { navigator.clipboard.writeText(text); } async function toggle(): Promise { setShowing((s) => !s); } if (showing) { return (
); } return (
{text.substring(0, 64)}...
); } if (transaction.type === TransactionType.PeerPullCredit) { const total = Amounts.parseOrThrow(transaction.amountEffective); return (
Invoice
{transaction.info.summary ? ( Subject} text={transaction.info.summary} kind="neutral" /> ) : undefined} Exchange} text={transaction.exchangeBaseUrl} kind="neutral" /> {transaction.pending && ( URI} text={} kind="neutral" /> )} Details} text={ } />
); } if (transaction.type === TransactionType.PeerPullDebit) { const total = Amounts.parseOrThrow(transaction.amountEffective); return (
Invoice
{transaction.info.summary ? ( Subject} text={transaction.info.summary} kind="neutral" /> ) : undefined} Exchange} text={transaction.exchangeBaseUrl} kind="neutral" /> Details} text={ } />
); } if (transaction.type === TransactionType.PeerPushDebit) { const total = Amounts.parseOrThrow(transaction.amountEffective); return (
Transfer
{transaction.info.summary ? ( Subject} text={transaction.info.summary} kind="neutral" /> ) : undefined} Exchange} text={transaction.exchangeBaseUrl} kind="neutral" /> {transaction.pending && ( URI} text={} kind="neutral" /> )} Details} text={ } />
); } if (transaction.type === TransactionType.PeerPushCredit) { const total = Amounts.parseOrThrow(transaction.amountEffective); return (
Transfer
{transaction.info.summary ? ( Subject} text={transaction.info.summary} kind="neutral" /> ) : undefined} Exchange} text={transaction.exchangeBaseUrl} kind="neutral" /> Details} text={ } />
); } return
; } export function MerchantDetails({ merchant, }: { merchant: MerchantInfo; }): VNode { return (
{merchant.logo && (
)}

{merchant.name}

{merchant.website && ( {merchant.website} )} {merchant.email && ( {merchant.email} )}
); } function DeliveryDetails({ date, location, }: { date: TalerProtocolTimestamp | undefined; location: Location | undefined; }): VNode { const { i18n } = useTranslationContext(); return ( {location && ( {location.country && ( Country {location.country} )} {location.address_lines && ( Address lines {location.address_lines} )} {location.building_number && ( Building number {location.building_number} )} {location.building_name && ( Building name {location.building_name} )} {location.street && ( Street {location.street} )} {location.post_code && ( Post code {location.post_code} )} {location.town_location && ( Town location {location.town_location} )} {location.town && ( Town {location.town} )} {location.district && ( District {location.district} )} {location.country_subdivision && ( Country subdivision {location.country_subdivision} )} )} {!location || !date ? undefined : (
)} {date && ( Date )}
); } export function ExchangeDetails({ exchange }: { exchange: string }): VNode { return ( ); } export interface AmountWithFee { effective: AmountJson; raw: AmountJson; } export function InvoiceDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); const fee = Amounts.sub(amount.raw, amount.effective).amount; const maxFrac = [amount.raw, amount.effective, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return ( Invoice {Amounts.isNonZero(fee) && ( Transaction fees )}
Total
); } export function TransferDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); const fee = Amounts.sub(amount.raw, amount.effective).amount; const maxFrac = [amount.raw, amount.effective, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return ( Transfer {Amounts.isNonZero(fee) && ( Transaction fees )}
Total
); } export function WithdrawDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); const fee = Amounts.sub(amount.raw, amount.effective).amount; const maxFrac = [amount.raw, amount.effective, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return ( Withdraw {Amounts.isNonZero(fee) && ( Transaction fees )}
Total
); } export function PurchaseDetails({ price, refund, info, proposalId, }: { price: AmountWithFee; refund?: AmountWithFee; info: OrderShortInfo; proposalId: string; }): VNode { const { i18n } = useTranslationContext(); const partialFee = Amounts.sub(price.effective, price.raw).amount; const refundFee = !refund ? Amounts.getZero(price.effective.currency) : Amounts.sub(refund.raw, refund.effective).amount; const fee = Amounts.sum([partialFee, refundFee]).amount; const hasProducts = info.products && info.products.length > 0; const hasShipping = info.delivery_date !== undefined || info.delivery_location !== undefined; const showLargePic = (): void => { return; }; const total = !refund ? price.effective : Amounts.sub(price.effective, refund.effective).amount; return ( Price {refund && Amounts.isNonZero(refund.raw) && ( Refunded )} {Amounts.isNonZero(fee) && ( Transaction fees )}
Total {hasProducts && ( Products} text={ {info.products?.map((p, k) => (
{p.quantity && p.quantity > 0 && ( x {p.quantity} {p.unit} )}
{p.description}
))}
} /> )} {hasShipping && ( Delivery} text={ } /> )}
); } function RefundDetails({ transaction, }: { transaction: TransactionRefund; }): VNode { const { i18n } = useTranslationContext(); const r = Amounts.parseOrThrow(transaction.amountRaw); const e = Amounts.parseOrThrow(transaction.amountEffective); const fee = Amounts.sub(r, e).amount; const maxFrac = [r, e, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return ( Amount {Amounts.isNonZero(fee) && ( Transaction fees )}
Total
); } function DepositDetails({ transaction, }: { transaction: TransactionDeposit; }): VNode { const { i18n } = useTranslationContext(); const r = Amounts.parseOrThrow(transaction.amountRaw); const e = Amounts.parseOrThrow(transaction.amountEffective); const fee = Amounts.sub(r, e).amount; const maxFrac = [r, e, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return ( Amount {Amounts.isNonZero(fee) && ( Transaction fees )}
Total transfer
); } function RefreshDetails({ transaction, }: { transaction: TransactionRefresh; }): VNode { const { i18n } = useTranslationContext(); const r = Amounts.parseOrThrow(transaction.amountRaw); const e = Amounts.parseOrThrow(transaction.amountEffective); const fee = Amounts.sub(r, e).amount; const maxFrac = [r, e, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return ( Amount Transaction fees
Total
); } function TipDetails({ transaction }: { transaction: TransactionTip }): VNode { const { i18n } = useTranslationContext(); const r = Amounts.parseOrThrow(transaction.amountRaw); const e = Amounts.parseOrThrow(transaction.amountEffective); const fee = Amounts.sub(r, e).amount; const maxFrac = [r, e, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return ( Amount {Amounts.isNonZero(fee) && ( Transaction fees )}
Total
); } function Header({ timestamp, total, children, kind, type, }: { timestamp: TalerProtocolTimestamp; total: AmountJson; children: ComponentChildren; kind: Kind; type: string; }): VNode { return (
{children}
} kind={kind} />
); } function NicePayto({ payto }: { payto: PaytoUri }): VNode { if (payto.isKnown) { switch (payto.targetType) { case "bitcoin": { return
{payto.targetPath.substring(0, 20)}...
; } case "x-taler-bank": { const url = new URL("/", `https://${payto.host}`); return (
{payto.account}
{url.toString()}
); } case "iban": { return
{payto.targetPath.substring(0, 20)}
; } } } return {stringifyPaytoUri(payto)}; }