/* 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, TalerErrorCode, TalerPreciseTimestamp, TalerProtocolTimestamp, Transaction, TransactionDeposit, TransactionIdStr, TransactionMajorState, TransactionMinorState, TransactionType, TranslatedString, WithdrawalType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { styled } from "@linaria/react"; import { differenceInSeconds, isPast } 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 { CopyButton } from "../components/CopyButton.js"; import { AlertView, ErrorAlertView } from "../components/CurrentAlerts.js"; import { Loading } from "../components/Loading.js"; import { Kind, Part, PartCollapsible, PartPayto } from "../components/Part.js"; import { QR } from "../components/QR.js"; import { ShowFullContractTermPopup } from "../components/ShowFullContractTermPopup.js"; import { CenteredDialog, ErrorBox, InfoBox, ListOfProducts, Overlay, Row, SmallLightText, SubTitle, SuccessBox, WarningBox, } from "../components/styled/index.js"; import { Time } from "../components/Time.js"; import { alertFromError, useAlertContext } from "../context/alert.js"; import { useBackendContext } from "../context/backend.js"; import { useAsyncAsHook } from "../hooks/useAsyncAsHook.js"; import { Button } from "../mui/Button.js"; import { SafeHandler } from "../mui/handlers.js"; import { Pages } from "../NavigationBar.js"; import { assertUnreachable } from "../utils/index.js"; import { EnabledBySettings } from "../components/EnabledBySettings.js"; import { useSettings } from "../hooks/useSettings.js"; interface Props { tid: string; goToWalletHistory: (currency?: string) => Promise; } export function TransactionPage({ tid, goToWalletHistory }: Props): VNode { const transactionId = tid as TransactionIdStr; //FIXME: validate const { i18n } = useTranslationContext(); const api = useBackendContext(); const state = useAsyncAsHook( () => api.wallet.call(WalletApiOperation.GetTransactionById, { transactionId, }), [transactionId], ); useEffect(() => api.listener.onUpdateNotification( [NotificationType.TransactionStateTransition], state?.retry, ), ); if (!state) { return ; } if (state.hasError) { return ( ); } const currency = Amounts.parse(state.response.amountRaw)?.currency; return ( { await api.wallet.call(WalletApiOperation.CancelAbortingTransaction, { transactionId, }); goToWalletHistory(currency); }} onSuspend={async () => { await api.wallet.call(WalletApiOperation.SuspendTransaction, { transactionId, }); goToWalletHistory(currency); }} onResume={async () => { await api.wallet.call(WalletApiOperation.ResumeTransaction, { transactionId, }); goToWalletHistory(currency); }} onAbort={async () => { await api.wallet.call(WalletApiOperation.AbortTransaction, { transactionId, }); goToWalletHistory(currency); }} onRetry={async () => { await api.wallet.call(WalletApiOperation.RetryTransaction, { transactionId, }); goToWalletHistory(currency); }} onDelete={async () => { await api.wallet.call(WalletApiOperation.DeleteTransaction, { transactionId, }); goToWalletHistory(currency); }} onRefund={async (transactionId) => { await api.wallet.call(WalletApiOperation.StartRefundQuery, { transactionId, }); }} onBack={() => goToWalletHistory(currency)} /> ); } export interface WalletTransactionProps { transaction: Transaction; onCancel: () => Promise; onSuspend: () => Promise; onResume: () => Promise; onAbort: () => Promise; onDelete: () => Promise; onRetry: () => Promise; onRefund: (id: TransactionIdStr) => Promise; onBack: () => Promise; } const PurchaseDetailsTable = styled.table` width: 100%; & > tr > td:nth-child(2n) { text-align: right; } `; type TransactionTemplateProps = Omit< Omit, "onBack" > & { children: ComponentChildren; }; function TransactionTemplate({ transaction, onDelete, onRetry, onAbort, onResume, onSuspend, onCancel, children, }: TransactionTemplateProps): VNode { const { i18n } = useTranslationContext(); const [confirmBeforeForget, setConfirmBeforeForget] = useState(false); const [confirmBeforeCancel, setConfirmBeforeCancel] = useState(false); const { safely } = useAlertContext(); const [settings] = useSettings(); async function doCheckBeforeForget(): Promise { if ( transaction.txState.major === TransactionMajorState.Pending && transaction.type === TransactionType.Withdrawal ) { setConfirmBeforeForget(true); } else { onDelete(); } } async function doCheckBeforeCancel(): Promise { setConfirmBeforeCancel(true); } const hasCancelTransactionImplemented = transaction.type === TransactionType.Payment; const hasAbortTransactionImplemented = transaction.type === TransactionType.Withdrawal || transaction.type === TransactionType.Deposit || transaction.type === TransactionType.Payment; const isFinalState = transaction.txState.major === TransactionMajorState.Aborted || transaction.txState.major === TransactionMajorState.Done || transaction.txState.major === TransactionMajorState.Failed; const showAbort = hasAbortTransactionImplemented && transaction.txState.major === TransactionMajorState.Pending; const showCancel = hasCancelTransactionImplemented && transaction.txState.major === TransactionMajorState.Aborting; const showRetry = !isFinalState && transaction.txState.major !== TransactionMajorState.Pending && transaction.txState.major !== TransactionMajorState.Aborting; const showDelete = isFinalState; const showResume = transaction.txState.major === TransactionMajorState.Suspended || transaction.txState.major === TransactionMajorState.SuspendedAborting; const showSuspend = transaction.txState.major === TransactionMajorState.Pending || transaction.txState.major === TransactionMajorState.Aborting; return (
{transaction?.error && // FIXME: wallet core should stop sending this error on KYC transaction.error.code !== TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED ? ( ) : undefined} {transaction.txState.minor === TransactionMinorState.KycRequired && ( Follow this link to the{` `} KYC verifier ) : ( i18n.str`No more information has been provided` ), }} /> )} {transaction.txState.minor === TransactionMinorState.AmlRequired && ( The transaction has been blocked since the account required an AML check )} {transaction.txState.major === TransactionMajorState.Pending && ( This transaction is not completed )} {transaction.txState.major === TransactionMajorState.Aborted && ( This transaction was aborted )} {transaction.txState.major === TransactionMajorState.Failed && ( This transaction failed )} {confirmBeforeForget ? (
Caution!
If you have already wired money to the exchange you will loose the chance to get the coins form it.
) : undefined} {confirmBeforeCancel ? (
Caution!
Doing a cancellation while the transaction still active might result in lost coins. Do you still want to cancel the transaction?
) : undefined}
{children}
{showRetry && ( )} {showAbort && ( )} {showResume && settings.suspendIndividualTransaction && ( )} {showSuspend && settings.suspendIndividualTransaction && ( )} {showCancel && ( )} {showDelete && ( )}
); } export function TransactionView({ transaction, onDelete, onAbort, onBack, onResume, onSuspend, onRetry, onRefund, onCancel, }: WalletTransactionProps): VNode { const { i18n } = useTranslationContext(); const { safely } = useAlertContext(); const raw = Amounts.parseOrThrow(transaction.amountRaw); const effective = Amounts.parseOrThrow(transaction.amountEffective); if ( transaction.type === TransactionType.Withdrawal || transaction.type === TransactionType.InternalWithdrawal ) { return (
{transaction.exchangeBaseUrl}
{/**FIXME: DD37 check if this holds */} {transaction.txState.major !== TransactionMajorState.Pending ? undefined : transaction .withdrawalDetails.type === WithdrawalType.ManualTransfer ? ( //manual withdrawal
                      
                        
                          Payto URI
                        
                      
                    
{transaction.withdrawalDetails.exchangePaytoUris[0]} transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer ? transaction.withdrawalDetails.exchangePaytoUris[0] : "" } />
Make sure to use the correct subject, otherwise the money will not arrive in this wallet.
) : ( //integrated bank withdrawal {!transaction.withdrawalDetails.confirmed && transaction.withdrawalDetails.bankConfirmationUrl ? (
Wire transfer need a confirmation. Go to the{" "} bank site {" "} and check wire transfer operation to exchange account is complete.
) : undefined} {transaction.withdrawalDetails.confirmed && !transaction.withdrawalDetails.reserveIsReady && ( Bank has confirmed the wire transfer. Waiting for the exchange to send the coins )} {transaction.withdrawalDetails.confirmed && transaction.withdrawalDetails.reserveIsReady && ( Exchange is ready to send the coins, withdrawal in progress. )}
)} } />
); } if (transaction.type === TransactionType.Payment) { const pendingRefund = transaction.refundPending === undefined ? undefined : Amounts.parseOrThrow(transaction.refundPending); const effectiveRefund = Amounts.parseOrThrow( transaction.totalRefundEffective, ); return (
{transaction.info.fulfillmentUrl ? ( {transaction.info.summary} ) : ( transaction.info.summary )}

{transaction.refunds.length > 0 ? ( {transaction.refunds.map((r, i) => { return ( {}{" "} was refunded {" "} on{" "} { ); })} } kind="neutral" /> ) : undefined} {pendingRefund !== undefined && Amounts.isNonZero(pendingRefund) && ( {transaction.refundQueryActive ? ( Refund is in progress. ) : ( Merchant created a refund for this order but was not automatically picked up. )} } kind="positive" /> {transaction.refundQueryActive ? undefined : (
)} )} {transaction.posConfirmation ? ( {transaction.posConfirmation}, }} /> ) : undefined} } kind="neutral" /> } kind="neutral" /> ); } if (transaction.type === TransactionType.Deposit) { const payto = parsePaytoUri(transaction.targetPaytoUri); const wireTime = AbsoluteTime.fromProtocolTimestamp( transaction.wireTransferDeadline, ); const shouldBeWired = wireTime.t_ms !== "never" && isPast(wireTime.t_ms); return (
{!payto ? transaction.targetPaytoUri : }
{payto && } } kind="neutral" /> {!shouldBeWired ? ( } kind="neutral" /> ) : transaction.wireTransferProgress === 0 ? ( ) : transaction.wireTransferProgress === 100 ? ( } kind="neutral" /> ) : ( )}
); } if (transaction.type === TransactionType.Refresh) { return (
{"Refresh"}
} />
); } if (transaction.type === TransactionType.Tip) { return (
{transaction.merchantBaseUrl}
{/* } kind="neutral" /> */} } />
); } if (transaction.type === TransactionType.Refund) { return (
{transaction.paymentInfo ? ( {transaction.paymentInfo.summary} ) : ( -- deleted -- )}
} />
); } if (transaction.type === TransactionType.PeerPullCredit) { return (
Invoice
{transaction.info.summary ? ( ) : undefined} {transaction.txState.major === TransactionMajorState.Pending && !transaction.error && ( } kind="neutral" /> )} } />
); } if (transaction.type === TransactionType.PeerPullDebit) { return (
Invoice
{transaction.info.summary ? ( ) : undefined} } />
); } if (transaction.type === TransactionType.PeerPushDebit) { const total = Amounts.parseOrThrow(transaction.amountEffective); return (
Transfer
{transaction.info.summary ? ( ) : undefined} {/* {transaction.pending && ( //pending is not-received )} */} } kind="neutral" /> } />
); } if (transaction.type === TransactionType.PeerPushCredit) { return (
Transfer
{transaction.info.summary ? ( ) : undefined} } />
); } assertUnreachable(transaction); } 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 { value: AmountJson; fee: AmountJson; total: AmountJson; maxFrac: number; } export function getAmountWithFee( effective: AmountJson, raw: AmountJson, direction: "credit" | "debit", ): AmountWithFee { const fee = direction === "credit" ? Amounts.sub(raw, effective).amount : Amounts.sub(effective, raw).amount; const maxFrac = [effective, raw, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return { total: effective, value: raw, fee, maxFrac, }; } export function InvoiceDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); return ( Invoice {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } export function TransferDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); return ( Transfer {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } export function WithdrawDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); const maxFrac = [amount.fee, amount.fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); const total = Amounts.add(amount.value, amount.fee).amount; return ( Withdraw {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } export function PurchaseDetails({ price, effectiveRefund, info, proposalId, }: { price: AmountWithFee; effectiveRefund?: AmountJson; info: OrderShortInfo; proposalId: string; }): VNode { const { i18n } = useTranslationContext(); const total = Amounts.add(price.value, price.fee).amount; const hasProducts = info.products && info.products.length > 0; const hasShipping = info.delivery_date !== undefined || info.delivery_location !== undefined; const showLargePic = (): void => { return; }; return ( Price {Amounts.isNonZero(price.fee) && ( Transaction fees )} {effectiveRefund && Amounts.isNonZero(effectiveRefund) ? (
Subtotal Refunded
Total
) : (
Total
)} {hasProducts && ( {info.products?.map((p, k) => (
{p.quantity && p.quantity > 0 && ( x {p.quantity} {p.unit} )}
{p.description}
))} } /> )} {hasShipping && ( } /> )}
); } function RefundDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); return ( Refund {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } type AmountAmountByWireTransferByWire = { id: string; amount: string; }[]; function calculateAmountByWireTransfer( state: TransactionDeposit["trackingState"], ): AmountAmountByWireTransferByWire { const allTracking = Object.values(state ?? {}); //group tracking by wtid, sum amounts const trackByWtid = allTracking.reduce((prev, cur) => { const fee = Amounts.parseOrThrow(cur.wireFee); const raw = Amounts.parseOrThrow(cur.amountRaw); const total = !prev[cur.wireTransferId] ? raw : Amounts.add(prev[cur.wireTransferId].total, raw).amount; prev[cur.wireTransferId] = { total, fee, }; return prev; }, {} as Record); //remove wire fee from total amount return Object.entries(trackByWtid).map(([id, info]) => ({ id, amount: Amounts.stringify(Amounts.sub(info.total, info.fee).amount), })); } function TrackingDepositDetails({ trackingState, }: { trackingState: TransactionDeposit["trackingState"]; }): VNode { const { i18n } = useTranslationContext(); const wireTransfers = calculateAmountByWireTransfer(trackingState); return ( Transfer identification Amount {wireTransfers.map((wire) => ( {wire.id} ))} ); } function DepositDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); return ( Deposit {Amounts.isNonZero(amount.fee) && ( Fees )}
Total transfer
); } function RefreshDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); return ( Refresh Fees
Total
); } function TipDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); return ( Tip {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } function Header({ timestamp, total, children, kind, type, }: { timestamp: TalerPreciseTimestamp; total: AmountJson; children: ComponentChildren; kind: Kind; type: TranslatedString; }): 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.href}
); } case "iban": { return
{payto.targetPath.substring(0, 20)}
; } } } return {stringifyPaytoUri(payto)}; } function ShowQrWithCopy({ text }: { text: string }): VNode { const [showing, setShowing] = useState(false); const { i18n } = useTranslationContext(); async function copy(): Promise { navigator.clipboard.writeText(text); } async function toggle(): Promise { setShowing((s) => !s); } if (showing) { return (
); } return (
{text.substring(0, 64)}...
); }