/* 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, AmountString, MerchantInfo, NotificationType, OrderShortInfo, parsePaytoUri, PaytoUri, stringifyPaytoUri, TalerErrorCode, TalerPreciseTimestamp, Transaction, TransactionAction, TransactionDeposit, TransactionIdStr, TransactionInternalWithdrawal, TransactionMajorState, TransactionMinorState, TransactionType, TransactionWithdrawal, 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 { isPast } from "date-fns"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { Amount } from "../components/Amount.js"; import { BankDetailsByPaytoType } from "../components/BankDetailsByPaytoType.js"; import { AlertView, ErrorAlertView } from "../components/CurrentAlerts.js"; import { EnabledBySettings } from "../components/EnabledBySettings.js"; import { Loading } from "../components/Loading.js"; import { Kind, Part, PartPayto } from "../components/Part.js"; import { QR } from "../components/QR.js"; import { ShowFullContractTermPopup } from "../components/ShowFullContractTermPopup.js"; import { CenteredDialog, ErrorBox, InfoBox, Link, Overlay, SmallLightText, SubTitle, SvgIcon, 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 { useSettings } from "../hooks/useSettings.js"; import { Button } from "../mui/Button.js"; import { SafeHandler } from "../mui/handlers.js"; import { Pages } from "../NavigationBar.js"; import refreshIcon from "../svg/refresh_24px.inline.svg"; import { assertUnreachable } from "../utils/index.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.FailTransaction, { 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 showButton = getShowButtonStates(transaction); 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}
{showButton.abort && ( )} {showButton.resume && settings.suspendIndividualTransaction && ( )} {showButton.suspend && settings.suspendIndividualTransaction && ( )} {showButton.fail && ( )} {showButton.remove && ( )}
); } 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 ) { const conversion = transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer ? transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] : []; return (
{transaction.exchangeBaseUrl}
{transaction.txState.major !== TransactionMajorState.Pending ? undefined : transaction.txState .minor === TransactionMinorState.KycRequired || transaction.txState.minor === TransactionMinorState.AmlRequired ? undefined : transaction .withdrawalDetails.type === WithdrawalType.ManualTransfer && transaction.withdrawalDetails.exchangeCreditAccountDetails ? ( {transaction.withdrawalDetails.exchangeCreditAccountDetails.length > 1 ? Now the payment service provider is waiting for to be transferred. Select one of the accounts and use the information below to complete the operation by making a wire transfer from your bank account. : Now the payment service provider is waiting for to be transferred. Use the information below to complete the operation by making a wire transfer from your bank account. } ) : ( //integrated bank withdrawal )} } />
); } 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.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.txState.minor === TransactionMinorState.Ready && transaction.talerUri && !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.talerUri && ( } kind="neutral" /> )} } />
); } if (transaction.type === TransactionType.PeerPushCredit) { return (
Transfer
{transaction.info.summary ? ( ) : undefined} } />
); } if (transaction.type === TransactionType.Recoup) { throw Error("recoup transaction not implemented"); } if (transaction.type === TransactionType.Reward) { throw Error("recoup transaction not implemented"); } 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 total = direction === "credit" ? effective : raw; const value = direction === "debit" ? effective : raw; const fee = Amounts.sub(value, total).amount; const maxFrac = [effective, raw, fee] .map((a) => Amounts.maxFractionalDigits(a)) .reduce((c, p) => Math.max(c, p), 0); return { total, value, fee, maxFrac, }; } export function InvoiceCreationDetails({ amount, }: { amount: AmountWithFee; }): VNode { const { i18n } = useTranslationContext(); return ( Invoice {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } export function InvoicePaymentDetails({ amount, }: { amount: AmountWithFee; }): VNode { const { i18n } = useTranslationContext(); return ( Invoice {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } export function TransferCreationDetails({ amount, }: { amount: AmountWithFee; }): VNode { const { i18n } = useTranslationContext(); return ( Sent {Amounts.isNonZero(amount.fee) && ( Fees )}
Transfer
); } export function TransferPickupDetails({ amount, }: { amount: AmountWithFee; }): VNode { const { i18n } = useTranslationContext(); return ( Transfer {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } export function WithdrawDetails({ conversion, amount, }: { conversion?: AmountJson; 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 ( {conversion ? ( Transfer {conversion.fraction === amount.value.fraction && conversion.value === amount.value.value ? undefined : ( Converted )} ) : ( Transfer )} {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) && ( 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: AmountString; }[]; 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 ( Sent {Amounts.isNonZero(amount.fee) && ( Fees )}
Total
); } function RefreshDetails({ amount }: { amount: AmountWithFee }): VNode { const { i18n } = useTranslationContext(); return ( Refresh 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)}...
); } function getShowButtonStates(transaction: Transaction) { let abort = false; let fail = false; let resume = false; let remove = false; let suspend = false; transaction.txActions.forEach((a) => { switch (a) { case TransactionAction.Delete: remove = true; break; case TransactionAction.Suspend: suspend = true; break; case TransactionAction.Resume: resume = true; break; case TransactionAction.Abort: abort = true; break; case TransactionAction.Fail: fail = true; break; case TransactionAction.Retry: break; default: assertUnreachable(a); break; } }); return { abort, fail, resume, remove, suspend }; } function ShowWithdrawalDetailForBankIntegrated({ transaction, }: { transaction: TransactionWithdrawal | TransactionInternalWithdrawal; }): VNode { const { i18n } = useTranslationContext(); const [showDetails, setShowDetails] = useState(false); if ( transaction.txState.major !== TransactionMajorState.Pending || transaction.withdrawalDetails.type === WithdrawalType.ManualTransfer ) return ; const raw = Amounts.parseOrThrow(transaction.amountRaw); return ( { e.preventDefault(); setShowDetails(!showDetails); }} > show details {showDetails && ( )} {!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. )}
); }