1938 lines
52 KiB
TypeScript
1938 lines
52 KiB
TypeScript
/*
|
|
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 <http://www.gnu.org/licenses/>
|
|
*/
|
|
|
|
import {
|
|
AbsoluteTime,
|
|
AmountJson,
|
|
Amounts,
|
|
Location,
|
|
MerchantInfo,
|
|
NotificationType,
|
|
OrderShortInfo,
|
|
parsePaytoUri,
|
|
PaytoUri,
|
|
stringifyPaytoUri,
|
|
TalerPreciseTimestamp,
|
|
TalerProtocolTimestamp,
|
|
Transaction,
|
|
TransactionDeposit,
|
|
TransactionIdStr,
|
|
TransactionMajorState,
|
|
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";
|
|
|
|
interface Props {
|
|
tid: string;
|
|
goToWalletHistory: (currency?: string) => Promise<void>;
|
|
}
|
|
|
|
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.WithdrawGroupFinished, NotificationType.KycRequested],
|
|
state?.retry,
|
|
),
|
|
);
|
|
|
|
if (!state) {
|
|
return <Loading />;
|
|
}
|
|
|
|
if (state.hasError) {
|
|
return (
|
|
<ErrorAlertView
|
|
error={alertFromError(
|
|
i18n.str`Could not load transaction information`,
|
|
state,
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const currency = Amounts.parse(state.response.amountRaw)?.currency;
|
|
|
|
return (
|
|
<TransactionView
|
|
transaction={state.response}
|
|
onCancel={async () => {
|
|
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<void>;
|
|
onSuspend: () => Promise<void>;
|
|
onResume: () => Promise<void>;
|
|
onAbort: () => Promise<void>;
|
|
onDelete: () => Promise<void>;
|
|
onRetry: () => Promise<void>;
|
|
onRefund: (id: TransactionIdStr) => Promise<void>;
|
|
onBack: () => Promise<void>;
|
|
}
|
|
|
|
const PurchaseDetailsTable = styled.table`
|
|
width: 100%;
|
|
|
|
& > tr > td:nth-child(2n) {
|
|
text-align: right;
|
|
}
|
|
`;
|
|
|
|
type TransactionTemplateProps = Omit<
|
|
Omit<WalletTransactionProps, "onRefund">,
|
|
"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();
|
|
|
|
async function doCheckBeforeForget(): Promise<void> {
|
|
if (
|
|
transaction.txState.major === TransactionMajorState.Pending &&
|
|
transaction.type === TransactionType.Withdrawal
|
|
) {
|
|
setConfirmBeforeForget(true);
|
|
} else {
|
|
onDelete();
|
|
}
|
|
}
|
|
|
|
async function doCheckBeforeCancel(): Promise<void> {
|
|
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 (
|
|
<Fragment>
|
|
<section style={{ padding: 8, textAlign: "center" }}>
|
|
{transaction?.error ? (
|
|
transaction.error.code === 7025 ? (
|
|
<AlertView
|
|
alert={{
|
|
type: "warning",
|
|
message: i18n.str`KYC check required for the transaction to complete`,
|
|
description:
|
|
transaction.error.kycUrl &&
|
|
typeof transaction.error.kycUrl === "string" ? (
|
|
<div>
|
|
<i18n.Translate>
|
|
Follow this link to the{` `}
|
|
<a href={transaction.error.kycUrl}>KYC verifier</a>
|
|
</i18n.Translate>
|
|
</div>
|
|
) : (
|
|
i18n.str`No more information has been provided`
|
|
),
|
|
}}
|
|
/>
|
|
) : (
|
|
<ErrorAlertView
|
|
error={alertFromError(
|
|
i18n.str`There was an error trying to complete the transaction`,
|
|
transaction.error,
|
|
)}
|
|
/>
|
|
)
|
|
) : undefined}
|
|
{transaction.txState.major === TransactionMajorState.Pending && (
|
|
<WarningBox>
|
|
<i18n.Translate>This transaction is not completed</i18n.Translate>
|
|
</WarningBox>
|
|
)}
|
|
{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>
|
|
<header>
|
|
<i18n.Translate>Caution!</i18n.Translate>
|
|
</header>
|
|
<section>
|
|
<i18n.Translate>
|
|
If you have already wired money to the exchange you will loose
|
|
the chance to get the coins form it.
|
|
</i18n.Translate>
|
|
</section>
|
|
<footer>
|
|
<Button
|
|
variant="contained"
|
|
color="secondary"
|
|
onClick={
|
|
(async () =>
|
|
setConfirmBeforeForget(false)) as SafeHandler<void>
|
|
}
|
|
>
|
|
<i18n.Translate>Cancel</i18n.Translate>
|
|
</Button>
|
|
|
|
<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>
|
|
{showRetry && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={safely("retry transaction", onRetry)}
|
|
>
|
|
<i18n.Translate>Retry</i18n.Translate>
|
|
</Button>
|
|
)}
|
|
{showAbort && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={safely("abort transaction", onAbort)}
|
|
>
|
|
<i18n.Translate>Abort</i18n.Translate>
|
|
</Button>
|
|
)}
|
|
{showResume && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={safely("resume transaction", onResume)}
|
|
>
|
|
<i18n.Translate>Resume</i18n.Translate>
|
|
</Button>
|
|
)}
|
|
{showSuspend && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={safely("suspend transaction", onSuspend)}
|
|
>
|
|
<i18n.Translate>Suspend</i18n.Translate>
|
|
</Button>
|
|
)}
|
|
{showCancel && (
|
|
<Button
|
|
variant="contained"
|
|
color="error"
|
|
onClick={doCheckBeforeCancel as SafeHandler<void>}
|
|
>
|
|
<i18n.Translate>Cancel</i18n.Translate>
|
|
</Button>
|
|
)}
|
|
{showDelete && (
|
|
<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
|
|
) {
|
|
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={effective}
|
|
kind="positive"
|
|
>
|
|
{transaction.exchangeBaseUrl}
|
|
</Header>
|
|
|
|
{/**FIXME: DD37 check if this holds */}
|
|
{transaction.txState.major !==
|
|
TransactionMajorState.Pending ? undefined : transaction
|
|
.withdrawalDetails.type === WithdrawalType.ManualTransfer ? (
|
|
//manual withdrawal
|
|
<Fragment>
|
|
<BankDetailsByPaytoType
|
|
amount={raw}
|
|
exchangeBaseUrl={transaction.exchangeBaseUrl}
|
|
payto={parsePaytoUri(
|
|
transaction.withdrawalDetails.exchangePaytoUris[0],
|
|
)}
|
|
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>
|
|
) : (
|
|
//integrated bank withdrawal
|
|
<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 &&
|
|
!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>
|
|
)}
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<WithdrawDetails
|
|
amount={getAmountWithFee(effective, raw, "credit")}
|
|
/>
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
if (transaction.type === TransactionType.Payment) {
|
|
const pendingRefund =
|
|
transaction.refundPending === undefined
|
|
? undefined
|
|
: Amounts.parseOrThrow(transaction.refundPending);
|
|
|
|
const effectiveRefund = Amounts.parseOrThrow(
|
|
transaction.totalRefundEffective,
|
|
);
|
|
|
|
return (
|
|
<TransactionTemplate
|
|
transaction={transaction}
|
|
onDelete={onDelete}
|
|
onAbort={onAbort}
|
|
onResume={onResume}
|
|
onSuspend={onSuspend}
|
|
onRetry={onRetry}
|
|
onCancel={onCancel}
|
|
>
|
|
<Header
|
|
timestamp={transaction.timestamp}
|
|
total={effective}
|
|
type={i18n.str`Payment`}
|
|
kind="negative"
|
|
>
|
|
{transaction.info.fulfillmentUrl ? (
|
|
<a
|
|
href={transaction.info.fulfillmentUrl}
|
|
target="_bank"
|
|
rel="noreferrer"
|
|
>
|
|
{transaction.info.summary}
|
|
</a>
|
|
) : (
|
|
transaction.info.summary
|
|
)}
|
|
</Header>
|
|
<br />
|
|
{transaction.refunds.length > 0 ? (
|
|
<Part
|
|
title={i18n.str`Refunds`}
|
|
text={
|
|
<table>
|
|
{transaction.refunds.map((r, i) => {
|
|
return (
|
|
<tr key={i}>
|
|
<td>
|
|
<i18n.Translate>
|
|
{<Amount value={r.amountEffective} />}{" "}
|
|
<a
|
|
href={Pages.balanceTransaction({
|
|
tid: r.transactionId,
|
|
})}
|
|
>
|
|
was refunded
|
|
</a>{" "}
|
|
on{" "}
|
|
{
|
|
<Time
|
|
timestamp={AbsoluteTime.fromProtocolTimestamp(
|
|
r.timestamp,
|
|
)}
|
|
format="dd MMMM yyyy"
|
|
/>
|
|
}
|
|
</i18n.Translate>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</table>
|
|
}
|
|
kind="neutral"
|
|
/>
|
|
) : undefined}
|
|
{pendingRefund !== undefined && Amounts.isNonZero(pendingRefund) && (
|
|
<InfoBox>
|
|
{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.str`Offer`}
|
|
text={<Amount value={pendingRefund} />}
|
|
kind="positive"
|
|
/>
|
|
{transaction.refundQueryActive ? undefined : (
|
|
<div>
|
|
<div />
|
|
<div>
|
|
<Button
|
|
variant="contained"
|
|
onClick={safely("refund transaction", () =>
|
|
onRefund(transaction.transactionId),
|
|
)}
|
|
>
|
|
<i18n.Translate>Accept</i18n.Translate>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</InfoBox>
|
|
)}
|
|
{transaction.posConfirmation ? (
|
|
<AlertView
|
|
alert={{
|
|
type: "info",
|
|
message: i18n.str`Confirmation code`,
|
|
description: <pre>{transaction.posConfirmation}</pre>,
|
|
}}
|
|
/>
|
|
) : undefined}
|
|
<Part
|
|
title={i18n.str`Merchant`}
|
|
text={<MerchantDetails merchant={transaction.info.merchant} />}
|
|
kind="neutral"
|
|
/>
|
|
<Part
|
|
title={i18n.str`Invoice ID`}
|
|
text={transaction.info.orderId as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<PurchaseDetails
|
|
price={getAmountWithFee(effective, raw, "debit")}
|
|
effectiveRefund={effectiveRefund}
|
|
info={transaction.info}
|
|
proposalId={transaction.proposalId}
|
|
/>
|
|
}
|
|
kind="neutral"
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<TransactionTemplate
|
|
transaction={transaction}
|
|
onDelete={onDelete}
|
|
onRetry={onRetry}
|
|
onAbort={onAbort}
|
|
onResume={onResume}
|
|
onSuspend={onSuspend}
|
|
onCancel={onCancel}
|
|
>
|
|
<Header
|
|
timestamp={transaction.timestamp}
|
|
type={i18n.str`Deposit`}
|
|
total={effective}
|
|
kind="negative"
|
|
>
|
|
{!payto ? transaction.targetPaytoUri : <NicePayto payto={payto} />}
|
|
</Header>
|
|
{payto && <PartPayto payto={payto} kind="neutral" />}
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<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) {
|
|
return (
|
|
<TransactionTemplate
|
|
transaction={transaction}
|
|
onDelete={onDelete}
|
|
onRetry={onRetry}
|
|
onAbort={onAbort}
|
|
onResume={onResume}
|
|
onSuspend={onSuspend}
|
|
onCancel={onCancel}
|
|
>
|
|
<Header
|
|
timestamp={transaction.timestamp}
|
|
type={i18n.str`Refresh`}
|
|
total={effective}
|
|
kind="negative"
|
|
>
|
|
{"Refresh"}
|
|
</Header>
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<RefreshDetails
|
|
amount={getAmountWithFee(effective, raw, "debit")}
|
|
/>
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
if (transaction.type === TransactionType.Tip) {
|
|
return (
|
|
<TransactionTemplate
|
|
transaction={transaction}
|
|
onDelete={onDelete}
|
|
onRetry={onRetry}
|
|
onAbort={onAbort}
|
|
onResume={onResume}
|
|
onSuspend={onSuspend}
|
|
onCancel={onCancel}
|
|
>
|
|
<Header
|
|
timestamp={transaction.timestamp}
|
|
type={i18n.str`Tip`}
|
|
total={effective}
|
|
kind="positive"
|
|
>
|
|
{transaction.merchantBaseUrl}
|
|
</Header>
|
|
{/* <Part
|
|
title={i18n.str`Merchant`}
|
|
text={<MerchantDetails merchant={transaction.merchant} />}
|
|
kind="neutral"
|
|
/> */}
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<TipDetails amount={getAmountWithFee(effective, raw, "credit")} />
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
if (transaction.type === TransactionType.Refund) {
|
|
return (
|
|
<TransactionTemplate
|
|
transaction={transaction}
|
|
onDelete={onDelete}
|
|
onRetry={onRetry}
|
|
onAbort={onAbort}
|
|
onResume={onResume}
|
|
onSuspend={onSuspend}
|
|
onCancel={onCancel}
|
|
>
|
|
<Header
|
|
timestamp={transaction.timestamp}
|
|
type={i18n.str`Refund`}
|
|
total={effective}
|
|
kind="positive"
|
|
>
|
|
{transaction.paymentInfo ? (
|
|
<a
|
|
href={Pages.balanceTransaction({
|
|
tid: transaction.refundedTransactionId,
|
|
})}
|
|
>
|
|
{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.str`Purchase summary`}
|
|
text={
|
|
(transaction.paymentInfo
|
|
? transaction.paymentInfo.summary
|
|
: "-- deleted --") as TranslatedString
|
|
}
|
|
kind="neutral"
|
|
/>
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<RefundDetails
|
|
amount={getAmountWithFee(effective, raw, "credit")}
|
|
/>
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
if (transaction.type === TransactionType.PeerPullCredit) {
|
|
return (
|
|
<TransactionTemplate
|
|
transaction={transaction}
|
|
onDelete={onDelete}
|
|
onRetry={onRetry}
|
|
onAbort={onAbort}
|
|
onResume={onResume}
|
|
onSuspend={onSuspend}
|
|
onCancel={onCancel}
|
|
>
|
|
<Header
|
|
timestamp={transaction.timestamp}
|
|
type={i18n.str`Credit`}
|
|
total={effective}
|
|
kind="positive"
|
|
>
|
|
<i18n.Translate>Invoice</i18n.Translate>
|
|
</Header>
|
|
|
|
{transaction.info.summary ? (
|
|
<Part
|
|
title={i18n.str`Subject`}
|
|
text={transaction.info.summary as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
) : undefined}
|
|
<Part
|
|
title={i18n.str`Exchange`}
|
|
text={transaction.exchangeBaseUrl as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
{transaction.txState.major === TransactionMajorState.Pending &&
|
|
!transaction.error && (
|
|
<Part
|
|
title={i18n.str`URI`}
|
|
text={<ShowQrWithCopy text={transaction.talerUri} />}
|
|
kind="neutral"
|
|
/>
|
|
)}
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<InvoiceDetails
|
|
amount={getAmountWithFee(effective, raw, "credit")}
|
|
/>
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
if (transaction.type === TransactionType.PeerPullDebit) {
|
|
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>Invoice</i18n.Translate>
|
|
</Header>
|
|
|
|
{transaction.info.summary ? (
|
|
<Part
|
|
title={i18n.str`Subject`}
|
|
text={transaction.info.summary as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
) : undefined}
|
|
<Part
|
|
title={i18n.str`Exchange`}
|
|
text={transaction.exchangeBaseUrl as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<InvoiceDetails
|
|
amount={getAmountWithFee(effective, raw, "debit")}
|
|
/>
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
if (transaction.type === TransactionType.PeerPushDebit) {
|
|
const total = Amounts.parseOrThrow(transaction.amountEffective);
|
|
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={total}
|
|
kind="negative"
|
|
>
|
|
<i18n.Translate>Transfer</i18n.Translate>
|
|
</Header>
|
|
|
|
{transaction.info.summary ? (
|
|
<Part
|
|
title={i18n.str`Subject`}
|
|
text={transaction.info.summary as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
) : undefined}
|
|
<Part
|
|
title={i18n.str`Exchange`}
|
|
text={transaction.exchangeBaseUrl as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
{/* {transaction.pending && ( //pending is not-received
|
|
)} */}
|
|
<Part
|
|
title={i18n.str`URI`}
|
|
text={<ShowQrWithCopy text={transaction.talerUri} />}
|
|
kind="neutral"
|
|
/>
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<TransferDetails
|
|
amount={getAmountWithFee(effective, raw, "debit")}
|
|
/>
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
|
|
if (transaction.type === TransactionType.PeerPushCredit) {
|
|
return (
|
|
<TransactionTemplate
|
|
transaction={transaction}
|
|
onDelete={onDelete}
|
|
onRetry={onRetry}
|
|
onAbort={onAbort}
|
|
onResume={onResume}
|
|
onSuspend={onSuspend}
|
|
onCancel={onCancel}
|
|
>
|
|
<Header
|
|
timestamp={transaction.timestamp}
|
|
type={i18n.str`Credit`}
|
|
total={effective}
|
|
kind="positive"
|
|
>
|
|
<i18n.Translate>Transfer</i18n.Translate>
|
|
</Header>
|
|
|
|
{transaction.info.summary ? (
|
|
<Part
|
|
title={i18n.str`Subject`}
|
|
text={transaction.info.summary as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
) : undefined}
|
|
<Part
|
|
title={i18n.str`Exchange`}
|
|
text={transaction.exchangeBaseUrl as TranslatedString}
|
|
kind="neutral"
|
|
/>
|
|
<Part
|
|
title={i18n.str`Details`}
|
|
text={
|
|
<TransferDetails
|
|
amount={getAmountWithFee(effective, raw, "credit")}
|
|
/>
|
|
}
|
|
/>
|
|
</TransactionTemplate>
|
|
);
|
|
}
|
|
assertUnreachable(transaction);
|
|
}
|
|
|
|
export function MerchantDetails({
|
|
merchant,
|
|
}: {
|
|
merchant: MerchantInfo;
|
|
}): VNode {
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "row" }}>
|
|
{merchant.logo && (
|
|
<div>
|
|
<img
|
|
src={merchant.logo}
|
|
style={{ width: 64, height: 64, margin: 4 }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p style={{ marginTop: 0 }}>{merchant.name}</p>
|
|
{merchant.website && (
|
|
<a
|
|
href={merchant.website}
|
|
target="_blank"
|
|
style={{ textDecorationColor: "gray" }}
|
|
rel="noreferrer"
|
|
>
|
|
<SmallLightText>{merchant.website}</SmallLightText>
|
|
</a>
|
|
)}
|
|
{merchant.email && (
|
|
<a
|
|
href={`mailto:${merchant.email}`}
|
|
style={{ textDecorationColor: "gray" }}
|
|
>
|
|
<SmallLightText>{merchant.email}</SmallLightText>
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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.fromProtocolTimestamp(date)}
|
|
format="dd MMMM yyyy, HH:mm"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</Fragment>
|
|
)}
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
export function ExchangeDetails({ exchange }: { exchange: string }): VNode {
|
|
return (
|
|
<div>
|
|
<p style={{ marginTop: 0 }}>
|
|
<a rel="noreferrer" target="_blank" href={exchange}>
|
|
{exchange}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Invoice</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={amount.value} maxFracSize={amount.maxFrac} />
|
|
</td>
|
|
</tr>
|
|
|
|
{Amounts.isNonZero(amount.fee) && (
|
|
<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>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
export function TransferDetails({ amount }: { amount: AmountWithFee }): VNode {
|
|
const { i18n } = useTranslationContext();
|
|
|
|
return (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Transfer</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={amount.value} maxFracSize={amount.maxFrac} />
|
|
</td>
|
|
</tr>
|
|
|
|
{Amounts.isNonZero(amount.fee) && (
|
|
<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>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Withdraw</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={amount.value} maxFracSize={amount.maxFrac} />
|
|
</td>
|
|
</tr>
|
|
|
|
{Amounts.isNonZero(amount.fee) && (
|
|
<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>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Price</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={price.value} />
|
|
</td>
|
|
</tr>
|
|
{Amounts.isNonZero(price.fee) && (
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Transaction 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.total} />
|
|
</td>
|
|
</tr>
|
|
</Fragment>
|
|
)}
|
|
{hasProducts && (
|
|
<tr>
|
|
<td colSpan={2}>
|
|
<PartCollapsible
|
|
big
|
|
title={i18n.str`Products`}
|
|
text={
|
|
<ListOfProducts>
|
|
{info.products?.map((p, k) => (
|
|
<Row key={k}>
|
|
<a href="#" onClick={showLargePic}>
|
|
<img src={p.image ? p.image : emptyImg} />
|
|
</a>
|
|
<div>
|
|
{p.quantity && p.quantity > 0 && (
|
|
<SmallLightText>
|
|
x {p.quantity} {p.unit}
|
|
</SmallLightText>
|
|
)}
|
|
<div>{p.description}</div>
|
|
</div>
|
|
</Row>
|
|
))}
|
|
</ListOfProducts>
|
|
}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{hasShipping && (
|
|
<tr>
|
|
<td colSpan={2}>
|
|
<PartCollapsible
|
|
big
|
|
title={i18n.str`Delivery`}
|
|
text={
|
|
<DeliveryDetails
|
|
date={info.delivery_date}
|
|
location={info.delivery_location}
|
|
/>
|
|
}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
<tr>
|
|
<td>
|
|
<ShowFullContractTermPopup proposalId={proposalId} />
|
|
</td>
|
|
</tr>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
function RefundDetails({ amount }: { amount: AmountWithFee }): VNode {
|
|
const { i18n } = useTranslationContext();
|
|
|
|
return (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Refund</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={amount.value} maxFracSize={amount.maxFrac} />
|
|
</td>
|
|
</tr>
|
|
|
|
{Amounts.isNonZero(amount.fee) && (
|
|
<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>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
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<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,
|
|
}: {
|
|
trackingState: TransactionDeposit["trackingState"];
|
|
}): VNode {
|
|
const { i18n } = useTranslationContext();
|
|
|
|
const wireTransfers = calculateAmountByWireTransfer(trackingState);
|
|
|
|
return (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Transfer identification</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<i18n.Translate>Amount</i18n.Translate>
|
|
</td>
|
|
</tr>
|
|
|
|
{wireTransfers.map((wire) => (
|
|
<tr>
|
|
<td>{wire.id}</td>
|
|
<td>
|
|
<Amount value={wire.amount} />
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
function DepositDetails({ amount }: { amount: AmountWithFee }): VNode {
|
|
const { i18n } = useTranslationContext();
|
|
|
|
return (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Deposit</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={amount.value} maxFracSize={amount.maxFrac} />
|
|
</td>
|
|
</tr>
|
|
|
|
{Amounts.isNonZero(amount.fee) && (
|
|
<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 transfer</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={amount.total} maxFracSize={amount.maxFrac} />
|
|
</td>
|
|
</tr>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
function RefreshDetails({ amount }: { amount: AmountWithFee }): VNode {
|
|
const { i18n } = useTranslationContext();
|
|
|
|
return (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Refresh</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<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>
|
|
<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>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
function TipDetails({ amount }: { amount: AmountWithFee }): VNode {
|
|
const { i18n } = useTranslationContext();
|
|
|
|
return (
|
|
<PurchaseDetailsTable>
|
|
<tr>
|
|
<td>
|
|
<i18n.Translate>Tip</i18n.Translate>
|
|
</td>
|
|
<td>
|
|
<Amount value={amount.value} maxFracSize={amount.maxFrac} />
|
|
</td>
|
|
</tr>
|
|
|
|
{Amounts.isNonZero(amount.fee) && (
|
|
<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>
|
|
</PurchaseDetailsTable>
|
|
);
|
|
}
|
|
|
|
function Header({
|
|
timestamp,
|
|
total,
|
|
children,
|
|
kind,
|
|
type,
|
|
}: {
|
|
timestamp: TalerPreciseTimestamp;
|
|
total: AmountJson;
|
|
children: ComponentChildren;
|
|
kind: Kind;
|
|
type: TranslatedString;
|
|
}): VNode {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
flexDirection: "row",
|
|
}}
|
|
>
|
|
<div>
|
|
<SubTitle>{children}</SubTitle>
|
|
<Time
|
|
timestamp={AbsoluteTime.fromPreciseTimestamp(timestamp)}
|
|
format="dd MMMM yyyy, HH:mm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<SubTitle>
|
|
<Part
|
|
title={type}
|
|
text={<Amount value={total} negative={kind === "negative"} />}
|
|
kind={kind}
|
|
/>
|
|
</SubTitle>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function NicePayto({ payto }: { payto: PaytoUri }): VNode {
|
|
if (payto.isKnown) {
|
|
switch (payto.targetType) {
|
|
case "bitcoin": {
|
|
return <div>{payto.targetPath.substring(0, 20)}...</div>;
|
|
}
|
|
case "x-taler-bank": {
|
|
const url = new URL("/", `https://${payto.host}`);
|
|
return (
|
|
<Fragment>
|
|
<div>{"payto.account"}</div>
|
|
<SmallLightText>
|
|
<a href={url.href} target="_bank" rel="noreferrer">
|
|
{url.href}
|
|
</a>
|
|
</SmallLightText>
|
|
</Fragment>
|
|
);
|
|
}
|
|
case "iban": {
|
|
return <div>{payto.targetPath.substring(0, 20)}</div>;
|
|
}
|
|
}
|
|
}
|
|
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>
|
|
);
|
|
}
|