diff options
Diffstat (limited to 'packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx')
-rw-r--r-- | packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx | 705 |
1 files changed, 705 insertions, 0 deletions
diff --git a/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx new file mode 100644 index 000000000..62ceaa24b --- /dev/null +++ b/packages/auditor-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx @@ -0,0 +1,705 @@ +/* + 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 { AbsoluteTime, Amounts, Duration, TalerProtocolDuration } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { format, isFuture } from "date-fns"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { + FormErrors, + 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 { InputNumber } from "../../../../components/form/InputNumber.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; +import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js"; +import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js"; +import { ProductList } from "../../../../components/product/ProductList.js"; +import { useConfigContext } from "../../../../context/config.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { useSettings } from "../../../../hooks/useSettings.js"; +import { OrderCreateSchema as schema } from "../../../../schemas/index.js"; +import { rate } from "../../../../utils/amount.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; + +interface Props { + onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; + onBack?: () => void; + instanceConfig: InstanceConfig; + instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[]; +} +interface InstanceConfig { + use_stefan: boolean; + default_pay_delay: TalerProtocolDuration; + default_wire_transfer_delay: TalerProtocolDuration; +} + +function with_defaults(config: InstanceConfig, currency: string): Partial<Entity> { + const defaultPayDeadline = Duration.fromTalerProtocolDuration(config.default_pay_delay); + const defaultWireDeadline = Duration.fromTalerProtocolDuration(config.default_wire_transfer_delay); + + return { + inventoryProducts: {}, + products: [], + pricing: {}, + payments: { + max_fee: undefined, + createToken: true, + pay_deadline: (defaultPayDeadline), + refund_deadline: (defaultPayDeadline), + wire_transfer_deadline: (defaultWireDeadline), + }, + 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: Duration; + pay_deadline: Duration; + wire_transfer_deadline: Duration; + auto_refund_deadline: Duration; + max_fee?: string; + createToken: boolean; + minimum_age?: number; +} +interface Entity { + inventoryProducts: ProductMap; + products: MerchantBackend.Product[]; + pricing: Partial<Pricing>; + payments: Partial<Payments>; + shipping: Partial<Shipping>; + extra: Record<string, string>; +} + +const stringIsValidJSON = (value: string) => { + try { + JSON.parse(value.trim()); + return true; + } catch { + return false; + } +}; + +export function CreatePage({ + onCreate, + onBack, + instanceConfig, + instanceInventory, +}: Props): VNode { + const config = useConfigContext(); + const instance_default = with_defaults(instanceConfig, config.currency) + const [value, valueHandler] = useState(instance_default); + const zero = Amounts.zeroOfCurrency(config.currency); + const [settings, updateSettings] = useSettings() + const inventoryList = Object.values(value.inventoryProducts || {}); + const productList = Object.values(value.products || {}); + + const { i18n } = useTranslationContext(); + + const parsedPrice = !value.pricing?.order_price + ? undefined + : Amounts.parse(value.pricing.order_price); + + const errors: FormErrors<Entity> = { + pricing: undefinedIfEmpty({ + summary: !value.pricing?.summary ? i18n.str`required` : undefined, + order_price: !value.pricing?.order_price + ? i18n.str`required` + : !parsedPrice + ? i18n.str`not valid` + : Amounts.isZero(parsedPrice) + ? i18n.str`must be greater than 0` + : undefined, + }), + payments: undefinedIfEmpty({ + refund_deadline: !value.payments?.refund_deadline + ? undefined + : value.payments.pay_deadline && + Duration.cmp(value.payments.refund_deadline, value.payments.pay_deadline) === -1 + ? i18n.str`refund deadline cannot be before pay deadline` + : value.payments.wire_transfer_deadline && + Duration.cmp( + value.payments.wire_transfer_deadline, + value.payments.refund_deadline, + ) === -1 + ? i18n.str`wire transfer deadline cannot be before refund deadline` + : undefined, + pay_deadline: !value.payments?.pay_deadline + ? i18n.str`required` + : value.payments.wire_transfer_deadline && + Duration.cmp( + value.payments.wire_transfer_deadline, + value.payments.pay_deadline, + ) === -1 + ? i18n.str`wire transfer deadline cannot be before pay deadline` + : undefined, + wire_transfer_deadline: !value.payments?.wire_transfer_deadline + ? i18n.str`required` + : undefined, + auto_refund_deadline: !value.payments?.auto_refund_deadline + ? undefined + : !value.payments?.refund_deadline + ? i18n.str`should have a refund deadline` + : Duration.cmp( + value.payments.refund_deadline, + value.payments.auto_refund_deadline, + ) == -1 + ? i18n.str`auto refund cannot be after refund deadline` + : undefined, + + }), + shipping: undefinedIfEmpty({ + delivery_date: !value.shipping?.delivery_date + ? undefined + : !isFuture(value.shipping.delivery_date) + ? i18n.str`should be in the future` + : undefined, + }), + }; + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submit = (): void => { + const order = value as any; //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: undefinedIfEmpty(value.extra), + pay_deadline: !value.payments.pay_deadline ? + i18n.str`required` : + AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.pay_deadline)) + ,// : undefined, + wire_transfer_deadline: value.payments.wire_transfer_deadline + ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.wire_transfer_deadline)) + : undefined, + refund_deadline: value.payments.refund_deadline + ? AbsoluteTime.toProtocolTimestamp(AbsoluteTime.addDuration(AbsoluteTime.now(), value.payments.refund_deadline)) + : undefined, + auto_refund: value.payments.auto_refund_deadline + ? Duration.toTalerProtocolDuration(value.payments.auto_refund_deadline) + : undefined, + max_fee: value.payments.max_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)); + + const [newField, setNewField] = useState("") + + useEffect(() => { + valueHandler((v) => { + return { + ...v, + pricing: { + ...v.pricing, + products_price: hasProducts ? totalAsString : undefined, + order_price: hasProducts ? totalAsString : undefined, + }, + }; + }); + }, [hasProducts, totalAsString]); + + const discountOrRise = rate( + parsedPrice ?? Amounts.zeroOfCurrency(config.currency), + totalPrice.amount, + ); + + const minAgeByProducts = allProducts.reduce( + (cur, prev) => + !prev.minimum_age || cur > prev.minimum_age ? cur : prev.minimum_age, + 0, + ); + + // if there is no default pay deadline + const noDefault_payDeadline = !instance_default.payments || !instance_default.payments.pay_deadline + // and there is no default wire deadline + const noDefault_wireDeadline = !instance_default.payments || !instance_default.payments.wire_transfer_deadline + // user required to set the taler options + const requiresSomeTalerOptions = noDefault_payDeadline || noDefault_wireDeadline + + + return ( + <div> + + <section class="section is-main-section"> + <div class="tabs is-toggle is-fullwidth is-small"> + <ul> + <li class={!settings.advanceOrderMode ? "is-active" : ""} onClick={() => { + updateSettings({ + ...settings, + advanceOrderMode: false + }) + }}> + <a > + <span><i18n.Translate>Simple</i18n.Translate></span> + </a> + </li> + <li class={settings.advanceOrderMode ? "is-active" : ""} onClick={() => { + updateSettings({ + ...settings, + advanceOrderMode: true + }) + }}> + <a > + <span><i18n.Translate>Advanced</i18n.Translate></span> + </a> + </li> + </ul> + </div> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + {/* // FIXME: translating plural singular */} + <InputGroup + name="inventory_products" + label={i18n.str`Manage products in order`} + alternative={ + allProducts.length > 0 && ( + <p> + {allProducts.length} products with a total price of{" "} + {totalAsString}. + </p> + ) + } + tooltip={i18n.str`Manage list of products in the order.`} + > + <InventoryProductForm + currentProducts={value.inventoryProducts || {}} + onAddProduct={addProductToTheInventoryList} + inventory={instanceInventory} + /> + + {settings.advanceOrderMode && + <NonInventoryProductFrom + productToEdit={editingProduct} + onAddProduct={(p) => { + setEditingProduct(undefined); + return addNewProduct(p); + }} + /> + } + + {allProducts.length > 0 && ( + <ProductList + list={allProducts} + actions={[ + { + name: i18n.str`Remove`, + tooltip: i18n.str`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.str`Total price`} + readonly + tooltip={i18n.str`total product price added up`} + /> + <InputCurrency + name="pricing.order_price" + label={i18n.str`Total price`} + addonAfter={ + discountOrRise > 0 && + (discountOrRise < 1 + ? `discount of %${Math.round( + (1 - discountOrRise) * 100, + )}` + : `rise of %${Math.round((discountOrRise - 1) * 100)}`) + } + tooltip={i18n.str`Amount to be paid by the customer`} + /> + </Fragment> + ) : ( + <InputCurrency + name="pricing.order_price" + label={i18n.str`Order price`} + tooltip={i18n.str`final order price`} + /> + )} + + <Input + name="pricing.summary" + inputType="multiline" + label={i18n.str`Summary`} + tooltip={i18n.str`Title of the order to be shown to the customer`} + /> + + {settings.advanceOrderMode && + <InputGroup + name="shipping" + label={i18n.str`Shipping and Fulfillment`} + initialActive + > + <InputDate + name="shipping.delivery_date" + label={i18n.str`Delivery date`} + tooltip={i18n.str`Deadline for physical delivery assured by the merchant.`} + /> + {value.shipping?.delivery_date && ( + <InputGroup + name="shipping.delivery_location" + label={i18n.str`Location`} + tooltip={i18n.str`address where the products will be delivered`} + > + <InputLocation name="shipping.delivery_location" /> + </InputGroup> + )} + <Input + name="shipping.fullfilment_url" + label={i18n.str`Fulfillment URL`} + tooltip={i18n.str`URL to which the user will be redirected after successful payment.`} + /> + </InputGroup> + } + + {(settings.advanceOrderMode || requiresSomeTalerOptions) && + <InputGroup + name="payments" + label={i18n.str`Taler payment options`} + tooltip={i18n.str`Override default Taler payment settings for this order`} + > + {(settings.advanceOrderMode || noDefault_payDeadline) && <InputDuration + name="payments.pay_deadline" + label={i18n.str`Payment time`} + help={<DeadlineHelp duration={value.payments?.pay_deadline} />} + withForever + withoutClear + tooltip={i18n.str`Time for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline. Time start to run after the order is created.`} + side={ + <span> + <button class="button" onClick={() => { + const c = { + ...value, + payments: { + ...(value.payments ?? {}), + pay_deadline: instance_default.payments?.pay_deadline + } + } + valueHandler(c) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } + />} + {settings.advanceOrderMode && <InputDuration + name="payments.refund_deadline" + label={i18n.str`Refund time`} + help={<DeadlineHelp duration={value.payments?.refund_deadline} />} + withForever + withoutClear + tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + refund_deadline: instance_default.payments?.refund_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } + />} + {(settings.advanceOrderMode || noDefault_wireDeadline) && <InputDuration + name="payments.wire_transfer_deadline" + label={i18n.str`Wire transfer time`} + help={<DeadlineHelp duration={value.payments?.wire_transfer_deadline} />} + withoutClear + withForever + tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + wire_transfer_deadline: instance_default.payments?.wire_transfer_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } + />} + {settings.advanceOrderMode && <InputDuration + name="payments.auto_refund_deadline" + label={i18n.str`Auto-refund time`} + help={<DeadlineHelp duration={value.payments?.auto_refund_deadline} />} + tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`} + withForever + />} + + {settings.advanceOrderMode && <InputCurrency + name="payments.max_fee" + label={i18n.str`Maximum fee`} + tooltip={i18n.str`Maximum fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} + />} + {settings.advanceOrderMode && <InputToggle + name="payments.createToken" + label={i18n.str`Create token`} + tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`} + />} + {settings.advanceOrderMode && <InputNumber + name="payments.minimum_age" + label={i18n.str`Minimum age required`} + tooltip={i18n.str`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.str`Min age defined by the producs is ${minAgeByProducts}` + : i18n.str`No product with age restriction in this order` + } + />} + </InputGroup> + } + + {settings.advanceOrderMode && + <InputGroup + name="extra" + label={i18n.str`Additional information`} + tooltip={i18n.str`Custom information to be included in the contract for this order.`} + > + {Object.keys(value.extra ?? {}).map((key) => { + + return <Input + name={`extra.${key}`} + inputType="multiline" + label={key} + tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`} + side={ + <button class="button" onClick={(e) => { + if (value.extra && value.extra[key] !== undefined) { + console.log(value.extra) + delete value.extra[key] + } + valueHandler({ + ...value, + }) + }}>remove</button> + } + /> + })} + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Custom field name</i18n.Translate> + <span class="icon has-tooltip-right" data-tooltip={"new extra field"}> + <i class="mdi mdi-information" /> + </span> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input class="input " value={newField} onChange={(e) => setNewField(e.currentTarget.value)} /> + </p> + </div> + </div> + <button class="button" onClick={(e) => { + setNewField("") + valueHandler({ + ...value, + extra: { + ...(value.extra ?? {}), + [newField]: "" + } + }) + }}>add</button> + </div> + </InputGroup> + } + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <button + class="button is-success" + onClick={submit} + disabled={hasErrors} + > + <i18n.Translate>Confirm</i18n.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, + }; +} + + +function DeadlineHelp({ duration }: { duration?: Duration }): VNode { + const { i18n } = useTranslationContext(); + const [now, setNow] = useState(AbsoluteTime.now()) + useEffect(() => { + const iid = setInterval(() => { + setNow(AbsoluteTime.now()) + }, 60 * 1000) + return () => { + clearInterval(iid) + } + }) + if (!duration) return <i18n.Translate>Disabled</i18n.Translate> + const when = AbsoluteTime.addDuration(now, duration) + if (when.t_ms === "never") return <i18n.Translate>No deadline</i18n.Translate> + return <i18n.Translate>Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")}</i18n.Translate> +} |