summaryrefslogtreecommitdiff
path: root/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx')
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx780
1 files changed, 780 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
new file mode 100644
index 000000000..498ea83e3
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx
@@ -0,0 +1,780 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021-2024 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,
+ TalerMerchantApi,
+ 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 { useSessionContext } from "../../../../context/session.js";
+import {
+ datetimeFormatForSettings,
+ usePreference,
+} from "../../../../hooks/preference.js";
+import { mergeRefunds } from "../../../../utils/amount.js";
+import { RefundModal } from "../list/Table.js";
+import { Event, Timeline } from "./Timeline.js";
+
+type Entity = TalerMerchantApi.MerchantOrderStatusResponse;
+type CT = TalerMerchantApi.ContractTerms;
+
+interface Props {
+ onBack: () => void;
+ selected: Entity;
+ id: string;
+ onRefund: (id: string, value: TalerMerchantApi.RefundRequest) => void;
+}
+
+type Paid = TalerMerchantApi.CheckPaymentPaidResponse & {
+ refund_taken: string;
+};
+type Unpaid = TalerMerchantApi.CheckPaymentUnpaidResponse;
+type Claimed = TalerMerchantApi.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: TalerMerchantApi.CheckPaymentClaimedResponse;
+}) {
+ const now = new Date();
+ const refundable =
+ order.contract_terms.refund_deadline.t_s !== "never" &&
+ now.getTime() < order.contract_terms.refund_deadline.t_s * 1000;
+ 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" && refundable) {
+ 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] = usePreference();
+
+ 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>{" "}
+ {order.contract_terms.timestamp.t_s === "never"
+ ? "never"
+ : 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: TalerMerchantApi.CheckPaymentPaidResponse;
+ onRefund: (id: string) => void;
+}) {
+ const now = new Date();
+ const refundable =
+ order.contract_terms.refund_deadline.t_s !== "never" &&
+ now.getTime() < order.contract_terms.refund_deadline.t_s * 1000;
+
+ const events: Event[] = [];
+ if (order.contract_terms.refund_deadline.t_s !== "never" && refundable) {
+ 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",
+ });
+ }
+ });
+ const ra = !order.refunded ? undefined : Amounts.parse(order.refund_amount);
+ const am = Amounts.parseOrThrow(order.contract_terms.amount);
+ if (ra && Amounts.cmp(ra, am) === 1) {
+ if (order.wire_details && order.wire_details.length) {
+ if (order.wire_details.length > 1) {
+ let last: TalerMerchantApi.TransactionWireTransfer | null = null;
+ let first: TalerMerchantApi.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 nextEvent = events.find((e) => {
+ return e.when.getTime() > now.getTime();
+ });
+
+ const [value, valueHandler] = useState<Partial<Paid>>(order);
+ const { state } = useSessionContext();
+
+ const refundurl = stringifyRefundUri({
+ merchantBaseUrl: state.backendUrl.href,
+ orderId: order.contract_terms.order_id,
+ });
+ 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: TalerMerchantApi.CheckPaymentUnpaidResponse;
+}) {
+ const [value, valueHandler] = useState<Partial<Unpaid>>(order);
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreference();
+ 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>
+ );
+}