/* 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 */ /** * * @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.js"; import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputDate } from "../../../../components/form/InputDate.js"; import { InputGroup } from "../../../../components/form/InputGroup.js"; import { InputLocation } from "../../../../components/form/InputLocation.js"; import { ProductList } from "../../../../components/product/ProductList.js"; import { useConfigContext } from "../../../../context/config.js"; import { Duration, MerchantBackend, WithId } from "../../../../declaration.js"; import { Translate, useTranslator } from "../../../../i18n/index.js"; import { OrderCreateSchema as schema } from "../../../../schemas/index.js"; import { rate } from "../../../../utils/amount.js"; import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js"; import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js"; import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputBoolean } from "../../../../components/form/InputBoolean.js"; 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 { 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; payments: Partial; shipping: Partial; extra: string; } const stringIsValidJSON = (value: string) => { try { JSON.parse(value.trim()); return true; } catch { return false; } }; function undefinedIfEmpty(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 = { 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 ( {/* // FIXME: translating plural singular */} 0 && ( {allProducts.length} products with a total price of{" "} {totalAsString}. ) } tooltip={i18n`Manage list of products in the order.`} > { 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`Amount to be paid by the customer`} /> ) : ( )} {value.shipping?.delivery_date && ( )} 0 ? i18n`Min age defined by the producs is ${minAgeByProducts}` : undefined } /> {onBack && ( Cancel )} Confirm ); } 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, }; }
{allProducts.length} products with a total price of{" "} {totalAsString}.