diff options
Diffstat (limited to 'packages/merchant-backoffice-ui/src/paths/instance/orders')
12 files changed, 3242 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx new file mode 100644 index 000000000..fc814b68f --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx @@ -0,0 +1,71 @@ +/* + 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 { h, VNode, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/Order/Create", + component: TestedComponent, + argTypes: { + onCreate: { action: "onCreate" }, + goBack: { action: "goBack" }, + }, +}; + +function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => <Component {...args} />; + r.args = props; + return r; +} + +export const Example = createExample(TestedComponent, { + instanceConfig: { + default_pay_delay: { + d_us: 1000 * 1000 * 60 * 60, //one hour + }, + default_wire_transfer_delay: { + d_us: 1000 * 1000 * 60 * 60, //one hour + }, + use_stefan: true, + }, + instanceInventory: [ + { + id: "t-shirt-1", + description: "a m size t-shirt", + price: "TESTKUDOS:1", + total_stock: -1, + }, + { + id: "t-shirt-2", + price: "TESTKUDOS:1", + description: "a xl size t-shirt", + } as any, + { + id: "t-shirt-3", + price: "TESTKUDOS:1", + description: "a s size t-shirt", + } as any, + ], +}); 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..7be3d23f6 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx @@ -0,0 +1,794 @@ +/* + 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 { + AbsoluteTime, + AmountString, + Amounts, + Duration, + TalerMerchantApi, + TalerProtocolDuration, +} from "@gnu-taler/taler-util"; +import { + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { format, isFuture } from "date-fns"; +import { 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 { useSessionContext } from "../../../../context/session.js"; +import { usePreference } from "../../../../hooks/preference.js"; +import { rate } from "../../../../utils/amount.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; + +interface Props { + onCreate: (d: TalerMerchantApi.PostOrderRequest) => void; + onBack?: () => void; + instanceConfig: InstanceConfig; + instanceInventory: (TalerMerchantApi.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: TalerMerchantApi.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?: TalerMerchantApi.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: TalerMerchantApi.Product[]; + pricing: Partial<Pricing>; + payments: Partial<Payments>; + shipping: Partial<Shipping>; + extra: Record<string, string>; +} + +export function CreatePage({ + onCreate, + onBack, + instanceConfig, + instanceInventory, +}: Props): VNode { + const { config } = useSessionContext(); + const instance_default = with_defaults(instanceConfig, config.currency); + const [value, valueHandler] = useState(instance_default); + const zero = Amounts.zeroOfCurrency(config.currency); + const [settings, updateSettings] = usePreference(); + 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: TalerMerchantApi.PostOrderRequest = { + order: { + amount: order.pricing.order_price, + summary: order.pricing.summary, + products: productList, + extra: undefinedIfEmpty(value.extra), + pay_deadline: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + value.payments.pay_deadline!, + ), + ), + wire_transfer_deadline: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + value.payments.wire_transfer_deadline!, + ), + ), + refund_deadline: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.addDuration( + AbsoluteTime.now(), + value.payments.refund_deadline!, + ), + ), + auto_refund: value.payments.auto_refund_deadline + ? Duration.toTalerProtocolDuration( + value.payments.auto_refund_deadline, + ) + : undefined, + max_fee: value.payments.max_fee as AmountString, + 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: TalerMerchantApi.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: TalerMerchantApi.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< + TalerMerchantApi.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 ?? 0).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 = inventoryList.reduce( + (cur, prev) => + !prev.product.minimum_age || cur > prev.product.minimum_age ? cur : prev.product.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("advanceOrderMode", false); + }} + > + <a> + <span> + <i18n.Translate>Simple</i18n.Translate> + </span> + </a> + </li> + <li + class={settings.advanceOrderMode ? "is-active" : ""} + onClick={() => { + updateSettings("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, idx) => { + return ( + <Input + name={`extra.${key}`} + key={String(idx)} + 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 + ) { + delete value.extra[key]; + } + valueHandler({ + ...value, + }); + e.preventDefault(); + }} + > + 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]: "", + }, + }); + e.preventDefault(); + }} + > + 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): TalerMerchantApi.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, + }; +} + +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> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx new file mode 100644 index 000000000..32f3f05c7 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/OrderCreatedSuccessfully.tsx @@ -0,0 +1,141 @@ +/* + 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/> + */ +import { HttpStatusCode, TalerError, assertUnreachable } from "@gnu-taler/taler-util"; +import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { CreatedSuccessfully } from "../../../../components/notifications/CreatedSuccessfully.js"; +import { useOrderDetails } from "../../../../hooks/order.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { Entity } from "./index.js"; +import { LoginPage } from "../../../login/index.js"; + +interface Props { + entity: Entity; + onConfirm: () => void; + onCreateAnother?: () => void; +} + +export function OrderCreatedSuccessfully({ + entity, + onConfirm, + onCreateAnother, +}: Props): VNode { + const result = useOrderDetails(entity.response.order_id) + const { i18n } = useTranslationContext(); + + if (!result) return <Loading /> + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} /> + } + if (result.type === "fail") { + switch(result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate /> + } + case HttpStatusCode.BadGateway: { + return <div>Failed to obtain a response from the exchange</div>; + } + case HttpStatusCode.GatewayTimeout: { + return ( + <div>The merchant's interaction with the exchange took too long</div> + ); + } + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + default: { + assertUnreachable(result) + } + } + } + + const url = result.body.order_status === "unpaid" ? + result.body.taler_pay_uri : + result.body.contract_terms.fulfillment_url + + return ( + <CreatedSuccessfully + onConfirm={onConfirm} + onCreateAnother={onCreateAnother} + > + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Amount</i18n.Translate> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input + class="input" + readonly + value={entity.request.order.amount} + /> + </p> + </div> + </div> + </div> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Summary</i18n.Translate> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input + class="input" + readonly + value={entity.request.order.summary} + /> + </p> + </div> + </div> + </div> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Order ID</i18n.Translate> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input class="input" readonly value={entity.response.order_id} /> + </p> + </div> + </div> + </div> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Payment URL</i18n.Translate> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input class="input" readonly value={url} /> + </p> + </div> + </div> + </div> + </CreatedSuccessfully> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx new file mode 100644 index 000000000..861114014 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx @@ -0,0 +1,120 @@ +/* + 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 { HttpStatusCode, TalerError, TalerMerchantApi, assertUnreachable } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { useInstanceDetails } from "../../../../hooks/instance.js"; +import { useInstanceProducts } from "../../../../hooks/product.js"; +import { Notification } from "../../../../utils/types.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { CreatePage } from "./CreatePage.js"; + +export type Entity = { + request: TalerMerchantApi.PostOrderRequest; + response: TalerMerchantApi.PostOrderResponse; +}; +interface Props { + onBack?: () => void; + onConfirm: (id: string) => void; +} +export default function OrderCreate({ + onConfirm, + onBack, +}: Props): VNode { + const { lib } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { state } = useSessionContext(); + const detailsResult = useInstanceDetails(); + const inventoryResult = useInstanceProducts(); + + if (!detailsResult) return <Loading /> + if (detailsResult instanceof TalerError) { + return <ErrorLoadingMerchant error={detailsResult} /> + } + if (detailsResult.type === "fail") { + switch (detailsResult.case) { + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + default: { + assertUnreachable(detailsResult); + } + } + } + if (!inventoryResult) return <Loading /> + if (inventoryResult instanceof TalerError) { + return <ErrorLoadingMerchant error={inventoryResult} /> + } + if (inventoryResult.type === "fail") { + switch (inventoryResult.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + default: { + assertUnreachable(inventoryResult); + } + } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <CreatePage + onBack={onBack} + onCreate={(request: TalerMerchantApi.PostOrderRequest) => { + lib.instance.createOrder(state.token, request) + .then((r) => { + if (r.type === "ok") { + return onConfirm(r.body.order_id) + } else { + setNotif({ + message: "could not create order", + type: "ERROR", + }); + } + }) + .catch((error) => { + setNotif({ + message: "could not create order", + type: "ERROR", + description: error.message, + }); + }); + }} + instanceConfig={detailsResult.body} + instanceInventory={inventoryResult.body} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx new file mode 100644 index 000000000..7d4877db9 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx @@ -0,0 +1,134 @@ +/* + 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 { AmountString, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { addDays } from "date-fns"; +import { FunctionalComponent, h } from "preact"; +import { DetailPage as TestedComponent } from "./DetailPage.js"; + +export default { + title: "Pages/Order/Detail", + component: TestedComponent, + argTypes: { + onRefund: { action: "onRefund" }, + onBack: { action: "onBack" }, + }, +}; + +function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => <Component {...args} />; + r.args = props; + return r; +} + +const defaultContractTerm: TalerMerchantApi.ContractTerms = { + amount: "TESTKUDOS:10" as AmountString, + timestamp: { + t_s: new Date().getTime() / 1000, + }, + exchanges: [], + max_fee: "TESTKUDOS:1" as AmountString, + merchant: {} as any, + merchant_base_url: "http://merchant.url/", + order_id: "2021.165-03GDFC26Y1NNG", + products: [], + summary: "text summary", + wire_transfer_deadline: { + t_s: "never", + }, + refund_deadline: { t_s: "never" }, + merchant_pub: "ASDASDASDSd", + nonce: "QWEQWEQWE", + pay_deadline: { + t_s: "never", + }, + wire_method: "x-taler-bank", + h_wire: "asd", +}; + +// contract_terms: defaultContracTerm, +export const Claimed = createExample(TestedComponent, { + id: "2021.165-03GDFC26Y1NNG", + selected: { + order_status: "claimed", + contract_terms: defaultContractTerm, + }, +}); + +export const PaidNotRefundable = createExample(TestedComponent, { + id: "2021.165-03GDFC26Y1NNG", + selected: { + order_status: "paid", + contract_terms: defaultContractTerm, + refunded: false, + deposit_total: "TESTKUDOS:10" as AmountString, + exchange_code: 0, + order_status_url: "http://merchant.backend/status", + exchange_http_status: 0, + refund_amount: "TESTKUDOS:0" as AmountString, + refund_details: [], + refund_pending: false, + wire_details: [], + wired: false, + wire_reports: [], + }, +}); + +export const PaidRefundable = createExample(TestedComponent, { + id: "2021.165-03GDFC26Y1NNG", + selected: { + order_status: "paid", + contract_terms: { + ...defaultContractTerm, + refund_deadline: { + t_s: addDays(new Date(), 2).getTime() / 1000, + }, + }, + refunded: false, + deposit_total: "TESTKUDOS:10" as AmountString, + exchange_code: 0, + order_status_url: "http://merchant.backend/status", + exchange_http_status: 0, + refund_amount: "TESTKUDOS:0" as AmountString, + refund_details: [], + wire_reports: [], + refund_pending: false, + wire_details: [], + wired: false, + }, +}); + +export const Unpaid = createExample(TestedComponent, { + id: "2021.165-03GDFC26Y1NNG", + selected: { + order_status: "unpaid", + order_status_url: "http://merchant.backend/status", + creation_time: { + t_s: new Date().getTime() / 1000, + }, + summary: "text summary", + taler_pay_uri: "pay uri", + total_amount: "TESTKUDOS:10" as AmountString, + }, +}); 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> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx new file mode 100644 index 000000000..2d62e2252 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx @@ -0,0 +1,129 @@ +/* + 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/> + */ +import { format } from "date-fns"; +import { h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { datetimeFormatForSettings, usePreference } from "../../../../hooks/preference.js"; + +interface Props { + events: Event[]; +} + +export function Timeline({ events: e }: Props) { + const events = [...e]; + events.push({ + when: new Date(), + description: "now", + type: "now", + }); + + events.sort((a, b) => a.when.getTime() - b.when.getTime()); + const [settings] = usePreference(); + const [state, setState] = useState(events); + useEffect(() => { + const handle = setTimeout(() => { + const eventsWithoutNow = state.filter((e) => e.type !== "now"); + eventsWithoutNow.push({ + when: new Date(), + description: "now", + type: "now", + }); + setState(eventsWithoutNow); + }, 1000); + return () => { + clearTimeout(handle); + }; + }); + return ( + <div class="timeline"> + {events.map((e, i) => { + return ( + <div key={i} class="timeline-item"> + {(() => { + switch (e.type) { + case "deadline": + return ( + <div class="timeline-marker is-icon "> + <i class="mdi mdi-flag" /> + </div> + ); + case "delivery": + return ( + <div class="timeline-marker is-icon "> + <i class="mdi mdi-delivery" /> + </div> + ); + case "start": + return ( + <div class="timeline-marker is-icon"> + <i class="mdi mdi-flag " /> + </div> + ); + case "wired": + return ( + <div class="timeline-marker is-icon is-success"> + <i class="mdi mdi-cash" /> + </div> + ); + case "wired-range": + return ( + <div class="timeline-marker is-icon is-success"> + <i class="mdi mdi-cash" /> + </div> + ); + case "refund": + return ( + <div class="timeline-marker is-icon is-danger"> + <i class="mdi mdi-cash" /> + </div> + ); + case "refund-taken": + return ( + <div class="timeline-marker is-icon is-success"> + <i class="mdi mdi-cash" /> + </div> + ); + case "now": + return ( + <div class="timeline-marker is-icon is-info"> + <i class="mdi mdi-clock" /> + </div> + ); + } + })()} + <div class="timeline-content"> + {e.description !== "now" && <p class="heading">{format(e.when, datetimeFormatForSettings(settings))}</p>} + <p>{e.description}</p> + </div> + </div> + ); + })} + </div> + ); +} +export interface Event { + when: Date; + description: string; + type: + | "start" + | "refund" + | "refund-taken" + | "wired" + | "wired-range" + | "deadline" + | "delivery" + | "now"; +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx new file mode 100644 index 000000000..b28e59b29 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/index.tsx @@ -0,0 +1,106 @@ +/* + 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/> + */ +import { + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { useOrderDetails } from "../../../../hooks/order.js"; +import { Notification } from "../../../../utils/types.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { DetailPage } from "./DetailPage.js"; + +export interface Props { + oid: string; + onBack: () => void; +} + +export default function Update({ oid, onBack }: Props): VNode { + const result = useOrderDetails(oid); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { lib: api } = useSessionContext(); + const { state } = useSessionContext(); + + const { i18n } = useTranslationContext(); + + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.BadGateway: { + return <div>Failed to obtain a response from the exchange</div>; + } + case HttpStatusCode.GatewayTimeout: { + return ( + <div>The merchant's interaction with the exchange took too long</div> + ); + } + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + default: { + assertUnreachable(result); + } + } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <DetailPage + onBack={onBack} + id={oid} + onRefund={(id, value) => { + if (state.status !== "loggedIn") { + return; + } + api.instance + .addRefund(state.token, id, value) + .then(() => + setNotif({ + message: i18n.str`refund created successfully`, + type: "SUCCESS", + }), + ) + .catch((error) => + setNotif({ + message: i18n.str`could not create the refund`, + type: "ERROR", + description: error.message, + }), + ); + }} + selected={result.body} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx new file mode 100644 index 000000000..5c9969689 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/List.stories.tsx @@ -0,0 +1,108 @@ +/* + 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 { FunctionalComponent, h } from "preact"; +import { ListPage as TestedComponent } from "./ListPage.js"; +import { AmountString } from "@gnu-taler/taler-util"; + +export default { + title: "Pages/Order/List", + component: TestedComponent, + argTypes: { + onShowAll: { action: "onShowAll" }, + onShowPaid: { action: "onShowPaid" }, + onShowRefunded: { action: "onShowRefunded" }, + onShowNotWired: { action: "onShowNotWired" }, + onCopyURL: { action: "onCopyURL" }, + onSelectDate: { action: "onSelectDate" }, + onLoadMoreBefore: { action: "onLoadMoreBefore" }, + onLoadMoreAfter: { action: "onLoadMoreAfter" }, + onSelectOrder: { action: "onSelectOrder" }, + onRefundOrder: { action: "onRefundOrder" }, + onSearchOrderById: { action: "onSearchOrderById" }, + onCreate: { action: "onCreate" }, + }, +}; + +function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => <Component {...args} />; + r.args = props; + return r; +} + +export const Example = createExample(TestedComponent, { + orders: [ + { + id: "123", + amount: "TESTKUDOS:10" as AmountString, + paid: false, + refundable: true, + row_id: 1, + summary: "summary", + timestamp: { + t_s: new Date().getTime() / 1000, + }, + order_id: "123", + }, + { + id: "234", + amount: "TESTKUDOS:12" as AmountString, + paid: true, + refundable: true, + row_id: 2, + summary: + "summary with long text, very very long text that someone want to add as a description of the order", + timestamp: { + t_s: new Date().getTime() / 1000, + }, + order_id: "234", + }, + { + id: "456", + amount: "TESTKUDOS:1" as AmountString, + paid: false, + refundable: false, + row_id: 3, + summary: + "summary with long text, very very long text that someone want to add as a description of the order", + timestamp: { + t_s: new Date().getTime() / 1000, + }, + order_id: "456", + }, + { + id: "234", + amount: "TESTKUDOS:12" as AmountString, + paid: false, + refundable: false, + row_id: 4, + summary: + "summary with long text, very very long text that someone want to add as a description of the order", + timestamp: { + t_s: new Date().getTime() / 1000, + }, + order_id: "234", + }, + ], +}); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx new file mode 100644 index 000000000..408bc0c0a --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx @@ -0,0 +1,222 @@ +/* + 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 { AbsoluteTime, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { DatePicker } from "../../../../components/picker/DatePicker.js"; +import { dateFormatForSettings, usePreference } from "../../../../hooks/preference.js"; +import { CardTable } from "./Table.js"; + +export interface ListPageProps { + onShowAll: () => void; + onShowNotPaid: () => void; + onShowPaid: () => void; + onShowRefunded: () => void; + onShowNotWired: () => void; + onShowWired: () => void; + onCopyURL: (id: string) => void; + isAllActive: string; + isPaidActive: string; + isNotPaidActive: string; + isRefundedActive: string; + isNotWiredActive: string; + isWiredActive: string; + + jumpToDate?: AbsoluteTime; + onSelectDate: (date?: AbsoluteTime) => void; + + orders: (TalerMerchantApi.OrderHistoryEntry & WithId)[]; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; + + onSelectOrder: (o: TalerMerchantApi.OrderHistoryEntry & WithId) => void; + onRefundOrder: (o: TalerMerchantApi.OrderHistoryEntry & WithId) => void; + onCreate: () => void; +} + +export function ListPage({ + onLoadMoreAfter, + onLoadMoreBefore, + orders, + isAllActive, + onSelectOrder, + onRefundOrder, + jumpToDate, + onCopyURL, + onShowAll, + onShowPaid, + onShowNotPaid, + onShowRefunded, + onShowNotWired, + onShowWired, + onSelectDate, + isPaidActive, + isRefundedActive, + isNotWiredActive, + onCreate, + isNotPaidActive, + isWiredActive, +}: ListPageProps): VNode { + const { i18n } = useTranslationContext(); + const dateTooltip = i18n.str`select date to show nearby orders`; + const [pickDate, setPickDate] = useState(false); + const [settings] = usePreference(); + + return ( + <Fragment> + <div class="columns"> + <div class="column is-two-thirds"> + <div class="tabs" style={{ overflow: "inherit" }}> + <ul> + <li class={isNotPaidActive}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`only show paid orders`} + > + <a onClick={onShowNotPaid}> + <i18n.Translate>New</i18n.Translate> + </a> + </div> + </li> + <li class={isPaidActive}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`only show paid orders`} + > + <a onClick={onShowPaid}> + <i18n.Translate>Paid</i18n.Translate> + </a> + </div> + </li> + <li class={isRefundedActive}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`only show orders with refunds`} + > + <a onClick={onShowRefunded}> + <i18n.Translate>Refunded</i18n.Translate> + </a> + </div> + </li> + <li class={isNotWiredActive}> + <div + class="has-tooltip-left" + data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`} + > + <a onClick={onShowNotWired}> + <i18n.Translate>Not wired</i18n.Translate> + </a> + </div> + </li> + <li class={isWiredActive}> + <div + class="has-tooltip-left" + data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`} + > + <a onClick={onShowWired}> + <i18n.Translate>Completed</i18n.Translate> + </a> + </div> + </li> + <li class={isAllActive}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`remove all filters`} + > + <a onClick={onShowAll}> + <i18n.Translate>All</i18n.Translate> + </a> + </div> + </li> + </ul> + </div> + </div> + <div class="column "> + <div class="buttons is-right"> + <div class="field has-addons"> + {jumpToDate && ( + <div class="control"> + <a class="button is-fullwidth" onClick={() => onSelectDate(undefined)}> + <span + class="icon" + data-tooltip={i18n.str`clear date filter`} + > + <i class="mdi mdi-close" /> + </span> + </a> + </div> + )} + <div class="control"> + <span class="has-tooltip-top" data-tooltip={dateTooltip}> + <input + class="input" + type="text" + readonly + value={!jumpToDate || jumpToDate.t_ms === "never" ? "" : format(jumpToDate.t_ms, dateFormatForSettings(settings))} + placeholder={i18n.str`date (${dateFormatForSettings(settings)})`} + onClick={() => { + setPickDate(true); + }} + /> + </span> + </div> + <div class="control"> + <span class="has-tooltip-left" data-tooltip={dateTooltip}> + <a + class="button is-fullwidth" + onClick={() => { + setPickDate(true); + }} + > + <span class="icon"> + <i class="mdi mdi-calendar" /> + </span> + </a> + </span> + </div> + </div> + </div> + </div> + </div> + + <DatePicker + opened={pickDate} + closeFunction={() => setPickDate(false)} + dateReceiver={(d) => { + onSelectDate(AbsoluteTime.fromMilliseconds(d.getTime())) + }} + /> + + <CardTable + orders={orders} + onCreate={onCreate} + onCopyURL={onCopyURL} + onSelect={onSelectOrder} + onRefund={onRefundOrder} + onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={onLoadMoreBefore} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx new file mode 100644 index 000000000..5ece34409 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx @@ -0,0 +1,407 @@ +/* + 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 { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { VNode, h } from "preact"; +import { StateUpdater, 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 { InputGroup } from "../../../../components/form/InputGroup.js"; +import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { ConfirmModal } from "../../../../components/modal/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { + datetimeFormatForSettings, + usePreference, +} from "../../../../hooks/preference.js"; +import { mergeRefunds } from "../../../../utils/amount.js"; + +type Entity = TalerMerchantApi.OrderHistoryEntry & WithId; +interface Props { + orders: Entity[]; + onRefund: (value: Entity) => void; + onCopyURL: (id: string) => void; + onCreate: () => void; + onSelect: (order: Entity) => void; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; +} + +export function CardTable({ + orders, + onCreate, + onRefund, + onCopyURL, + onSelect, + onLoadMoreAfter, + onLoadMoreBefore, +}: Props): VNode { + const [rowSelection, rowSelectionHandler] = useState<string[]>([]); + + const { i18n } = useTranslationContext(); + + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-cash-register" /> + </span> + <i18n.Translate>Orders</i18n.Translate> + </p> + + <div class="card-header-icon" aria-label="more options" /> + + <div class="card-header-icon" aria-label="more options"> + <span class="has-tooltip-left" data-tooltip={i18n.str`create order`}> + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small"> + <i class="mdi mdi-plus mdi-36px" /> + </span> + </button> + </span> + </div> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {orders.length > 0 ? ( + <Table + instances={orders} + onSelect={onSelect} + onRefund={onRefund} + onCopyURL={(o) => onCopyURL(o.id)} + rowSelection={rowSelection} + rowSelectionHandler={rowSelectionHandler} + onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={onLoadMoreBefore} + /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onRefund: (id: Entity) => void; + onCopyURL: (id: Entity) => void; + onSelect: (id: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; +} + +function Table({ + instances, + onSelect, + onRefund, + onCopyURL, + onLoadMoreAfter, + onLoadMoreBefore, +}: TableProps): VNode { + const { i18n } = useTranslationContext(); + const [settings] = usePreference(); + return ( + <div class="table-container"> + {onLoadMoreBefore && ( + <button class="button is-fullwidth" onClick={onLoadMoreBefore}> + <i18n.Translate>load first page</i18n.Translate> + </button> + )} + <table class="table is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th style={{ minWidth: 100 }}> + <i18n.Translate>Date</i18n.Translate> + </th> + <th style={{ minWidth: 100 }}> + <i18n.Translate>Amount</i18n.Translate> + </th> + <th style={{ minWidth: 400 }}> + <i18n.Translate>Summary</i18n.Translate> + </th> + <th style={{ minWidth: 50 }} /> + </tr> + </thead> + <tbody> + {instances.map((i) => { + return ( + <tr key={i.id}> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.timestamp.t_s === "never" + ? "never" + : format( + new Date(i.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} + </td> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.amount} + </td> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.summary} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + {i.refundable && ( + <button + class="button is-small is-danger jb-modal" + type="button" + onClick={(): void => onRefund(i)} + > + <i18n.Translate>Refund</i18n.Translate> + </button> + )} + {!i.paid && ( + <button + class="button is-small is-info jb-modal" + type="button" + onClick={(): void => onCopyURL(i)} + > + <i18n.Translate>copy url</i18n.Translate> + </button> + )} + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + {onLoadMoreAfter && ( + <button class="button is-fullwidth" + data-tooltip={i18n.str`load more orders after the last one`} + onClick={onLoadMoreAfter}> + <i18n.Translate>load next page</i18n.Translate> + </button> + )} + </div> + ); +} + +function EmptyTable(): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"> + <i class="mdi mdi-magnify mdi-48px" /> + </span> + </p> + <p> + <i18n.Translate> + No orders have been found matching your query! + </i18n.Translate> + </p> + </div> + ); +} + +interface RefundModalProps { + onCancel: () => void; + onConfirm: (value: TalerMerchantApi.RefundRequest) => void; + order: TalerMerchantApi.MerchantOrderStatusResponse; +} + +export function RefundModal({ + order, + onCancel, + onConfirm, +}: RefundModalProps): VNode { + type State = { mainReason?: string; description?: string; refund?: string }; + const [form, setValue] = useState<State>({}); + const [settings] = usePreference(); + const { i18n } = useTranslationContext(); + // const [errors, setErrors] = useState<FormErrors<State>>({}); + + const refunds = ( + order.order_status === "paid" ? order.refund_details : [] + ).reduce(mergeRefunds, []); + + const { config } = useSessionContext(); + const totalRefunded = refunds + .map((r) => r.amount) + .reduce( + (p, c) => Amounts.add(p, Amounts.parseOrThrow(c)).amount, + Amounts.zeroOfCurrency(config.currency), + ); + const orderPrice = + order.order_status === "paid" + ? Amounts.parseOrThrow(order.contract_terms.amount) + : undefined; + const totalRefundable = !orderPrice + ? Amounts.zeroOfCurrency(totalRefunded.currency) + : refunds.length + ? Amounts.sub(orderPrice, totalRefunded).amount + : orderPrice; + + const isRefundable = Amounts.isNonZero(totalRefundable); + const duplicatedText = i18n.str`duplicated`; + + const errors: FormErrors<State> = { + mainReason: !form.mainReason ? i18n.str`required` : undefined, + description: + !form.description && form.mainReason !== duplicatedText + ? i18n.str`required` + : undefined, + refund: !form.refund + ? i18n.str`required` + : !Amounts.parse(form.refund) + ? i18n.str`invalid format` + : Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1 + ? i18n.str`this value exceed the refundable amount` + : undefined, + }; + const hasErrors = Object.keys(errors).some( + (k) => (errors as Record<string, unknown>)[k] !== undefined, + ); + + const validateAndConfirm = () => { + try { + if (!form.refund) return; + onConfirm({ + refund: Amounts.stringify( + Amounts.add(Amounts.parse(form.refund)!, totalRefunded).amount, + ), + reason: + form.description === undefined + ? form.mainReason || "" + : `${form.mainReason}: ${form.description}`, + }); + } catch (err) { + console.log(err); + } + }; + + //FIXME: parameters in the translation + return ( + <ConfirmModal + description="refund" + danger + active + disabled={!isRefundable || hasErrors} + onCancel={onCancel} + onConfirm={validateAndConfirm} + > + {refunds.length > 0 && ( + <div class="columns"> + <div class="column is-12"> + <InputGroup + name="asd" + label={`${Amounts.stringify(totalRefunded)} was already refunded`} + > + <table class="table is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>date</i18n.Translate> + </th> + <th> + <i18n.Translate>amount</i18n.Translate> + </th> + <th> + <i18n.Translate>reason</i18n.Translate> + </th> + </tr> + </thead> + <tbody> + {refunds.map((r) => { + return ( + <tr key={r.timestamp.t_s}> + <td> + {r.timestamp.t_s === "never" + ? "never" + : format( + new Date(r.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} + </td> + <td>{r.amount}</td> + <td>{r.reason}</td> + </tr> + ); + })} + </tbody> + </table> + </InputGroup> + </div> + </div> + )} + + {isRefundable && ( + <FormProvider<State> + errors={errors} + object={form} + valueHandler={(d) => setValue(d)} + > + <InputCurrency<State> + name="refund" + label={i18n.str`Refund`} + tooltip={i18n.str`amount to be refunded`} + > + <i18n.Translate>Max refundable:</i18n.Translate>{" "} + {Amounts.stringify(totalRefundable)} + </InputCurrency> + <InputSelector + name="mainReason" + label={i18n.str`Reason`} + values={[ + i18n.str`Choose one...`, + duplicatedText, + i18n.str`requested by the customer`, + i18n.str`other`, + ]} + tooltip={i18n.str`why this order is being refunded`} + /> + {form.mainReason && form.mainReason !== duplicatedText ? ( + <Input<State> + label={i18n.str`Description`} + name="description" + tooltip={i18n.str`more information to give context`} + /> + ) : undefined} + </FormProvider> + )} + </ConfirmModal> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx new file mode 100644 index 000000000..8a1f85b1c --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx @@ -0,0 +1,230 @@ +/* + 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 { + AbsoluteTime, + HttpStatusCode, + TalerError, + TalerMerchantApi, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { Loading } from "../../../../components/exception/loading.js"; +import { JumpToElementById } from "../../../../components/form/JumpToElementById.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { + InstanceOrderFilter, + useInstanceOrders, + useOrderDetails, +} from "../../../../hooks/order.js"; +import { Notification } from "../../../../utils/types.js"; +import { LoginPage } from "../../../login/index.js"; +import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; +import { ListPage } from "./ListPage.js"; +import { RefundModal } from "./Table.js"; + +interface Props { + onSelect: (id: string) => void; + onCreate: () => void; +} + +export default function OrderList({ onCreate, onSelect }: Props): VNode { + const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: false }); + const [orderToBeRefunded, setOrderToBeRefunded] = useState< + TalerMerchantApi.OrderHistoryEntry | undefined + >(undefined); + + const setNewDate = (date?: AbsoluteTime): void => + setFilter((prev) => ({ ...prev, date })); + + const result = useInstanceOrders(filter, (d) => + setFilter({ ...filter, position: d }), + ); + const { lib } = useSessionContext(); + + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const { i18n } = useTranslationContext(); + const { state } = useSessionContext(); + + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch(result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + default: { + assertUnreachable(result) + } + } + } + + const isNotPaidActive = filter.paid === false ? "is-active" : ""; + const isPaidActive = + filter.paid === true && filter.wired === undefined ? "is-active" : ""; + const isRefundedActive = filter.refunded === true ? "is-active" : ""; + const isNotWiredActive = + filter.wired === false && filter.paid === true ? "is-active" : ""; + const isWiredActive = filter.wired === true ? "is-active" : ""; + const isAllActive = + filter.paid === undefined && + filter.refunded === undefined && + filter.wired === undefined + ? "is-active" + : ""; + + return ( + <section class="section is-main-section"> + <NotificationCard notification={notif} /> + + <JumpToElementById + testIfExist={async (order) => { + const resp = await lib.instance.getOrderDetails(state.token, order); + return resp.type === "ok"; + }} + onSelect={onSelect} + description={i18n.str`jump to order with the given product ID`} + placeholder={i18n.str`order id`} + /> + + <ListPage + orders={result.body.map((o) => ({ ...o, id: o.order_id }))} + onLoadMoreBefore={result.isFirstPage ? undefined : result.loadFirst} + onLoadMoreAfter={result.isLastPage ? undefined : result.loadNext} + onSelectOrder={(order) => onSelect(order.id)} + onRefundOrder={(value) => setOrderToBeRefunded(value)} + isAllActive={isAllActive} + isNotWiredActive={isNotWiredActive} + isWiredActive={isWiredActive} + isPaidActive={isPaidActive} + isNotPaidActive={isNotPaidActive} + isRefundedActive={isRefundedActive} + jumpToDate={filter.date} + onSelectDate={setNewDate} + onCopyURL={async (id) => { + const resp = await lib.instance.getOrderDetails(state.token, id); + if (resp.type === "ok") { + if (resp.body.order_status === "unpaid") { + copyToClipboard(resp.body.taler_pay_uri); + } else { + if (resp.body.contract_terms.fulfillment_url) { + copyToClipboard(resp.body.contract_terms.fulfillment_url); + } + } + copyToClipboard(resp.body.order_status); + } + }} + onCreate={onCreate} + onShowAll={() => setFilter({})} + onShowNotPaid={() => setFilter({ paid: false })} + onShowPaid={() => setFilter({ paid: true })} + onShowRefunded={() => setFilter({ refunded: true })} + onShowNotWired={() => setFilter({ wired: false, paid: true })} + onShowWired={() => setFilter({ wired: true })} + /> + + {orderToBeRefunded && ( + <RefundModalForTable + id={orderToBeRefunded.order_id} + onCancel={() => setOrderToBeRefunded(undefined)} + onConfirm={(value) => { + lib.instance + .addRefund(state.token, orderToBeRefunded.order_id, value) + .then(() => + setNotif({ + message: i18n.str`refund created successfully`, + type: "SUCCESS", + }), + ) + .catch((error) => + setNotif({ + message: i18n.str`could not create the refund`, + type: "ERROR", + description: error.message, + }), + ) + .then(() => setOrderToBeRefunded(undefined)); + }} + /> + )} + </section> + ); +} + +interface RefundProps { + id: string; + onCancel: () => void; + onConfirm: (m: TalerMerchantApi.RefundRequest) => void; +} + +function RefundModalForTable({ id, onConfirm, onCancel }: RefundProps): VNode { + const result = useOrderDetails(id); + + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: { + return <NotFoundPageOrAdminCreate />; + } + case HttpStatusCode.BadGateway: { + return <div>Failed to obtain a response from the exchange</div>; + } + case HttpStatusCode.GatewayTimeout: { + return ( + <div>The merchant's interaction with the exchange took too long</div> + ); + } + case HttpStatusCode.Unauthorized: { + return <LoginPage /> + } + default: { + assertUnreachable(result); + } + } + } + + return ( + <RefundModal + order={result.body} + onCancel={onCancel} + onConfirm={onConfirm} + /> + ); +} + +async function copyToClipboard(text: string): Promise<void> { + return navigator.clipboard.writeText(text); +} |