aboutsummaryrefslogtreecommitdiff
path: root/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx')
-rw-r--r--packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx576
1 files changed, 576 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
new file mode 100644
index 000000000..c08d8ee1d
--- /dev/null
+++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx
@@ -0,0 +1,576 @@
+/*
+ This file is part of GNU Taler
+ (C) 2021 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 { add, isAfter, isBefore, isFuture } from "date-fns";
+import { Amounts } from "@gnu-taler/taler-util";
+import { Fragment, h, VNode } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import {
+ FormProvider,
+ FormErrors,
+} from "../../../../components/form/FormProvider";
+import { Input } from "../../../../components/form/Input";
+import { InputCurrency } from "../../../../components/form/InputCurrency";
+import { InputDate } from "../../../../components/form/InputDate";
+import { InputGroup } from "../../../../components/form/InputGroup";
+import { InputLocation } from "../../../../components/form/InputLocation";
+import { ProductList } from "../../../../components/product/ProductList";
+import { useConfigContext } from "../../../../context/config";
+import { Duration, MerchantBackend, WithId } from "../../../../declaration";
+import { Translate, useTranslator } from "../../../../i18n";
+import { OrderCreateSchema as schema } from "../../../../schemas/index";
+import { rate } from "../../../../utils/amount";
+import { InventoryProductForm } from "../../../../components/product/InventoryProductForm";
+import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm";
+import { InputNumber } from "../../../../components/form/InputNumber";
+import { InputBoolean } from "../../../../components/form/InputBoolean";
+
+interface Props {
+ onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void;
+ onBack?: () => void;
+ instanceConfig: InstanceConfig;
+ instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[];
+}
+interface InstanceConfig {
+ default_max_wire_fee: string;
+ default_max_deposit_fee: string;
+ default_wire_fee_amortization: number;
+ default_pay_delay: Duration;
+}
+
+function with_defaults(config: InstanceConfig): Partial<Entity> {
+ const defaultPayDeadline =
+ !config.default_pay_delay || config.default_pay_delay.d_us === "forever"
+ ? undefined
+ : add(new Date(), { seconds: config.default_pay_delay.d_us / 1000 });
+
+ return {
+ inventoryProducts: {},
+ products: [],
+ pricing: {},
+ payments: {
+ max_wire_fee: config.default_max_wire_fee,
+ max_fee: config.default_max_deposit_fee,
+ wire_fee_amortization: config.default_wire_fee_amortization,
+ pay_deadline: defaultPayDeadline,
+ refund_deadline: defaultPayDeadline,
+ createToken: true,
+ },
+ shipping: {},
+ extra: "",
+ };
+}
+
+interface ProductAndQuantity {
+ product: MerchantBackend.Products.ProductDetail & WithId;
+ quantity: number;
+}
+export interface ProductMap {
+ [id: string]: ProductAndQuantity;
+}
+
+interface Pricing {
+ products_price: string;
+ order_price: string;
+ summary: string;
+}
+interface Shipping {
+ delivery_date?: Date;
+ delivery_location?: MerchantBackend.Location;
+ fullfilment_url?: string;
+}
+interface Payments {
+ refund_deadline?: Date;
+ pay_deadline?: Date;
+ wire_transfer_deadline?: Date;
+ auto_refund_deadline?: Date;
+ max_fee?: string;
+ max_wire_fee?: string;
+ wire_fee_amortization?: number;
+ createToken: boolean;
+ minimum_age?: number;
+}
+interface Entity {
+ inventoryProducts: ProductMap;
+ products: MerchantBackend.Product[];
+ pricing: Partial<Pricing>;
+ payments: Partial<Payments>;
+ shipping: Partial<Shipping>;
+ extra: string;
+}
+
+const stringIsValidJSON = (value: string) => {
+ try {
+ JSON.parse(value.trim());
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+function undefinedIfEmpty<T>(obj: T): T | undefined {
+ return Object.keys(obj).some((k) => (obj as any)[k] !== undefined)
+ ? obj
+ : undefined;
+}
+
+export function CreatePage({
+ onCreate,
+ onBack,
+ instanceConfig,
+ instanceInventory,
+}: Props): VNode {
+ const [value, valueHandler] = useState(with_defaults(instanceConfig));
+ const config = useConfigContext();
+ const zero = Amounts.getZero(config.currency);
+
+ const inventoryList = Object.values(value.inventoryProducts || {});
+ const productList = Object.values(value.products || {});
+
+ const i18n = useTranslator();
+
+ const errors: FormErrors<Entity> = {
+ pricing: undefinedIfEmpty({
+ summary: !value.pricing?.summary ? i18n`required` : undefined,
+ order_price: !value.pricing?.order_price
+ ? i18n`required`
+ : Amounts.isZero(value.pricing.order_price)
+ ? i18n`must be greater than 0`
+ : undefined,
+ }),
+ extra:
+ value.extra && !stringIsValidJSON(value.extra)
+ ? i18n`not a valid json`
+ : undefined,
+ payments: undefinedIfEmpty({
+ refund_deadline: !value.payments?.refund_deadline
+ ? undefined
+ : !isFuture(value.payments.refund_deadline)
+ ? i18n`should be in the future`
+ : value.payments.pay_deadline &&
+ isBefore(value.payments.refund_deadline, value.payments.pay_deadline)
+ ? i18n`refund deadline cannot be before pay deadline`
+ : value.payments.wire_transfer_deadline &&
+ isBefore(
+ value.payments.wire_transfer_deadline,
+ value.payments.refund_deadline
+ )
+ ? i18n`wire transfer deadline cannot be before refund deadline`
+ : undefined,
+ pay_deadline: !value.payments?.pay_deadline
+ ? undefined
+ : !isFuture(value.payments.pay_deadline)
+ ? i18n`should be in the future`
+ : value.payments.wire_transfer_deadline &&
+ isBefore(
+ value.payments.wire_transfer_deadline,
+ value.payments.pay_deadline
+ )
+ ? i18n`wire transfer deadline cannot be before pay deadline`
+ : undefined,
+ auto_refund_deadline: !value.payments?.auto_refund_deadline
+ ? undefined
+ : !isFuture(value.payments.auto_refund_deadline)
+ ? i18n`should be in the future`
+ : !value.payments?.refund_deadline
+ ? i18n`should have a refund deadline`
+ : !isAfter(
+ value.payments.refund_deadline,
+ value.payments.auto_refund_deadline
+ )
+ ? i18n`auto refund cannot be after refund deadline`
+ : undefined,
+ }),
+ shipping: undefinedIfEmpty({
+ delivery_date: !value.shipping?.delivery_date
+ ? undefined
+ : !isFuture(value.shipping.delivery_date)
+ ? i18n`should be in the future`
+ : undefined,
+ }),
+ };
+ const hasErrors = Object.keys(errors).some(
+ (k) => (errors as any)[k] !== undefined
+ );
+
+ const submit = (): void => {
+ const order = schema.cast(value);
+ if (!value.payments) return;
+ if (!value.shipping) return;
+
+ const request: MerchantBackend.Orders.PostOrderRequest = {
+ order: {
+ amount: order.pricing.order_price,
+ summary: order.pricing.summary,
+ products: productList,
+ extra: value.extra,
+ pay_deadline: value.payments.pay_deadline
+ ? {
+ t_s: Math.floor(value.payments.pay_deadline.getTime() / 1000),
+ }
+ : undefined,
+ wire_transfer_deadline: value.payments.wire_transfer_deadline
+ ? {
+ t_s: Math.floor(
+ value.payments.wire_transfer_deadline.getTime() / 1000
+ ),
+ }
+ : undefined,
+ refund_deadline: value.payments.refund_deadline
+ ? {
+ t_s: Math.floor(value.payments.refund_deadline.getTime() / 1000),
+ }
+ : undefined,
+ auto_refund: value.payments.auto_refund_deadline
+ ? {
+ d_us: Math.floor(
+ value.payments.auto_refund_deadline.getTime() * 1000
+ ),
+ }
+ : undefined,
+ wire_fee_amortization: value.payments.wire_fee_amortization as number,
+ max_fee: value.payments.max_fee as string,
+ max_wire_fee: value.payments.max_wire_fee as string,
+
+ delivery_date: value.shipping.delivery_date
+ ? { t_s: value.shipping.delivery_date.getTime() / 1000 }
+ : undefined,
+ delivery_location: value.shipping.delivery_location,
+ fulfillment_url: value.shipping.fullfilment_url,
+ minimum_age: value.payments.minimum_age,
+ },
+ inventory_products: inventoryList.map((p) => ({
+ product_id: p.product.id,
+ quantity: p.quantity,
+ })),
+ create_token: value.payments.createToken,
+ };
+
+ onCreate(request);
+ };
+
+ const addProductToTheInventoryList = (
+ product: MerchantBackend.Products.ProductDetail & WithId,
+ quantity: number
+ ) => {
+ valueHandler((v) => {
+ const inventoryProducts = { ...v.inventoryProducts };
+ inventoryProducts[product.id] = { product, quantity };
+ return { ...v, inventoryProducts };
+ });
+ };
+
+ const removeProductFromTheInventoryList = (id: string) => {
+ valueHandler((v) => {
+ const inventoryProducts = { ...v.inventoryProducts };
+ delete inventoryProducts[id];
+ return { ...v, inventoryProducts };
+ });
+ };
+
+ const addNewProduct = async (product: MerchantBackend.Product) => {
+ return valueHandler((v) => {
+ const products = v.products ? [...v.products, product] : [];
+ return { ...v, products };
+ });
+ };
+
+ const removeFromNewProduct = (index: number) => {
+ valueHandler((v) => {
+ const products = v.products ? [...v.products] : [];
+ products.splice(index, 1);
+ return { ...v, products };
+ });
+ };
+
+ const [editingProduct, setEditingProduct] = useState<
+ MerchantBackend.Product | undefined
+ >(undefined);
+
+ const totalPriceInventory = inventoryList.reduce((prev, cur) => {
+ const p = Amounts.parseOrThrow(cur.product.price);
+ return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount;
+ }, zero);
+
+ const totalPriceProducts = productList.reduce((prev, cur) => {
+ if (!cur.price) return zero;
+ const p = Amounts.parseOrThrow(cur.price);
+ return Amounts.add(prev, Amounts.mult(p, cur.quantity).amount).amount;
+ }, zero);
+
+ const hasProducts = inventoryList.length > 0 || productList.length > 0;
+ const totalPrice = Amounts.add(totalPriceInventory, totalPriceProducts);
+
+ const totalAsString = Amounts.stringify(totalPrice.amount);
+ const allProducts = productList.concat(inventoryList.map(asProduct));
+
+ useEffect(() => {
+ valueHandler((v) => {
+ return {
+ ...v,
+ pricing: {
+ ...v.pricing,
+ products_price: hasProducts ? totalAsString : undefined,
+ order_price: hasProducts ? totalAsString : undefined,
+ },
+ };
+ });
+ }, [hasProducts, totalAsString]);
+
+ const discountOrRise = rate(
+ value.pricing?.order_price || `${config.currency}:0`,
+ totalAsString
+ );
+
+ const minAgeByProducts = allProducts.reduce(
+ (cur, prev) =>
+ !prev.minimum_age || cur > prev.minimum_age ? cur : prev.minimum_age,
+ 0
+ );
+ return (
+ <div>
+ <section class="section is-main-section">
+ <div class="columns">
+ <div class="column" />
+ <div class="column is-four-fifths">
+ {/* // FIXME: translating plural singular */}
+ <InputGroup
+ name="inventory_products"
+ label={i18n`Manage products in order`}
+ alternative={
+ allProducts.length > 0 && (
+ <p>
+ {allProducts.length} products with a total price of{" "}
+ {totalAsString}.
+ </p>
+ )
+ }
+ tooltip={i18n`Manage list of products in the order.`}
+ >
+ <InventoryProductForm
+ currentProducts={value.inventoryProducts || {}}
+ onAddProduct={addProductToTheInventoryList}
+ inventory={instanceInventory}
+ />
+
+ <NonInventoryProductFrom
+ productToEdit={editingProduct}
+ onAddProduct={(p) => {
+ setEditingProduct(undefined);
+ return addNewProduct(p);
+ }}
+ />
+
+ {allProducts.length > 0 && (
+ <ProductList
+ list={allProducts}
+ actions={[
+ {
+ name: i18n`Remove`,
+ tooltip: i18n`Remove this product from the order.`,
+ handler: (e, index) => {
+ if (e.product_id) {
+ removeProductFromTheInventoryList(e.product_id);
+ } else {
+ removeFromNewProduct(index);
+ setEditingProduct(e);
+ }
+ },
+ },
+ ]}
+ />
+ )}
+ </InputGroup>
+
+ <FormProvider<Entity>
+ errors={errors}
+ object={value}
+ valueHandler={valueHandler as any}
+ >
+ {hasProducts ? (
+ <Fragment>
+ <InputCurrency
+ name="pricing.products_price"
+ label={i18n`Total price`}
+ readonly
+ tooltip={i18n`total product price added up`}
+ />
+ <InputCurrency
+ name="pricing.order_price"
+ label={i18n`Total price`}
+ addonAfter={
+ discountOrRise > 0 &&
+ (discountOrRise < 1
+ ? `discount of %${Math.round(
+ (1 - discountOrRise) * 100
+ )}`
+ : `rise of %${Math.round((discountOrRise - 1) * 100)}`)
+ }
+ tooltip={i18n`Amount to be paid by the customer`}
+ />
+ </Fragment>
+ ) : (
+ <InputCurrency
+ name="pricing.order_price"
+ label={i18n`Order price`}
+ tooltip={i18n`final order price`}
+ />
+ )}
+
+ <Input
+ name="pricing.summary"
+ inputType="multiline"
+ label={i18n`Summary`}
+ tooltip={i18n`Title of the order to be shown to the customer`}
+ />
+
+ <InputGroup
+ name="shipping"
+ label={i18n`Shipping and Fulfillment`}
+ initialActive
+ >
+ <InputDate
+ name="shipping.delivery_date"
+ label={i18n`Delivery date`}
+ tooltip={i18n`Deadline for physical delivery assured by the merchant.`}
+ />
+ {value.shipping?.delivery_date && (
+ <InputGroup
+ name="shipping.delivery_location"
+ label={i18n`Location`}
+ tooltip={i18n`address where the products will be delivered`}
+ >
+ <InputLocation name="shipping.delivery_location" />
+ </InputGroup>
+ )}
+ <Input
+ name="shipping.fullfilment_url"
+ label={i18n`Fulfillment URL`}
+ tooltip={i18n`URL to which the user will be redirected after successful payment.`}
+ />
+ </InputGroup>
+
+ <InputGroup
+ name="payments"
+ label={i18n`Taler payment options`}
+ tooltip={i18n`Override default Taler payment settings for this order`}
+ >
+ <InputDate
+ name="payments.pay_deadline"
+ label={i18n`Payment deadline`}
+ tooltip={i18n`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`}
+ />
+ <InputDate
+ name="payments.refund_deadline"
+ label={i18n`Refund deadline`}
+ tooltip={i18n`Time until which the order can be refunded by the merchant.`}
+ />
+ <InputDate
+ name="payments.wire_transfer_deadline"
+ label={i18n`Wire transfer deadline`}
+ tooltip={i18n`Deadline for the exchange to make the wire transfer.`}
+ />
+ <InputDate
+ name="payments.auto_refund_deadline"
+ label={i18n`Auto-refund deadline`}
+ tooltip={i18n`Time until which the wallet will automatically check for refunds without user interaction.`}
+ />
+
+ <InputCurrency
+ name="payments.max_fee"
+ label={i18n`Maximum deposit fee`}
+ tooltip={i18n`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`}
+ />
+ <InputCurrency
+ name="payments.max_wire_fee"
+ label={i18n`Maximum wire fee`}
+ tooltip={i18n`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`}
+ />
+ <InputNumber
+ name="payments.wire_fee_amortization"
+ label={i18n`Wire fee amortization`}
+ tooltip={i18n`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`}
+ />
+ <InputBoolean
+ name="payments.createToken"
+ label={i18n`Create token`}
+ tooltip={i18n`Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.`}
+ />
+ <InputNumber
+ name="payments.minimum_age"
+ label={i18n`Minimum age required`}
+ tooltip={i18n`Any value greater than 0 will limit the coins able be used to pay this contract. If empty the age restriction will be defined by the products`}
+ help={
+ minAgeByProducts > 0
+ ? i18n`Min age defined by the producs is ${minAgeByProducts}`
+ : undefined
+ }
+ />
+ </InputGroup>
+
+ <InputGroup
+ name="extra"
+ label={i18n`Additional information`}
+ tooltip={i18n`Custom information to be included in the contract for this order.`}
+ >
+ <Input
+ name="extra"
+ inputType="multiline"
+ label={`Value`}
+ tooltip={i18n`You must enter a value in JavaScript Object Notation (JSON).`}
+ />
+ </InputGroup>
+ </FormProvider>
+
+ <div class="buttons is-right mt-5">
+ {onBack && (
+ <button class="button" onClick={onBack}>
+ <Translate>Cancel</Translate>
+ </button>
+ )}
+ <button
+ class="button is-success"
+ onClick={submit}
+ disabled={hasErrors}
+ >
+ <Translate>Confirm</Translate>
+ </button>
+ </div>
+ </div>
+ <div class="column" />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+function asProduct(p: ProductAndQuantity): MerchantBackend.Product {
+ return {
+ product_id: p.product.id,
+ image: p.product.image,
+ price: p.product.price,
+ unit: p.product.unit,
+ quantity: p.quantity,
+ description: p.product.description,
+ taxes: p.product.taxes,
+ minimum_age: p.product.minimum_age,
+ };
+}