diff options
Diffstat (limited to 'packages/taler-wallet-webextension/src/wallet/Transaction.tsx')
-rw-r--r-- | packages/taler-wallet-webextension/src/wallet/Transaction.tsx | 2060 |
1 files changed, 1262 insertions, 798 deletions
diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index e70f5fbd1..1f0293352 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -18,76 +18,84 @@ import { AbsoluteTime, AmountJson, Amounts, - Location, + AmountString, + DenomLossEventType, MerchantInfo, NotificationType, OrderShortInfo, parsePaytoUri, PaytoUri, stringifyPaytoUri, - TalerProtocolTimestamp, + TalerErrorCode, + TalerPreciseTimestamp, Transaction, + TransactionAction, TransactionDeposit, - TransactionRefresh, - TransactionRefund, - TransactionTip, + 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 { differenceInSeconds } from "date-fns"; +import { 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 { ErrorTalerOperation } from "../components/ErrorTalerOperation.js"; +import { AlertView, ErrorAlertView } from "../components/CurrentAlerts.js"; +import { EnabledBySettings } from "../components/EnabledBySettings.js"; import { Loading } from "../components/Loading.js"; -import { LoadingError } from "../components/LoadingError.js"; -import { Kind, Part, PartCollapsible, PartPayto } from "../components/Part.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, - ListOfProducts, + Link, Overlay, - Row, SmallLightText, SubTitle, + SvgIcon, WarningBox, } from "../components/styled/index.js"; import { Time } from "../components/Time.js"; -import { useTranslationContext } from "../context/translation.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 { wxApi } from "../wxApi.js"; +import refreshIcon from "../svg/refresh_24px.inline.svg"; +import { assertUnreachable } from "../utils/index.js"; interface Props { tid: string; goToWalletHistory: (currency?: string) => Promise<void>; } -export function TransactionPage({ - tid: transactionId, - goToWalletHistory, -}: Props): VNode { +export function TransactionPage({ tid, goToWalletHistory }: Props): VNode { + const transactionId = tid as TransactionIdStr; //FIXME: validate const { i18n } = useTranslationContext(); - + const api = useBackendContext(); const state = useAsyncAsHook( () => - wxApi.wallet.call(WalletApiOperation.GetTransactionById, { + api.wallet.call(WalletApiOperation.GetTransactionById, { transactionId, }), [transactionId], ); useEffect(() => - wxApi.listener.onUpdateNotification( - [NotificationType.WithdrawGroupFinished], + api.listener.onUpdateNotification( + [NotificationType.TransactionStateTransition], state?.retry, ), ); @@ -98,13 +106,12 @@ export function TransactionPage({ if (state.hasError) { return ( - <LoadingError - title={ - <i18n.Translate> - Could not load the transaction information - </i18n.Translate> - } - error={state} + <ErrorAlertView + error={alertFromError( + i18n, + i18n.str`Could not load transaction information`, + state, + )} /> ); } @@ -114,24 +121,45 @@ export function TransactionPage({ return ( <TransactionView transaction={state.response} - onSend={async () => { - null; + onCancel={async () => { + await api.wallet.call(WalletApiOperation.FailTransaction, { + transactionId, + }); + goToWalletHistory(currency); }} - onDelete={async () => { - await wxApi.wallet.call(WalletApiOperation.DeleteTransaction, { + 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 wxApi.wallet.call(WalletApiOperation.RetryTransaction, { + await api.wallet.call(WalletApiOperation.RetryTransaction, { transactionId, }); goToWalletHistory(currency); }} - onRefund={async (purchaseId) => { - await wxApi.wallet.call(WalletApiOperation.ApplyRefundFromPurchaseId, { - purchaseId, + onDelete={async () => { + await api.wallet.call(WalletApiOperation.DeleteTransaction, { + transactionId, + }); + goToWalletHistory(currency); + }} + onRefund={async (transactionId) => { + await api.wallet.call(WalletApiOperation.StartRefundQuery, { + transactionId, }); }} onBack={() => goToWalletHistory(currency)} @@ -141,10 +169,13 @@ export function TransactionPage({ export interface WalletTransactionProps { transaction: Transaction; - onSend: () => Promise<void>; + onCancel: () => Promise<void>; + onSuspend: () => Promise<void>; + onResume: () => Promise<void>; + onAbort: () => Promise<void>; onDelete: () => Promise<void>; onRetry: () => Promise<void>; - onRefund: (id: string) => Promise<void>; + onRefund: (id: TransactionIdStr) => Promise<void>; onBack: () => Promise<void>; } @@ -156,18 +187,32 @@ const PurchaseDetailsTable = styled.table` } `; -export function TransactionView({ +type TransactionTemplateProps = Omit< + Omit<WalletTransactionProps, "onRefund">, + "onBack" +> & { + children: ComponentChildren; +}; + +function TransactionTemplate({ transaction, onDelete, onRetry, - onSend, - onRefund, -}: WalletTransactionProps): VNode { + 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<void> { if ( - transaction.pending && + transaction.txState.major === TransactionMajorState.Pending && transaction.type === TransactionType.Withdrawal ) { setConfirmBeforeForget(true); @@ -176,76 +221,87 @@ export function TransactionView({ } } - const SHOWING_RETRY_THRESHOLD_SECS = 30; - - const { i18n } = useTranslationContext(); + async function doCheckBeforeCancel(): Promise<void> { + setConfirmBeforeCancel(true); + } - 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); + const showButton = getShowButtonStates(transaction); - return ( - <Fragment> - <section style={{ padding: 8, textAlign: "center" }}> - <ErrorTalerOperation - title={ + return ( + <Fragment> + <section style={{ padding: 8, textAlign: "center" }}> + {transaction?.error && + // FIXME: wallet core should stop sending this error on KYC + transaction.error.code !== + TalerErrorCode.WALLET_WITHDRAWAL_KYC_REQUIRED ? ( + <ErrorAlertView + error={alertFromError( + i18n, + i18n.str`There was an error trying to complete the transaction.`, + transaction.error, + )} + /> + ) : undefined} + {transaction.txState.major === TransactionMajorState.Pending && + (transaction.txState.minor === TransactionMinorState.KycRequired ? ( + <AlertView + alert={{ + type: "warning", + message: i18n.str`KYC check required for the transaction to complete.`, + description: + transaction.kycUrl && + typeof transaction.kycUrl === "string" ? ( + <div> + <i18n.Translate> + Follow this link to the{` `} + <a + rel="noreferrer" + target="_bank" + href={transaction.kycUrl} + > + KYC verifier. + </a> + </i18n.Translate> + </div> + ) : ( + i18n.str`No additional information has been provided.` + ), + }} + /> + ) : transaction.txState.minor === + TransactionMinorState.AmlRequired ? ( + <WarningBox> <i18n.Translate> - There was an error trying to complete the transaction + The transaction has been blocked since the account required an + AML check. </i18n.Translate> - } - error={transaction?.error} - /> - {transaction.pending && ( + </WarningBox> + ) : ( <WarningBox> - <i18n.Translate>This transaction is not completed</i18n.Translate> + <div style={{ justifyContent: "center", lineHeight: "25px" }}> + <i18n.Translate> + This transaction is not completed + </i18n.Translate> + <Link onClick={onRetry} style={{ padding: 0 }}> + <SvgIcon + title={i18n.str`Retry`} + dangerouslySetInnerHTML={{ __html: refreshIcon }} + color="black" + /> + </Link> + </div> </WarningBox> - )} - </section> - <section>{children}</section> - <footer> - <div> - {showSend ? ( - <Button variant="contained" onClick={onSend}> - <i18n.Translate>Send</i18n.Translate> - </Button> - ) : null} - </div> - <div> - {showRetry ? ( - <Button variant="contained" onClick={onRetry}> - <i18n.Translate>Retry</i18n.Translate> - </Button> - ) : null} - <Button - variant="contained" - color="error" - onClick={doCheckBeforeForget} - > - <i18n.Translate>Forget</i18n.Translate> - </Button> - </div> - </footer> - </Fragment> - ); - } - - if (transaction.type === TransactionType.Withdrawal) { - const total = Amounts.parseOrThrow(transaction.amountEffective); - const chosen = Amounts.parseOrThrow(transaction.amountRaw); - return ( - <TransactionTemplate> + ))} + {transaction.txState.major === TransactionMajorState.Aborted && ( + <InfoBox> + <i18n.Translate>This transaction was aborted.</i18n.Translate> + </InfoBox> + )} + {transaction.txState.major === TransactionMajorState.Failed && ( + <ErrorBox> + <i18n.Translate>This transaction failed.</i18n.Translate> + </ErrorBox> + )} {confirmBeforeForget ? ( <Overlay> <CenteredDialog> @@ -262,118 +318,205 @@ export function TransactionView({ <Button variant="contained" color="secondary" - onClick={async () => setConfirmBeforeForget(false)} + onClick={ + (async () => + setConfirmBeforeForget(false)) as SafeHandler<void> + } > <i18n.Translate>Cancel</i18n.Translate> </Button> - <Button variant="contained" color="error" onClick={onDelete}> + <Button + variant="contained" + color="error" + onClick={safely("delete transaction", onDelete)} + > <i18n.Translate>Confirm</i18n.Translate> </Button> </footer> </CenteredDialog> </Overlay> ) : undefined} + {confirmBeforeCancel ? ( + <Overlay> + <CenteredDialog> + <header> + <i18n.Translate>Caution!</i18n.Translate> + </header> + <section> + <i18n.Translate> + Doing a cancellation while the transaction still active might + result in lost coins. Do you still want to cancel the + transaction? + </i18n.Translate> + </section> + <footer> + <Button + variant="contained" + color="secondary" + onClick={ + (async () => + setConfirmBeforeCancel(false)) as SafeHandler<void> + } + > + <i18n.Translate>No</i18n.Translate> + </Button> + + <Button + variant="contained" + color="error" + onClick={safely("cancel active transaction", onCancel)} + > + <i18n.Translate>Yes</i18n.Translate> + </Button> + </footer> + </CenteredDialog> + </Overlay> + ) : undefined} + </section> + <section>{children}</section> + <footer> + <div /> + <div> + {showButton.abort && ( + <Button + variant="contained" + onClick={safely("abort transaction", onAbort)} + > + <i18n.Translate>Abort</i18n.Translate> + </Button> + )} + {showButton.resume && settings.suspendIndividualTransaction && ( + <Button + variant="contained" + onClick={safely("resume transaction", onResume)} + > + <i18n.Translate>Resume</i18n.Translate> + </Button> + )} + {showButton.suspend && settings.suspendIndividualTransaction && ( + <Button + variant="contained" + onClick={safely("suspend transaction", onSuspend)} + > + <i18n.Translate>Suspend</i18n.Translate> + </Button> + )} + {showButton.fail && ( + <Button + variant="contained" + color="error" + onClick={doCheckBeforeCancel as SafeHandler<void>} + > + <i18n.Translate>Cancel</i18n.Translate> + </Button> + )} + {showButton.remove && ( + <Button + variant="contained" + color="error" + onClick={doCheckBeforeForget as SafeHandler<void>} + > + <i18n.Translate>Delete</i18n.Translate> + </Button> + )} + </div> + </footer> + </Fragment> + ); +} + +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 ?? [] + // : []; + const blockedByKycOrAml = + transaction.txState.minor === TransactionMinorState.KycRequired || + transaction.txState.minor === TransactionMinorState.AmlRequired; + return ( + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Withdrawal`} - total={total} + total={effective} kind="positive" > {transaction.exchangeBaseUrl} </Header> - {!transaction.pending ? undefined : transaction.withdrawalDetails - .type === WithdrawalType.ManualTransfer ? ( + {transaction.txState.major !== TransactionMajorState.Pending || + blockedByKycOrAml ? undefined : transaction.withdrawalDetails.type === + WithdrawalType.ManualTransfer && + transaction.withdrawalDetails.exchangeCreditAccountDetails ? ( <Fragment> - <BankDetailsByPaytoType - amount={chosen} - exchangeBaseUrl={transaction.exchangeBaseUrl} - payto={parsePaytoUri( - transaction.withdrawalDetails.exchangePaytoUris[0], + <InfoBox> + {transaction.withdrawalDetails.exchangeCreditAccountDetails + .length > 1 ? ( + <span> + <i18n.Translate> + Now the payment service provider is waiting for{" "} + <Amount value={raw} /> 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. + </i18n.Translate> + </span> + ) : ( + <span> + <i18n.Translate> + Now the payment service provider is waiting for{" "} + <Amount value={raw} /> to be transferred. Use the + information below to complete the operation by making a wire + transfer from your bank account. + </i18n.Translate> + </span> )} + </InfoBox> + <BankDetailsByPaytoType + amount={raw} + accounts={ + transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] + } subject={transaction.withdrawalDetails.reservePub} /> - <table> - <tbody> - <tr> - <td> - <pre> - <b> - <a - target="_bank" - rel="noreferrer" - title="RFC 8905 for designating targets for payments" - href="https://tools.ietf.org/html/rfc8905" - > - Payto URI - </a> - </b> - </pre> - </td> - <td width="100%" style={{ wordBreak: "break-all" }}> - {transaction.withdrawalDetails.exchangePaytoUris[0]} - </td> - <td> - <CopyButton - getContent={() => - transaction.withdrawalDetails.type === - WithdrawalType.ManualTransfer - ? transaction.withdrawalDetails.exchangePaytoUris[0] - : "" - } - /> - </td> - </tr> - </tbody> - </table> - <WarningBox> - <i18n.Translate> - Make sure to use the correct subject, otherwise the money will - not arrive in this wallet. - </i18n.Translate> - </WarningBox> </Fragment> ) : ( - <Fragment> - {!transaction.withdrawalDetails.confirmed && - transaction.withdrawalDetails.bankConfirmationUrl ? ( - <InfoBox> - <div style={{ display: "block" }}> - <i18n.Translate> - Wire transfer need a confirmation. Go to the - <a - href={transaction.withdrawalDetails.bankConfirmationUrl} - target="_blank" - rel="noreferrer" - style={{ display: "inline" }} - > - <i18n.Translate>bank site</i18n.Translate> - </a>{" "} - and check wire transfer operation to exchange account is - complete. - </i18n.Translate> - </div> - </InfoBox> - ) : undefined} - {transaction.withdrawalDetails.confirmed && ( - <InfoBox> - <i18n.Translate> - Bank has confirmed the wire transfer. Waiting for the exchange - to send the coins - </i18n.Translate> - </InfoBox> - )} - </Fragment> + //integrated bank withdrawal + <ShowWithdrawalDetailForBankIntegrated transaction={transaction} /> )} <Part - title={<i18n.Translate>Details</i18n.Translate>} + title={i18n.str`Details`} text={ <WithdrawDetails - amount={{ - effective: Amounts.parseOrThrow(transaction.amountEffective), - raw: Amounts.parseOrThrow(transaction.amountRaw), - }} + amount={getAmountWithFee(effective, raw, "credit")} /> } /> @@ -387,21 +530,23 @@ export function TransactionView({ ? 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; + const effectiveRefund = Amounts.parseOrThrow( + transaction.totalRefundEffective, + ); return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onRetry={onRetry} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} - total={total} + total={effective} type={i18n.str`Payment`} kind="negative" > @@ -420,7 +565,7 @@ export function TransactionView({ <br /> {transaction.refunds.length > 0 ? ( <Part - title={<i18n.Translate>Refunds</i18n.Translate>} + title={i18n.str`Refunds`} text={ <table> {transaction.refunds.map((r, i) => { @@ -439,12 +584,13 @@ export function TransactionView({ on{" "} { <Time - timestamp={AbsoluteTime.fromTimestamp( + timestamp={AbsoluteTime.fromProtocolTimestamp( r.timestamp, )} format="dd MMMM yyyy" /> } + . </i18n.Translate> </td> </tr> @@ -457,225 +603,260 @@ export function TransactionView({ ) : undefined} {pendingRefund !== undefined && Amounts.isNonZero(pendingRefund) && ( <InfoBox> - <i18n.Translate> - Merchant created a refund for this order but was not automatically - picked up. - </i18n.Translate> + {transaction.refundQueryActive ? ( + <i18n.Translate>Refund is in progress.</i18n.Translate> + ) : ( + <i18n.Translate> + Merchant created a refund for this order but was not + automatically picked up. + </i18n.Translate> + )} <Part - title={<i18n.Translate>Offer</i18n.Translate>} + title={i18n.str`Offer`} text={<Amount value={pendingRefund} />} kind="positive" /> - <div> - <div /> + {transaction.refundQueryActive ? undefined : ( <div> - <Button - variant="contained" - onClick={() => onRefund(transaction.proposalId)} - > - <i18n.Translate>Accept</i18n.Translate> - </Button> + <div /> + <div> + <Button + variant="contained" + onClick={safely("refund transaction", () => + onRefund(transaction.transactionId), + )} + > + <i18n.Translate>Accept</i18n.Translate> + </Button> + </div> </div> - </div> + )} </InfoBox> )} + {transaction.posConfirmation ? ( + <AlertView + alert={{ + type: "info", + message: i18n.str`Confirmation code`, + description: <pre>{transaction.posConfirmation}</pre>, + }} + /> + ) : undefined} <Part - title={<i18n.Translate>Merchant</i18n.Translate>} + title={i18n.str`Merchant`} text={<MerchantDetails merchant={transaction.info.merchant} />} kind="neutral" /> <Part - title={<i18n.Translate>Invoice ID</i18n.Translate>} - text={transaction.info.orderId} + title={i18n.str`Invoice ID`} + text={transaction.info.orderId as TranslatedString} kind="neutral" /> <Part - title={<i18n.Translate>Details</i18n.Translate>} + title={i18n.str`Details`} text={ <PurchaseDetails - price={price} - refund={refund} + price={getAmountWithFee(effective, raw, "debit")} + effectiveRefund={effectiveRefund} info={transaction.info} - proposalId={transaction.proposalId} /> } kind="neutral" /> + <ShowFullContractTermPopup transactionId={transaction.transactionId} /> </TransactionTemplate> ); } if (transaction.type === TransactionType.Deposit) { - const total = Amounts.parseOrThrow(transaction.amountRaw); const payto = parsePaytoUri(transaction.targetPaytoUri); + + const wireTime = AbsoluteTime.fromProtocolTimestamp( + transaction.wireTransferDeadline, + ); + const shouldBeWired = wireTime.t_ms !== "never" && isPast(wireTime.t_ms); return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Deposit`} - total={total} + total={effective} kind="negative" > {!payto ? transaction.targetPaytoUri : <NicePayto payto={payto} />} </Header> {payto && <PartPayto payto={payto} kind="neutral" />} <Part - title={<i18n.Translate>Details</i18n.Translate>} - text={<DepositDetails transaction={transaction} />} - kind="neutral" - /> - <Part - title={<i18n.Translate>Wire transfer deadline</i18n.Translate>} + title={i18n.str`Details`} text={ - <Time - timestamp={AbsoluteTime.fromTimestamp( - transaction.wireTransferDeadline, - )} - format="dd MMMM yyyy 'at' HH:mm" + <DepositDetails + amount={getAmountWithFee(effective, raw, "debit")} /> } kind="neutral" /> + {!shouldBeWired ? ( + <Part + title={i18n.str`Wire transfer deadline.`} + text={ + <Time timestamp={wireTime} format="dd MMMM yyyy 'at' HH:mm" /> + } + kind="neutral" + /> + ) : transaction.wireTransferProgress === 0 ? ( + <AlertView + alert={{ + type: "warning", + message: i18n.str`Wire transfer is not initiated.`, + description: i18n.str` `, + }} + /> + ) : transaction.wireTransferProgress === 100 ? ( + <Fragment> + <AlertView + alert={{ + type: "success", + message: i18n.str`Wire transfer completed.`, + description: i18n.str` `, + }} + /> + <Part + title={i18n.str`Transfer details`} + text={ + <TrackingDepositDetails + trackingState={transaction.trackingState} + /> + } + kind="neutral" + /> + </Fragment> + ) : ( + <AlertView + alert={{ + type: "info", + message: i18n.str`Wire transfer in progress.`, + description: i18n.str` `, + }} + /> + )} </TransactionTemplate> ); } if (transaction.type === TransactionType.Refresh) { - const total = Amounts.sub( - Amounts.parseOrThrow(transaction.amountRaw), - Amounts.parseOrThrow(transaction.amountEffective), - ).amount; - return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Refresh`} - total={total} + total={effective} kind="negative" > - {transaction.exchangeBaseUrl} - </Header> - <Part - title={<i18n.Translate>Details</i18n.Translate>} - text={<RefreshDetails transaction={transaction} />} - /> - </TransactionTemplate> - ); - } - - if (transaction.type === TransactionType.Tip) { - const total = Amounts.parseOrThrow(transaction.amountEffective); - - return ( - <TransactionTemplate> - <Header - timestamp={transaction.timestamp} - type={i18n.str`Tip`} - total={total} - kind="positive" - > - {transaction.merchantBaseUrl} + {"Refresh"} </Header> - {/* <Part - title={<i18n.Translate>Merchant</i18n.Translate>} - text={<MerchantDetails merchant={transaction.merchant} />} - kind="neutral" - /> */} <Part - title={<i18n.Translate>Details</i18n.Translate>} - text={<TipDetails transaction={transaction} />} + title={i18n.str`Details`} + text={ + <RefreshDetails + amount={getAmountWithFee(effective, raw, "debit")} + /> + } /> </TransactionTemplate> ); } if (transaction.type === TransactionType.Refund) { - const total = Amounts.parseOrThrow(transaction.amountEffective); return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Refund`} - total={total} + total={effective} kind="positive" > - {transaction.info.summary} - </Header> - - <Part - title={<i18n.Translate>Merchant</i18n.Translate>} - text={transaction.info.merchant.name} - kind="neutral" - /> - <Part - title={<i18n.Translate>Original order ID</i18n.Translate>} - text={ + {transaction.paymentInfo ? ( <a href={Pages.balanceTransaction({ tid: transaction.refundedTransactionId, })} > - {transaction.info.orderId} + {transaction.paymentInfo.summary} </a> + ) : ( + <span style={{ color: "gray" }}>-- deleted --</span> + )} + </Header> + + <Part + title={i18n.str`Merchant`} + text={ + (transaction.paymentInfo + ? transaction.paymentInfo.merchant.name + : "-- deleted --") as TranslatedString } kind="neutral" /> <Part - title={<i18n.Translate>Purchase summary</i18n.Translate>} - text={transaction.info.summary} + title={i18n.str`Purchase summary`} + text={ + (transaction.paymentInfo + ? transaction.paymentInfo.summary + : "-- deleted --") as TranslatedString + } kind="neutral" /> <Part - title={<i18n.Translate>Details</i18n.Translate>} - text={<RefundDetails transaction={transaction} />} + title={i18n.str`Details`} + text={ + <RefundDetails + amount={getAmountWithFee(effective, raw, "credit")} + /> + } /> </TransactionTemplate> ); } - function ShowQrWithCopy({ text }: { text: string }): VNode { - const [showing, setShowing] = useState(false); - async function copy(): Promise<void> { - navigator.clipboard.writeText(text); - } - async function toggle(): Promise<void> { - setShowing((s) => !s); - } - if (showing) { - return ( - <div> - <QR text={text} /> - <Button onClick={copy}> - <i18n.Translate>copy</i18n.Translate> - </Button> - <Button onClick={toggle}> - <i18n.Translate>hide qr</i18n.Translate> - </Button> - </div> - ); - } - return ( - <div> - <div>{text.substring(0, 64)}...</div> - <Button onClick={copy}> - <i18n.Translate>copy</i18n.Translate> - </Button> - <Button onClick={toggle}> - <i18n.Translate>show qr</i18n.Translate> - </Button> - </div> - ); - } - if (transaction.type === TransactionType.PeerPullCredit) { - const total = Amounts.parseOrThrow(transaction.amountEffective); return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Credit`} - total={total} + total={effective} kind="positive" > <i18n.Translate>Invoice</i18n.Translate> @@ -683,31 +864,31 @@ export function TransactionView({ {transaction.info.summary ? ( <Part - title={<i18n.Translate>Subject</i18n.Translate>} - text={transaction.info.summary} + title={i18n.str`Subject`} + text={transaction.info.summary as TranslatedString} kind="neutral" /> ) : undefined} <Part - title={<i18n.Translate>Exchange</i18n.Translate>} - text={transaction.exchangeBaseUrl} + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} kind="neutral" /> - {transaction.pending /** pending is not-pay */ && ( - <Part - title={<i18n.Translate>URI</i18n.Translate>} - text={<ShowQrWithCopy text={transaction.talerUri} />} - kind="neutral" - /> - )} + {transaction.txState.major === TransactionMajorState.Pending && + transaction.txState.minor === TransactionMinorState.Ready && + transaction.talerUri && + !transaction.error && ( + <Part + title={i18n.str`URI`} + text={<ShowQrWithCopy text={transaction.talerUri} />} + kind="neutral" + /> + )} <Part - title={<i18n.Translate>Details</i18n.Translate>} + title={i18n.str`Details`} text={ - <InvoiceDetails - amount={{ - effective: Amounts.parseOrThrow(transaction.amountEffective), - raw: Amounts.parseOrThrow(transaction.amountRaw), - }} + <InvoiceCreationDetails + amount={getAmountWithFee(effective, raw, "credit")} /> } /> @@ -716,13 +897,20 @@ export function TransactionView({ } if (transaction.type === TransactionType.PeerPullDebit) { - const total = Amounts.parseOrThrow(transaction.amountEffective); return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Debit`} - total={total} + total={effective} kind="negative" > <i18n.Translate>Invoice</i18n.Translate> @@ -730,34 +918,40 @@ export function TransactionView({ {transaction.info.summary ? ( <Part - title={<i18n.Translate>Subject</i18n.Translate>} - text={transaction.info.summary} + title={i18n.str`Subject`} + text={transaction.info.summary as TranslatedString} kind="neutral" /> ) : undefined} <Part - title={<i18n.Translate>Exchange</i18n.Translate>} - text={transaction.exchangeBaseUrl} + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} kind="neutral" /> <Part - title={<i18n.Translate>Details</i18n.Translate>} + title={i18n.str`Details`} text={ - <InvoiceDetails - amount={{ - effective: Amounts.parseOrThrow(transaction.amountEffective), - raw: Amounts.parseOrThrow(transaction.amountRaw), - }} + <InvoicePaymentDetails + amount={getAmountWithFee(effective, raw, "debit")} /> } /> </TransactionTemplate> ); } + if (transaction.type === TransactionType.PeerPushDebit) { const total = Amounts.parseOrThrow(transaction.amountEffective); return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Debit`} @@ -769,31 +963,28 @@ export function TransactionView({ {transaction.info.summary ? ( <Part - title={<i18n.Translate>Subject</i18n.Translate>} - text={transaction.info.summary} + title={i18n.str`Subject`} + text={transaction.info.summary as TranslatedString} kind="neutral" /> ) : undefined} <Part - title={<i18n.Translate>Exchange</i18n.Translate>} - text={transaction.exchangeBaseUrl} - kind="neutral" - /> - {/* {transaction.pending && ( //pending is not-received - )} */} - <Part - title={<i18n.Translate>URI</i18n.Translate>} - text={<ShowQrWithCopy text={transaction.talerUri} />} + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} kind="neutral" /> + {transaction.talerUri && ( + <Part + title={i18n.str`URI`} + text={<ShowQrWithCopy text={transaction.talerUri} />} + kind="neutral" + /> + )} <Part - title={<i18n.Translate>Details</i18n.Translate>} + title={i18n.str`Details`} text={ - <TransferDetails - amount={{ - effective: Amounts.parseOrThrow(transaction.amountEffective), - raw: Amounts.parseOrThrow(transaction.amountRaw), - }} + <TransferCreationDetails + amount={getAmountWithFee(effective, raw, "debit")} /> } /> @@ -802,13 +993,20 @@ export function TransactionView({ } if (transaction.type === TransactionType.PeerPushCredit) { - const total = Amounts.parseOrThrow(transaction.amountEffective); return ( - <TransactionTemplate> + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > <Header timestamp={transaction.timestamp} type={i18n.str`Credit`} - total={total} + total={effective} kind="positive" > <i18n.Translate>Transfer</i18n.Translate> @@ -816,31 +1014,135 @@ export function TransactionView({ {transaction.info.summary ? ( <Part - title={<i18n.Translate>Subject</i18n.Translate>} - text={transaction.info.summary} + title={i18n.str`Subject`} + text={transaction.info.summary as TranslatedString} kind="neutral" /> ) : undefined} <Part - title={<i18n.Translate>Exchange</i18n.Translate>} - text={transaction.exchangeBaseUrl} + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} kind="neutral" /> <Part - title={<i18n.Translate>Details</i18n.Translate>} + title={i18n.str`Details`} text={ - <TransferDetails - amount={{ - effective: Amounts.parseOrThrow(transaction.amountEffective), - raw: Amounts.parseOrThrow(transaction.amountRaw), - }} + <TransferPickupDetails + amount={getAmountWithFee(effective, raw, "credit")} /> } /> </TransactionTemplate> ); } - return <div />; + + if (transaction.type === TransactionType.DenomLoss) { + switch (transaction.lossEventType) { + case DenomLossEventType.DenomExpired: { + return ( + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > + <Header + timestamp={transaction.timestamp} + type={i18n.str`Debit`} + total={effective} + kind="negative" + > + <i18n.Translate>Lost</i18n.Translate> + </Header> + + <Part + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} + kind="neutral" + /> + <Part + title={i18n.str`Reason`} + text={i18n.str`Denomination expired.`} + /> + </TransactionTemplate> + ); + } + case DenomLossEventType.DenomVanished: { + return ( + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > + <Header + timestamp={transaction.timestamp} + type={i18n.str`Debit`} + total={effective} + kind="negative" + > + <i18n.Translate>Lost</i18n.Translate> + </Header> + + <Part + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} + kind="neutral" + /> + <Part + title={i18n.str`Reason`} + text={i18n.str`Denomination vanished.`} + /> + </TransactionTemplate> + ); + } + case DenomLossEventType.DenomUnoffered: { + return ( + <TransactionTemplate + transaction={transaction} + onDelete={onDelete} + onRetry={onRetry} + onAbort={onAbort} + onResume={onResume} + onSuspend={onSuspend} + onCancel={onCancel} + > + <Header + timestamp={transaction.timestamp} + type={i18n.str`Debit`} + total={effective} + kind="negative" + > + <i18n.Translate>Lost</i18n.Translate> + </Header> + + <Part + title={i18n.str`Exchange`} + text={transaction.exchangeBaseUrl as TranslatedString} + kind="neutral" + /> + <Part + title={i18n.str`Reason`} + text={i18n.str`Denomination is unoffered.`} + /> + </TransactionTemplate> + ); + } + default: { + assertUnreachable(transaction.lossEventType); + } + } + } + if (transaction.type === TransactionType.Recoup) { + throw Error("recoup transaction not implemented"); + } + assertUnreachable(transaction); } export function MerchantDetails({ @@ -883,127 +1185,6 @@ export function MerchantDetails({ ); } -function DeliveryDetails({ - date, - location, -}: { - date: TalerProtocolTimestamp | undefined; - location: Location | undefined; -}): VNode { - const { i18n } = useTranslationContext(); - return ( - <PurchaseDetailsTable> - {location && ( - <Fragment> - {location.country && ( - <tr> - <td> - <i18n.Translate>Country</i18n.Translate> - </td> - <td>{location.country}</td> - </tr> - )} - {location.address_lines && ( - <tr> - <td> - <i18n.Translate>Address lines</i18n.Translate> - </td> - <td>{location.address_lines}</td> - </tr> - )} - {location.building_number && ( - <tr> - <td> - <i18n.Translate>Building number</i18n.Translate> - </td> - <td>{location.building_number}</td> - </tr> - )} - {location.building_name && ( - <tr> - <td> - <i18n.Translate>Building name</i18n.Translate> - </td> - <td>{location.building_name}</td> - </tr> - )} - {location.street && ( - <tr> - <td> - <i18n.Translate>Street</i18n.Translate> - </td> - <td>{location.street}</td> - </tr> - )} - {location.post_code && ( - <tr> - <td> - <i18n.Translate>Post code</i18n.Translate> - </td> - <td>{location.post_code}</td> - </tr> - )} - {location.town_location && ( - <tr> - <td> - <i18n.Translate>Town location</i18n.Translate> - </td> - <td>{location.town_location}</td> - </tr> - )} - {location.town && ( - <tr> - <td> - <i18n.Translate>Town</i18n.Translate> - </td> - <td>{location.town}</td> - </tr> - )} - {location.district && ( - <tr> - <td> - <i18n.Translate>District</i18n.Translate> - </td> - <td>{location.district}</td> - </tr> - )} - {location.country_subdivision && ( - <tr> - <td> - <i18n.Translate>Country subdivision</i18n.Translate> - </td> - <td>{location.country_subdivision}</td> - </tr> - )} - </Fragment> - )} - - {!location || !date ? undefined : ( - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - )} - {date && ( - <Fragment> - <tr> - <td> - <i18n.Translate>Date</i18n.Translate> - </td> - <td> - <Time - timestamp={AbsoluteTime.fromTimestamp(date)} - format="dd MMMM yyyy, HH:mm" - /> - </td> - </tr> - </Fragment> - )} - </PurchaseDetailsTable> - ); -} - export function ExchangeDetails({ exchange }: { exchange: string }): VNode { return ( <div> @@ -1017,19 +1198,40 @@ export function ExchangeDetails({ exchange }: { exchange: string }): VNode { } export interface AmountWithFee { - effective: AmountJson; - raw: AmountJson; + value: AmountJson; + fee: AmountJson; + total: AmountJson; + maxFrac: number; } -export function InvoiceDetails({ amount }: { amount: AmountWithFee }): VNode { - const { i18n } = useTranslationContext(); - - const fee = Amounts.sub(amount.raw, amount.effective).amount; +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 = [amount.raw, amount.effective, fee] + 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 ( <PurchaseDetailsTable> <tr> @@ -1037,164 +1239,264 @@ export function InvoiceDetails({ amount }: { amount: AmountWithFee }): VNode { <i18n.Translate>Invoice</i18n.Translate> </td> <td> - <Amount value={amount.raw} maxFracSize={maxFrac} /> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> - {Amounts.isNonZero(fee) && ( - <tr> - <td> - <i18n.Translate>Transaction fees</i18n.Translate> - </td> - <td> - <Amount value={fee} negative maxFracSize={maxFrac} /> - </td> - </tr> + {Amounts.isNonZero(amount.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.effective} maxFracSize={maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } -export function TransferDetails({ amount }: { amount: AmountWithFee }): VNode { +export function InvoicePaymentDetails({ + 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 ( <PurchaseDetailsTable> <tr> <td> - <i18n.Translate>Transfer</i18n.Translate> + <i18n.Translate>Invoice</i18n.Translate> </td> <td> - <Amount value={amount.raw} maxFracSize={maxFrac} /> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> </td> </tr> - {Amounts.isNonZero(fee) && ( - <tr> - <td> - <i18n.Translate>Transaction fees</i18n.Translate> - </td> - <td> - <Amount value={fee} negative maxFracSize={maxFrac} /> - </td> - </tr> + {Amounts.isNonZero(amount.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> + </PurchaseDetailsTable> + ); +} + +export function TransferCreationDetails({ + amount, +}: { + amount: AmountWithFee; +}): VNode { + const { i18n } = useTranslationContext(); + + return ( + <PurchaseDetailsTable> <tr> <td> - <i18n.Translate>Total</i18n.Translate> + <i18n.Translate>Sent</i18n.Translate> </td> <td> - <Amount value={amount.effective} maxFracSize={maxFrac} /> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> + + {Amounts.isNonZero(amount.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Transfer</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> + )} </PurchaseDetailsTable> ); } -export function WithdrawDetails({ amount }: { amount: AmountWithFee }): VNode { +export function TransferPickupDetails({ + 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 ( <PurchaseDetailsTable> <tr> <td> - <i18n.Translate>Withdraw</i18n.Translate> + <i18n.Translate>Transfer</i18n.Translate> </td> <td> - <Amount value={amount.raw} maxFracSize={maxFrac} /> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> - {Amounts.isNonZero(fee) && ( + {Amounts.isNonZero(amount.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> + )} + </PurchaseDetailsTable> + ); +} + +export function WithdrawDetails({ + conversion, + amount, +}: { + conversion?: AmountJson; + amount: AmountWithFee; +}): VNode { + const { i18n } = useTranslationContext(); + + return ( + <PurchaseDetailsTable> + {conversion ? ( + <Fragment> + <tr> + <td> + <i18n.Translate>Transfer</i18n.Translate> + </td> + <td> + <Amount value={conversion} maxFracSize={amount.maxFrac} /> + </td> + </tr> + {conversion.fraction === amount.value.fraction && + conversion.value === amount.value.value ? undefined : ( + <tr> + <td> + <i18n.Translate>Converted</i18n.Translate> + </td> + <td> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> + </td> + </tr> + )} + </Fragment> + ) : ( <tr> <td> - <i18n.Translate>Transaction fees</i18n.Translate> + <i18n.Translate>Transfer</i18n.Translate> </td> <td> - <Amount value={fee} negative maxFracSize={maxFrac} /> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={amount.effective} maxFracSize={maxFrac} /> - </td> - </tr> + {Amounts.isNonZero(amount.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> + )} </PurchaseDetailsTable> ); } export function PurchaseDetails({ price, - refund, - info, - proposalId, + effectiveRefund, + info: _info, }: { price: AmountWithFee; - refund?: AmountWithFee; + effectiveRefund?: AmountJson; info: OrderShortInfo; - proposalId: string; }): VNode { const { i18n } = useTranslationContext(); - const partialFee = Amounts.sub(price.effective, price.raw).amount; - - const refundFee = !refund - ? Amounts.zeroOfCurrency(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; + const total = Amounts.add(price.value, price.fee).amount; return ( <PurchaseDetailsTable> @@ -1203,49 +1505,82 @@ export function PurchaseDetails({ <i18n.Translate>Price</i18n.Translate> </td> <td> - <Amount value={price.raw} /> + <Amount value={price.total} /> </td> </tr> - - {refund && Amounts.isNonZero(refund.raw) && ( - <tr> - <td> - <i18n.Translate>Refunded</i18n.Translate> - </td> - <td> - <Amount value={refund.raw} negative /> - </td> - </tr> - )} - {Amounts.isNonZero(fee) && ( - <tr> - <td> - <i18n.Translate>Transaction fees</i18n.Translate> - </td> - <td> - <Amount value={fee} /> - </td> - </tr> + {Amounts.isNonZero(price.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={price.fee} /> + </td> + </tr> + {effectiveRefund && Amounts.isNonZero(effectiveRefund) ? ( + <Fragment> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Subtotal</i18n.Translate> + </td> + <td> + <Amount value={price.total} /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Refunded</i18n.Translate> + </td> + <td> + <Amount value={effectiveRefund} negative /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={Amounts.sub(total, effectiveRefund).amount} /> + </td> + </tr> + </Fragment> + ) : ( + <Fragment> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={price.value} /> + </td> + </tr> + </Fragment> + )} + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={total} /> - </td> - </tr> - {hasProducts && ( + + {/* {hasProducts && ( <tr> <td colSpan={2}> <PartCollapsible big - title={<i18n.Translate>Products</i18n.Translate>} + title={i18n.str`Products`} text={ <ListOfProducts> {info.products?.map((p, k) => ( @@ -1268,13 +1603,13 @@ export function PurchaseDetails({ /> </td> </tr> - )} - {hasShipping && ( + )} */} + {/* {hasShipping && ( <tr> <td colSpan={2}> <PartCollapsible big - title={<i18n.Translate>Delivery</i18n.Translate>} + title={i18n.str`Delivery`} text={ <DeliveryDetails date={info.delivery_date} @@ -1284,202 +1619,185 @@ export function PurchaseDetails({ /> </td> </tr> - )} - <tr> - <td> - <ShowFullContractTermPopup proposalId={proposalId} /> - </td> - </tr> + )} */} </PurchaseDetailsTable> ); } -function RefundDetails({ - transaction, -}: { - transaction: TransactionRefund; -}): VNode { +function RefundDetails({ amount }: { amount: AmountWithFee }): 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 ( <PurchaseDetailsTable> <tr> <td> - <i18n.Translate>Amount</i18n.Translate> + <i18n.Translate>Refund</i18n.Translate> </td> <td> - <Amount value={transaction.amountRaw} maxFracSize={maxFrac} /> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> - {Amounts.isNonZero(fee) && ( - <tr> - <td> - <i18n.Translate>Transaction fees</i18n.Translate> - </td> - <td> - <Amount value={fee} negative maxFracSize={maxFrac} /> - </td> - </tr> + {Amounts.isNonZero(amount.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> - </td> - <td> - <Amount value={transaction.amountEffective} maxFracSize={maxFrac} /> - </td> - </tr> </PurchaseDetailsTable> ); } -function DepositDetails({ - transaction, +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<string, { total: AmountJson; fee: AmountJson }>, + ); + + //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, }: { - transaction: TransactionDeposit; + trackingState: TransactionDeposit["trackingState"]; }): VNode { const { i18n } = useTranslationContext(); - const r = Amounts.parseOrThrow(transaction.amountRaw); - const e = Amounts.parseOrThrow(transaction.amountEffective); - const fee = Amounts.sub(e, r).amount; - const maxFrac = [r, e, fee] - .map((a) => Amounts.maxFractionalDigits(a)) - .reduce((c, p) => Math.max(c, p), 0); + const wireTransfers = calculateAmountByWireTransfer(trackingState); return ( <PurchaseDetailsTable> <tr> <td> - <i18n.Translate>Amount</i18n.Translate> + <i18n.Translate>Transfer identification</i18n.Translate> </td> <td> - <Amount value={transaction.amountRaw} maxFracSize={maxFrac} /> + <i18n.Translate>Amount</i18n.Translate> </td> </tr> - {Amounts.isNonZero(fee) && ( - <tr> + {wireTransfers.map((wire) => ( + <tr key={wire.id}> + <td>{wire.id}</td> <td> - <i18n.Translate>Transaction fees</i18n.Translate> - </td> - <td> - <Amount value={fee} maxFracSize={maxFrac} /> + <Amount value={wire.amount} /> </td> </tr> - )} - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total transfer</i18n.Translate> - </td> - <td> - <Amount value={transaction.amountEffective} maxFracSize={maxFrac} /> - </td> - </tr> + ))} </PurchaseDetailsTable> ); } -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); +function DepositDetails({ amount }: { amount: AmountWithFee }): VNode { + const { i18n } = useTranslationContext(); return ( <PurchaseDetailsTable> <tr> <td> - <i18n.Translate>Amount</i18n.Translate> - </td> - <td> - <Amount value={transaction.amountRaw} maxFracSize={maxFrac} /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Transaction fees</i18n.Translate> - </td> - <td> - <Amount value={fee} negative maxFracSize={maxFrac} /> - </td> - </tr> - <tr> - <td colSpan={2}> - <hr /> - </td> - </tr> - <tr> - <td> - <i18n.Translate>Total</i18n.Translate> + <i18n.Translate>Sent</i18n.Translate> </td> <td> - <Amount value={transaction.amountEffective} maxFracSize={maxFrac} /> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> </td> </tr> + + {Amounts.isNonZero(amount.fee) && ( + <Fragment> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td colSpan={2}> + <hr /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Total</i18n.Translate> + </td> + <td> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> + </td> + </tr> + </Fragment> + )} </PurchaseDetailsTable> ); } -function TipDetails({ transaction }: { transaction: TransactionTip }): VNode { +function RefreshDetails({ amount }: { amount: AmountWithFee }): 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 ( <PurchaseDetailsTable> <tr> <td> - <i18n.Translate>Amount</i18n.Translate> + <i18n.Translate>Refresh</i18n.Translate> </td> <td> - <Amount value={transaction.amountRaw} maxFracSize={maxFrac} /> + <Amount value={amount.value} maxFracSize={amount.maxFrac} /> + </td> + </tr> + <tr> + <td> + <i18n.Translate>Fees</i18n.Translate> + </td> + <td> + <Amount value={amount.fee} maxFracSize={amount.maxFrac} /> </td> </tr> - - {Amounts.isNonZero(fee) && ( - <tr> - <td> - <i18n.Translate>Transaction fees</i18n.Translate> - </td> - <td> - <Amount value={fee} negative maxFracSize={maxFrac} /> - </td> - </tr> - )} <tr> <td colSpan={2}> <hr /> @@ -1490,7 +1808,7 @@ function TipDetails({ transaction }: { transaction: TransactionTip }): VNode { <i18n.Translate>Total</i18n.Translate> </td> <td> - <Amount value={transaction.amountEffective} maxFracSize={maxFrac} /> + <Amount value={amount.total} maxFracSize={amount.maxFrac} /> </td> </tr> </PurchaseDetailsTable> @@ -1504,11 +1822,11 @@ function Header({ kind, type, }: { - timestamp: TalerProtocolTimestamp; + timestamp: TalerPreciseTimestamp; total: AmountJson; children: ComponentChildren; kind: Kind; - type: string; + type: TranslatedString; }): VNode { return ( <div @@ -1521,7 +1839,7 @@ function Header({ <div> <SubTitle>{children}</SubTitle> <Time - timestamp={AbsoluteTime.fromTimestamp(timestamp)} + timestamp={AbsoluteTime.fromPreciseTimestamp(timestamp)} format="dd MMMM yyyy, HH:mm" /> </div> @@ -1548,10 +1866,10 @@ function NicePayto({ payto }: { payto: PaytoUri }): VNode { const url = new URL("/", `https://${payto.host}`); return ( <Fragment> - <div>{payto.account}</div> + <div>{"payto.account"}</div> <SmallLightText> <a href={url.href} target="_bank" rel="noreferrer"> - {url.toString()} + {url.href} </a> </SmallLightText> </Fragment> @@ -1564,3 +1882,149 @@ function NicePayto({ payto }: { payto: PaytoUri }): VNode { } return <Fragment>{stringifyPaytoUri(payto)}</Fragment>; } + +function ShowQrWithCopy({ text }: { text: string }): VNode { + const [showing, setShowing] = useState(false); + const { i18n } = useTranslationContext(); + async function copy(): Promise<void> { + navigator.clipboard.writeText(text); + } + async function toggle(): Promise<void> { + setShowing((s) => !s); + } + if (showing) { + return ( + <div> + <QR text={text} /> + <Button onClick={copy as SafeHandler<void>}> + <i18n.Translate>copy</i18n.Translate> + </Button> + <Button onClick={toggle as SafeHandler<void>}> + <i18n.Translate>hide qr</i18n.Translate> + </Button> + </div> + ); + } + return ( + <div> + <div>{text.substring(0, 64)}...</div> + <Button onClick={copy as SafeHandler<void>}> + <i18n.Translate>copy</i18n.Translate> + </Button> + <Button onClick={toggle as SafeHandler<void>}> + <i18n.Translate>show qr</i18n.Translate> + </Button> + </div> + ); +} + +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 <Fragment />; + } + const raw = Amounts.parseOrThrow(transaction.amountRaw); + return ( + <Fragment> + <EnabledBySettings name="advancedMode"> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setShowDetails(!showDetails); + }} + > + Show details. + </a> + </EnabledBySettings> + + {showDetails && ( + <BankDetailsByPaytoType + amount={raw} + accounts={ + transaction.withdrawalDetails.exchangeCreditAccountDetails ?? [] + } + subject={transaction.withdrawalDetails.reservePub} + /> + )} + {!transaction.withdrawalDetails.confirmed && + transaction.withdrawalDetails.bankConfirmationUrl ? ( + <InfoBox> + <div style={{ display: "block" }}> + <i18n.Translate> + Wire transfer need a confirmation. Go to the{" "} + <a + href={transaction.withdrawalDetails.bankConfirmationUrl} + target="_blank" + rel="noreferrer" + style={{ display: "inline" }} + > + <i18n.Translate>bank site</i18n.Translate> + </a>{" "} + and check wire transfer operation to exchange account is complete. + </i18n.Translate> + </div> + </InfoBox> + ) : undefined} + {transaction.withdrawalDetails.confirmed && + !transaction.withdrawalDetails.reserveIsReady && ( + <InfoBox> + <i18n.Translate> + Bank has confirmed the wire transfer. Waiting for the exchange to + send the coins. + </i18n.Translate> + </InfoBox> + )} + {transaction.withdrawalDetails.confirmed && + transaction.withdrawalDetails.reserveIsReady && ( + <InfoBox> + <i18n.Translate> + Exchange is ready to send the coins, withdrawal in progress. + </i18n.Translate> + </InfoBox> + )} + </Fragment> + ); +} |