diff options
Diffstat (limited to 'packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx')
-rw-r--r-- | packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx | 770 |
1 files changed, 770 insertions, 0 deletions
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx new file mode 100644 index 000000000..5ff76e37a --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -0,0 +1,770 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { format, formatDistance } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { FormProvider } from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { InputCurrency } from "../../../../components/form/InputCurrency.js"; +import { InputDate } from "../../../../components/form/InputDate.js"; +import { InputDuration } from "../../../../components/form/InputDuration.js"; +import { InputGroup } from "../../../../components/form/InputGroup.js"; +import { InputLocation } from "../../../../components/form/InputLocation.js"; +import { TextField } from "../../../../components/form/TextField.js"; +import { ProductList } from "../../../../components/product/ProductList.js"; +import { useBackendContext } from "../../../../context/backend.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; +import { mergeRefunds } from "../../../../utils/amount.js"; +import { RefundModal } from "../list/Table.js"; +import { Event, Timeline } from "./Timeline.js"; + +type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; +type CT = MerchantBackend.ContractTerms; + +interface Props { + onBack: () => void; + selected: Entity; + id: string; + onRefund: (id: string, value: MerchantBackend.Orders.RefundRequest) => void; +} + +type Paid = MerchantBackend.Orders.CheckPaymentPaidResponse & { + refund_taken: string; +}; +type Unpaid = MerchantBackend.Orders.CheckPaymentUnpaidResponse; +type Claimed = MerchantBackend.Orders.CheckPaymentClaimedResponse; + +function ContractTerms({ value }: { value: CT }) { + const { i18n } = useTranslationContext(); + + return ( + <InputGroup name="contract_terms" label={i18n.str`Contract Terms`}> + <FormProvider<CT> object={value} valueHandler={null}> + <Input<CT> + readonly + name="summary" + label={i18n.str`Summary`} + tooltip={i18n.str`human-readable description of the whole purchase`} + /> + <InputCurrency<CT> + readonly + name="amount" + label={i18n.str`Amount`} + tooltip={i18n.str`total price for the transaction`} + /> + {value.fulfillment_url && ( + <Input<CT> + readonly + name="fulfillment_url" + label={i18n.str`Fulfillment URL`} + tooltip={i18n.str`URL for this purchase`} + /> + )} + <Input<CT> + readonly + name="max_fee" + label={i18n.str`Max fee`} + tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`} + /> + <InputDate<CT> + readonly + name="timestamp" + label={i18n.str`Created at`} + tooltip={i18n.str`time when this contract was generated`} + /> + <InputDate<CT> + readonly + name="refund_deadline" + label={i18n.str`Refund deadline`} + tooltip={i18n.str`after this deadline has passed no refunds will be accepted`} + /> + <InputDate<CT> + readonly + name="pay_deadline" + label={i18n.str`Payment deadline`} + tooltip={i18n.str`after this deadline, the merchant won't accept payments for the contract`} + /> + <InputDate<CT> + readonly + name="wire_transfer_deadline" + label={i18n.str`Wire transfer deadline`} + tooltip={i18n.str`transfer deadline for the exchange`} + /> + <InputDate<CT> + readonly + name="delivery_date" + label={i18n.str`Delivery date`} + tooltip={i18n.str`time indicating when the order should be delivered`} + /> + {value.delivery_date && ( + <InputGroup + name="delivery_location" + label={i18n.str`Location`} + tooltip={i18n.str`where the order will be delivered`} + > + <InputLocation name="payments.delivery_location" /> + </InputGroup> + )} + <InputDuration<CT> + readonly + name="auto_refund" + label={i18n.str`Auto-refund delay`} + tooltip={i18n.str`how long the wallet should try to get an automatic refund for the purchase`} + /> + <Input<CT> + readonly + name="extra" + label={i18n.str`Extra info`} + tooltip={i18n.str`extra data that is only interpreted by the merchant frontend`} + /> + </FormProvider> + </InputGroup> + ); +} + +function ClaimedPage({ + id, + order, +}: { + id: string; + order: MerchantBackend.Orders.CheckPaymentClaimedResponse; +}) { + const events: Event[] = []; + if (order.contract_terms.timestamp.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.timestamp.t_s * 1000), + description: "order created", + type: "start", + }); + } + if (order.contract_terms.pay_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.pay_deadline.t_s * 1000), + description: "pay deadline", + type: "deadline", + }); + } + if (order.contract_terms.refund_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.refund_deadline.t_s * 1000), + description: "refund deadline", + type: "deadline", + }); + } + if (order.contract_terms.wire_transfer_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000), + description: "wire deadline", + type: "deadline", + }); + } + if ( + order.contract_terms.delivery_date && + order.contract_terms.delivery_date.t_s !== "never" + ) { + events.push({ + when: new Date(order.contract_terms.delivery_date?.t_s * 1000), + description: "delivery", + type: "delivery", + }); + } + + const [value, valueHandler] = useState<Partial<Claimed>>(order); + const { i18n } = useTranslationContext(); + const [settings] = useSettings() + + return ( + <div> + <section class="section"> + <div class="columns"> + <div class="column" /> + <div class="column is-10"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <i18n.Translate>Order</i18n.Translate> #{id} + <div class="tag is-info ml-4"> + <i18n.Translate>claimed</i18n.Translate> + </div> + </div> + </div> + </div> + + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title">{order.contract_terms.amount}</h1> + </div> + </div> + </div> + + <div class="level"> + <div class="level-left" style={{ maxWidth: "100%" }}> + <div class="level-item" style={{ maxWidth: "100%" }}> + <div + class="content" + style={{ + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + <p> + <b> + <i18n.Translate>claimed at</i18n.Translate>: + </b>{" "} + {format( + new Date(order.contract_terms.timestamp.t_s * 1000), + datetimeFormatForSettings(settings) + )} + </p> + </div> + </div> + </div> + </div> + </div> + </section> + + <section class="section"> + <div class="columns"> + <div class="column is-4"> + <div class="title"> + <i18n.Translate>Timeline</i18n.Translate> + </div> + <Timeline events={events} /> + </div> + <div class="column is-8"> + <div class="title"> + <i18n.Translate>Payment details</i18n.Translate> + </div> + <FormProvider<Claimed> + object={value} + valueHandler={valueHandler} + > + <Input + name="contract_terms.summary" + readonly + inputType="multiline" + label={i18n.str`Summary`} + /> + <InputCurrency + name="contract_terms.amount" + readonly + label={i18n.str`Amount`} + /> + <Input<Claimed> + name="order_status" + readonly + label={i18n.str`Order status`} + /> + </FormProvider> + </div> + </div> + </section> + + {order.contract_terms.products.length ? ( + <Fragment> + <div class="title"> + <i18n.Translate>Product list</i18n.Translate> + </div> + <ProductList list={order.contract_terms.products} /> + </Fragment> + ) : undefined} + + {value.contract_terms && ( + <ContractTerms value={value.contract_terms} /> + )} + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} +function PaidPage({ + id, + order, + onRefund, +}: { + id: string; + order: MerchantBackend.Orders.CheckPaymentPaidResponse; + onRefund: (id: string) => void; +}) { + const events: Event[] = []; + if (order.contract_terms.timestamp.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.timestamp.t_s * 1000), + description: "order created", + type: "start", + }); + } + if (order.contract_terms.pay_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.pay_deadline.t_s * 1000), + description: "pay deadline", + type: "deadline", + }); + } + if (order.contract_terms.refund_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.refund_deadline.t_s * 1000), + description: "refund deadline", + type: "deadline", + }); + } + if (order.contract_terms.wire_transfer_deadline.t_s !== "never") { + events.push({ + when: new Date(order.contract_terms.wire_transfer_deadline.t_s * 1000), + description: "wire deadline", + type: "deadline", + }); + } + if ( + order.contract_terms.delivery_date && + order.contract_terms.delivery_date.t_s !== "never" + ) { + if (order.contract_terms.delivery_date) + events.push({ + when: new Date(order.contract_terms.delivery_date?.t_s * 1000), + description: "delivery", + type: "delivery", + }); + } + order.refund_details.reduce(mergeRefunds, []).forEach((e) => { + if (e.timestamp.t_s !== "never") { + events.push({ + when: new Date(e.timestamp.t_s * 1000), + description: `refund: ${e.amount}: ${e.reason}`, + type: e.pending ? "refund" : "refund-taken", + }); + } + }); + if (order.wire_details && order.wire_details.length) { + if (order.wire_details.length > 1) { + let last: MerchantBackend.Orders.TransactionWireTransfer | null = null; + let first: MerchantBackend.Orders.TransactionWireTransfer | null = null; + let total: AmountJson | null = null; + + order.wire_details.forEach((w) => { + if (last === null || last.execution_time.t_s < w.execution_time.t_s) { + last = w; + } + if (first === null || first.execution_time.t_s > w.execution_time.t_s) { + first = w; + } + total = + total === null + ? Amounts.parseOrThrow(w.amount) + : Amounts.add(total, Amounts.parseOrThrow(w.amount)).amount; + }); + const last_time = last!.execution_time.t_s; + if (last_time !== "never") { + events.push({ + when: new Date(last_time * 1000), + description: `wired ${Amounts.stringify(total!)}`, + type: "wired-range", + }); + } + const first_time = first!.execution_time.t_s; + if (first_time !== "never") { + events.push({ + when: new Date(first_time * 1000), + description: `wire transfer started...`, + type: "wired-range", + }); + } + } else { + order.wire_details.forEach((e) => { + if (e.execution_time.t_s !== "never") { + events.push({ + when: new Date(e.execution_time.t_s * 1000), + description: `wired ${e.amount}`, + type: "wired", + }); + } + }); + } + } + + const now = new Date() + const nextEvent = events.find((e) => { + return e.when.getTime() > now.getTime() + }) + + const [value, valueHandler] = useState<Partial<Paid>>(order); + const { url: backendURL } = useBackendContext() + const refundurl = stringifyRefundUri({ + merchantBaseUrl: backendURL, + orderId: order.contract_terms.order_id + }) + const refundable = + new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000; + const { i18n } = useTranslationContext(); + + const amount = Amounts.parseOrThrow(order.contract_terms.amount); + const refund_taken = order.refund_details.reduce((prev, cur) => { + if (cur.pending) return prev; + return Amounts.add(prev, Amounts.parseOrThrow(cur.amount)).amount; + }, Amounts.zeroOfCurrency(amount.currency)); + value.refund_taken = Amounts.stringify(refund_taken); + + return ( + <div> + <section class="section"> + <div class="columns"> + <div class="column" /> + <div class="column is-10"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <i18n.Translate>Order</i18n.Translate> #{id} + <div class="tag is-success ml-4"> + <i18n.Translate>paid</i18n.Translate> + </div> + {order.wired ? ( + <div class="tag is-success ml-4"> + <i18n.Translate>wired</i18n.Translate> + </div> + ) : null} + {order.refunded ? ( + <div class="tag is-danger ml-4"> + <i18n.Translate>refunded</i18n.Translate> + </div> + ) : null} + </div> + </div> + </div> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title">{order.contract_terms.amount}</h1> + </div> + </div> + <div class="level-right"> + <div class="level-item"> + <h1 class="title"> + <div class="buttons"> + <span + class="has-tooltip-left" + data-tooltip={ + refundable + ? i18n.str`refund order` + : i18n.str`not refundable` + } + > + <button + class="button is-danger" + disabled={!refundable} + onClick={() => onRefund(id)} + > + <i18n.Translate>refund</i18n.Translate> + </button> + </span> + </div> + </h1> + </div> + </div> + </div> + + <div class="level"> + <div class="level-left" style={{ maxWidth: "100%" }}> + <div class="level-item" style={{ maxWidth: "100%" }}> + <div + class="content" + style={{ + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + {nextEvent && + <p> + <i18n.Translate>Next event in </i18n.Translate> {formatDistance( + nextEvent.when, + new Date(), + // "yyyy/MM/dd HH:mm:ss", + )} + </p> + } + </div> + </div> + </div> + </div> + </div> + </section> + + <section class="section"> + <div class="columns"> + <div class="column is-4"> + <div class="title"> + <i18n.Translate>Timeline</i18n.Translate> + </div> + <Timeline events={events} /> + </div> + <div class="column is-8"> + <div class="title"> + <i18n.Translate>Payment details</i18n.Translate> + </div> + <FormProvider<Paid> + object={value} + valueHandler={valueHandler} + > + {/* <InputCurrency<Paid> name="deposit_total" readonly label={i18n.str`Deposit total`} /> */} + {order.refunded && ( + <InputCurrency<Paid> + name="refund_amount" + readonly + label={i18n.str`Refunded amount`} + /> + )} + {order.refunded && ( + <InputCurrency<Paid> + name="refund_taken" + readonly + label={i18n.str`Refund taken`} + /> + )} + <Input<Paid> + name="order_status" + readonly + label={i18n.str`Order status`} + /> + <TextField<Paid> + name="order_status_url" + label={i18n.str`Status URL`} + > + <a + target="_blank" + rel="noreferrer" + href={order.order_status_url} + > + {order.order_status_url} + </a> + </TextField> + {order.refunded && ( + <TextField<Paid> + name="order_status_url" + label={i18n.str`Refund URI`} + > + <a target="_blank" rel="noreferrer" href={refundurl}> + {refundurl} + </a> + </TextField> + )} + </FormProvider> + </div> + </div> + </section> + + {order.contract_terms.products.length ? ( + <Fragment> + <div class="title"> + <i18n.Translate>Product list</i18n.Translate> + </div> + <ProductList list={order.contract_terms.products} /> + </Fragment> + ) : undefined} + + {value.contract_terms && ( + <ContractTerms value={value.contract_terms} /> + )} + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} + +function UnpaidPage({ + id, + order, +}: { + id: string; + order: MerchantBackend.Orders.CheckPaymentUnpaidResponse; +}) { + const [value, valueHandler] = useState<Partial<Unpaid>>(order); + const { i18n } = useTranslationContext(); + const [settings] = useSettings() + return ( + <div> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <h1 class="title"> + <i18n.Translate>Order</i18n.Translate> #{id} + </h1> + </div> + <div class="tag is-dark"> + <i18n.Translate>unpaid</i18n.Translate> + </div> + </div> + </div> + + <div class="level"> + <div class="level-left" style={{ maxWidth: "100%" }}> + <div class="level-item" style={{ maxWidth: "100%" }}> + <div + class="content" + style={{ + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + }} + > + <p> + <b> + <i18n.Translate>pay at</i18n.Translate>: + </b>{" "} + <a + href={order.order_status_url} + rel="nofollow" + target="new" + > + {order.order_status_url} + </a> + </p> + <p> + <b> + <i18n.Translate>created at</i18n.Translate>: + </b>{" "} + {order.creation_time.t_s === "never" + ? "never" + : format( + new Date(order.creation_time.t_s * 1000), + datetimeFormatForSettings(settings) + )} + </p> + </div> + </div> + </div> + </div> + </div> + </section> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider<Unpaid> object={value} valueHandler={valueHandler}> + <Input<Unpaid> + readonly + name="summary" + label={i18n.str`Summary`} + tooltip={i18n.str`human-readable description of the whole purchase`} + /> + <InputCurrency<Unpaid> + readonly + name="total_amount" + label={i18n.str`Amount`} + tooltip={i18n.str`total price for the transaction`} + /> + <Input<Unpaid> + name="order_status" + readonly + label={i18n.str`Order status`} + /> + <Input<Unpaid> + name="order_status_url" + readonly + label={i18n.str`Order status URL`} + /> + <TextField<Unpaid> + name="taler_pay_uri" + label={i18n.str`Payment URI`} + > + <a target="_blank" rel="noreferrer" href={value.taler_pay_uri}> + {value.taler_pay_uri} + </a> + </TextField> + </FormProvider> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} + +export function DetailPage({ id, selected, onRefund, onBack }: Props): VNode { + const [showRefund, setShowRefund] = useState<string | undefined>(undefined); + const { i18n } = useTranslationContext(); + const DetailByStatus = function () { + switch (selected.order_status) { + case "claimed": + return <ClaimedPage id={id} order={selected} />; + case "paid": + return <PaidPage id={id} order={selected} onRefund={setShowRefund} />; + case "unpaid": + return <UnpaidPage id={id} order={selected} />; + default: + return ( + <div> + <i18n.Translate> + Unknown order status. This is an error, please contact the + administrator. + </i18n.Translate> + </div> + ); + } + }; + + return ( + <Fragment> + {DetailByStatus()} + {showRefund && ( + <RefundModal + order={selected} + onCancel={() => setShowRefund(undefined)} + onConfirm={(value) => { + onRefund(showRefund, value); + setShowRefund(undefined); + }} + /> + )} + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <div class="buttons is-right mt-5"> + <button class="button" onClick={onBack}> + <i18n.Translate>Back</i18n.Translate> + </button> + </div> + </div> + <div class="column" /> + </div> + </Fragment> + ); +} + +async function copyToClipboard(text: string) { + return navigator.clipboard.writeText(text); +} |