/* 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 */ /** * * @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 { 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; payments: Partial; shipping: Partial; extra: Record; } 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 = { 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 defailt 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 (
  • { updateSettings({ ...settings, advanceOrderMode: false }) }}> Simple
  • { updateSettings({ ...settings, advanceOrderMode: true }) }}> Advanced
{/* // FIXME: translating plural singular */} 0 && (

{allProducts.length} products with a total price of{" "} {totalAsString}.

) } tooltip={i18n.str`Manage list of products in the order.`} > {settings.advanceOrderMode && { setEditingProduct(undefined); return addNewProduct(p); }} /> } {allProducts.length > 0 && ( { if (e.product_id) { removeProductFromTheInventoryList(e.product_id); } else { removeFromNewProduct(index); setEditingProduct(e); } }, }, ]} /> )}
errors={errors} object={value} valueHandler={valueHandler as any} > {hasProducts ? ( 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`} /> ) : ( )} {settings.advanceOrderMode && {value.shipping?.delivery_date && ( )} } {(settings.advanceOrderMode || requiresSomeTalerOptions) && {(settings.advanceOrderMode || noDefault_payDeadline) && } 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={ } />} {settings.advanceOrderMode && } withForever withoutClear tooltip={i18n.str`Time while the order can be refunded by the merchant. Time starts after the order is created.`} side={ } />} {(settings.advanceOrderMode || noDefault_wireDeadline) && } withoutClear withForever tooltip={i18n.str`Time for the exchange to make the wire transfer. Time starts after the order is created.`} side={ } />} {settings.advanceOrderMode && } tooltip={i18n.str`Time until which the wallet will automatically check for refunds without user interaction.`} withForever />} {settings.advanceOrderMode && } {settings.advanceOrderMode && } {settings.advanceOrderMode && 0 ? i18n.str`Min age defined by the producs is ${minAgeByProducts}` : i18n.str`No product with age restriction in this order` } />} } {settings.advanceOrderMode && {Object.keys(value.extra ?? {}).map((key) => { return { if (value.extra && value.extra[key] !== undefined) { console.log(value.extra) delete value.extra[key] } valueHandler({ ...value, }) }}>remove } /> })}

setNewField(e.currentTarget.value)} />

}
{onBack && ( )}
); } 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 Disabled const when = AbsoluteTime.addDuration(now, duration) if (when.t_ms === "never") return No deadline return Deadline at {format(when.t_ms, "dd/MM/yy HH:mm")} }