commit d085d2a76b10096ca271ba3ff5a34608af6829b7 parent 7f9e128ca97d9f457d8ecac11d4b5cf8d0d7fe95 Author: Sebastian <sebasjm@taler-systems.com> Date: Thu, 12 Feb 2026 15:47:40 -0300 fix #10527 Diffstat:
18 files changed, 380 insertions(+), 301 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/components/Amount.tsx b/packages/merchant-backoffice-ui/src/components/Amount.tsx @@ -1,107 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 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 { - amountFractionalBase, - amountFractionalLength, - AmountJson, - Amounts, - AmountString, -} from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; - -export function Amount({ - value, - maxFracSize, - negative, - hideCurrency, - signType = "standard", - signDisplay = "auto", -}: { - negative?: boolean; - value: AmountJson | AmountString; - maxFracSize?: number; - hideCurrency?: boolean; - signType?: "accounting" | "standard"; - signDisplay?: "auto" | "always" | "never" | "exceptZero"; -}): VNode { - const aj = Amounts.jsonifyAmount(value); - const minFractional = - maxFracSize !== undefined && maxFracSize < 2 ? maxFracSize : 2; - const af = aj.fraction % amountFractionalBase; - let s = ""; - if ((af && maxFracSize) || minFractional > 0) { - s += "."; - let n = af; - for ( - let i = 0; - (maxFracSize === undefined || i < maxFracSize) && - i < amountFractionalLength; - i++ - ) { - if (!n && i >= minFractional) { - break; - } - s = s + Math.floor((n / amountFractionalBase) * 10).toString(); - n = (n * 10) % amountFractionalBase; - } - } - const fontSize = 18; - const letterSpacing = 0; - const mult = 0.7; - return ( - <span style={{ textAlign: "right", whiteSpace: "nowrap" }}> - <span - style={{ - display: "inline-block", - fontFamily: "monospace", - fontSize, - }} - > - {negative ? (signType === "accounting" ? "(" : "-") : ""} - <span - style={{ - display: "inline-block", - textAlign: "right", - fontFamily: "monospace", - fontSize, - letterSpacing, - }} - > - {aj.value} - </span> - <span - style={{ - display: "inline-block", - width: !maxFracSize ? undefined : `${(maxFracSize + 1) * mult}em`, - textAlign: "left", - fontFamily: "monospace", - fontSize, - letterSpacing, - }} - > - {s} - {negative && signType === "accounting" ? ")" : ""} - </span> - </span> - {hideCurrency ? undefined : ( - <Fragment> - - <span>{aj.currency}</span> - </Fragment> - )} - </span> - ); -} diff --git a/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx b/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx @@ -66,7 +66,7 @@ export function FormProvider<T>({ return ( <FormContext.Provider value={value}> <form>{children}</form> - {showDebugInfo ? <pre>{JSON.stringify(value.object, undefined, 2)}</pre> : undefined} + {showDebugInfo ? <pre>{JSON.stringify({form: value.object, errors}, undefined, 2)}</pre> : undefined} </FormContext.Provider> ); } diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx @@ -35,8 +35,9 @@ const TALER_SCREEN_ID = 7; export interface Props<T> extends InputProps<T> { readonly?: boolean; expand?: boolean; - //FIXME: create separated components InputDate and InputTimestamp - withTimestampSupport?: boolean; + useProtocolTimestamp?: boolean; + withNever?: boolean; + withoutClear?: boolean; side?: ComponentChildren; } @@ -48,7 +49,9 @@ export function InputDate<T>({ help, tooltip, expand, - withTimestampSupport, + useProtocolTimestamp, + withNever, + withoutClear, side, }: Props<keyof T>): VNode { const [opened, setOpened] = useState(false); @@ -59,15 +62,14 @@ export function InputDate<T>({ let strValue = ""; if (!value) { - strValue = withTimestampSupport ? "unknown" : ""; + strValue = ""; } else if (value instanceof Date) { - strValue = format(value, datetimeFormatForPreferences(preferences)); + // FIXME: how this can be possible? remove it! + throw Error("date not allowed, file a bug.") } else if (value.t_s) { strValue = value.t_s === "never" - ? withTimestampSupport - ? i18n.str`Never` - : "" + ? i18n.str`Never` : format( new Date(value.t_s * 1000), datetimeFormatForPreferences(preferences), @@ -133,14 +135,8 @@ export function InputDate<T>({ )} </div> - {!readonly && ( - <span - data-tooltip={ - withTimestampSupport - ? i18n.str`Change value to unknown date` - : i18n.str`Change value to empty` - } - > + {!readonly && !withoutClear && ( + <span data-tooltip={i18n.str`Change value to empty`}> <button type="button" class="button is-info mr-3" @@ -150,7 +146,7 @@ export function InputDate<T>({ </button> </span> )} - {withTimestampSupport && ( + {withNever && ( <span data-tooltip={i18n.str`Change value to never`}> <button type="button" @@ -167,7 +163,7 @@ export function InputDate<T>({ opened={opened} closeFunction={() => setOpened(false)} dateReceiver={(d) => { - if (withTimestampSupport) { + if (useProtocolTimestamp) { onChange({ t_s: d.getTime() / 1000 } as any); } else { onChange(d as any); diff --git a/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx b/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx @@ -201,7 +201,8 @@ export function InputStock<T>({ <InputDate<Entity> name="nextRestock" label={i18n.str`Next restock`} - withTimestampSupport + useProtocolTimestamp + withNever /> <InputGroup<Entity> name="address" label={i18n.str`Warehouse address`}> diff --git a/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx b/packages/merchant-backoffice-ui/src/components/product/InventoryProductForm.tsx @@ -15,7 +15,7 @@ */ import { TalerMerchantApi } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { WithId } from "../../declaration.js"; import { ProductMap } from "../../paths/instance/orders/create/CreatePage.js"; @@ -60,42 +60,36 @@ export function InventoryProductForm({ }); return; } - if (productWithInfiniteStock) { - onAddProduct(state.product, 1); - } else { - if (!state.quantity || state.quantity <= 0) { - setErrors({ quantity: i18n.str`Quantity must be greater than zero.` }); - return; - } - const currentStock = - state.product.total_stock - - state.product.total_lost - - state.product.total_sold; - const p = currentProducts[state.product.id]; - if (p) { - if (state.quantity + p.quantity > currentStock) { - const left = currentStock - p.quantity; - setErrors({ - quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`, - }); - return; - } - onAddProduct(state.product, state.quantity + p.quantity); - } else { - if (state.quantity > currentStock) { - const left = currentStock; - setErrors({ - quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`, - }); - return; - } - onAddProduct(state.product, state.quantity); - } + // if (productWithInfiniteStock) { + // onAddProduct(state.product, 1); + // } else { + if (!state.quantity || state.quantity <= 0) { + setErrors({ quantity: i18n.str`Quantity must be greater than zero.` }); + return; } + const currentStock = + state.product.total_stock - + state.product.total_lost - + state.product.total_sold; + const p = currentProducts[state.product.id]; + const totalQuantity = p ? state.quantity + p.quantity : state.quantity; + + if (!productWithInfiniteStock && totalQuantity > currentStock) { + const left = currentStock - p.quantity; + setErrors({ + quantity: i18n.str`This quantity exceeds remaining stock. Currently, only ${left} units remain unreserved in stock.`, + }); + return; + } + onAddProduct(state.product, totalQuantity); setState(initialState); }; + if (!inventory.length) { + return <Fragment />; + } + return ( <FormProvider<Form> errors={errors} object={state} valueHandler={setState}> <InputSearchOnList @@ -108,18 +102,16 @@ export function InventoryProductForm({ {state.product && ( <div class="columns mt-5"> <div class="column is-two-thirds"> - {!productWithInfiniteStock && ( - <InputNumber<Form> - name="quantity" - label={i18n.str`Quantity`} - tooltip={i18n.str`How many products will be added`} - /> - )} + <InputNumber<Form> + name="quantity" + label={i18n.str`Quantity`} + tooltip={i18n.str`How many products will be added`} + /> </div> <div class="column"> <div class="buttons is-right"> <button class="button is-success" onClick={submit}> - <i18n.Translate>Add from inventory</i18n.Translate> + <i18n.Translate>Add it to the order</i18n.Translate> </button> </div> </div> diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx @@ -16,8 +16,11 @@ import { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import emptyImage from "../../assets/empty.png"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; - +import { + RenderAmountBulma, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { useSessionContext } from "../../context/session.js"; const TALER_SCREEN_ID = 24; @@ -31,6 +34,7 @@ interface Props { } export function ProductList({ list, actions = [] }: Props): VNode { const { i18n } = useTranslationContext(); + const { config } = useSessionContext(); return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> @@ -56,15 +60,13 @@ export function ProductList({ list, actions = [] }: Props): VNode { </thead> <tbody> {list.map((entry, index) => { - const unitPrice = !entry.price ? "0" : entry.price; - const totalPrice = !entry.price - ? "0" - : Amounts.stringify( - Amounts.mult( - Amounts.parseOrThrow(entry.price), - entry.quantity ?? 0, - ).amount, - ); + const unitPrice = !entry.price + ? Amounts.zeroOfAmount(config.currency) + : Amounts.parseOrThrow(entry.price); + const totalPrice = Amounts.mult( + unitPrice, + entry.quantity ?? 0, + ).amount; return ( <tr key={index}> @@ -80,8 +82,18 @@ export function ProductList({ list, actions = [] }: Props): VNode { ? "--" : `${entry.quantity} ${entry.unit}`} </td> - <td>{unitPrice}</td> - <td>{totalPrice}</td> + <td> + <RenderAmountBulma + value={unitPrice} + specMap={config.currencies} + /> + </td> + <td> + <RenderAmountBulma + value={totalPrice} + specMap={config.currencies} + /> + </td> <td class="is-actions-cell right-sticky"> {actions.map((a, i) => { return ( diff --git a/packages/merchant-backoffice-ui/src/i18n/strings.ts b/packages/merchant-backoffice-ui/src/i18n/strings.ts @@ -23229,7 +23229,7 @@ strings['en'] = { "" ], "If set to %1$s, it cannot be used before this date.": [ - " cacacaca " + "" ], "Expiration Date": [ "" 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 @@ -35,6 +35,7 @@ import { import { ButtonBetterBulma, LocalNotificationBannerBulma, + RenderAmountBulma, useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -332,13 +333,13 @@ export function CreatePage({ case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized`; case TalerErrorCode.MERCHANT_PRIVATE_POST_ORDERS_INSTANCE_CONFIGURATION_LACKS_WIRE: - return i18n.str`No active bank accounts configured. At least one bank account must be available to create new orders`; + return i18n.str`No active bank accounts configured. At least one bank account must be available to create new orders.`; case HttpStatusCode.Conflict: return i18n.str`Conflict`; case HttpStatusCode.Gone: return i18n.str`Product with ID "${fail.body.product_id}" is out of stock.`; case HttpStatusCode.UnavailableForLegalReasons: - return i18n.str`No exchange would accept a payment because of KYC requirements.`; + return i18n.str`No exchange would accept a payment because of KYC requirements. Verify the `; default: assertUnreachable(fail); } @@ -454,7 +455,11 @@ export function CreatePage({ <p> <i18n.Translate> {allProducts.length} products with a total price of{" "} - {totalAsString}. + <RenderAmountBulma + value={totalPrice.amount} + specMap={config.currencies} + /> + . </i18n.Translate> </p> ) 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 @@ -29,7 +29,10 @@ import { assertUnreachable, stringifyRefundUri, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + RenderAmountBulma, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { format, formatDistance } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -112,7 +115,7 @@ function ContractTerms_V0({ value }: { value: CT0 }) { tooltip={i18n.str`URL for this purchase`} /> )} - <Input<CT0> + <InputCurrency<CT0> readonly name="max_fee" label={i18n.str`Max fee`} @@ -142,6 +145,12 @@ function ContractTerms_V0({ value }: { value: CT0 }) { label={i18n.str`Wire transfer deadline`} tooltip={i18n.str`Transfer deadline for the exchange`} /> + <InputDuration<CT0> + 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`} + /> <InputDate<CT0> readonly name="delivery_date" @@ -155,12 +164,6 @@ function ContractTerms_V0({ value }: { value: CT0 }) { > <InputLocation name="delivery_location" /> </InputGroup> - <InputDuration<CT0> - 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<CT0> readonly name="extra" @@ -352,11 +355,11 @@ function ClaimedPage({ {order.contract_terms.timestamp.t_s === "never" ? i18n.str`Never` : format( - new Date( - order.contract_terms.timestamp.t_s * 1000, - ), - datetimeFormatForPreferences(preferences), - )} + new Date( + order.contract_terms.timestamp.t_s * 1000, + ), + datetimeFormatForPreferences(preferences), + )} </p> </div> </div> @@ -441,8 +444,17 @@ function PaidPage({ order: TalerMerchantApi.CheckPaymentPaidResponse; onRefund: (id: string) => void; }) { - const [value, valueHandler] = useState<Partial<Paid>>(order); - const { state } = useSessionContext(); + const { state, config } = useSessionContext(); + const totalRefundAlreadyTaken = order.refund_details.reduce((prev, cur) => { + if (cur.pending) return prev; + return Amounts.add(prev, Amounts.parseOrThrow(cur.amount)).amount; + }, Amounts.zeroOfCurrency(config.currency)); + // value.refund_taken = Amounts.stringify(totalRefundAlreadyTaken); + + const [value, valueHandler] = useState<Partial<Paid>>({ + ...order, + refund_taken: Amounts.stringify(totalRefundAlreadyTaken), + }); const { i18n } = useTranslationContext(); const now = new Date(); @@ -511,35 +523,79 @@ function PaidPage({ } const { amount, wireFee } = orderAmounts; - - let totalRefunded = Amounts.zeroOfAmount(amount) + let totalRefunded = Amounts.zeroOfAmount(amount); sortedOrders.reduce(mergeRefunds, []).forEach((e) => { if (e.timestamp.t_s === "never") return; - totalRefunded = Amounts.add(totalRefunded, e.amount).amount + totalRefunded = Amounts.add(totalRefunded, e.amount).amount; if (e.pending) { if (wireDeadlineInThePast) { events.push({ when: new Date(e.timestamp.t_s * 1000), - description: !e.reason - ? i18n.str`refund missed: ${e.amount}` - : i18n.str`refund missed: ${e.amount}: ${e.reason}`, + description: !e.reason ? ( + <i18n.Translate> + refund missed:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + </i18n.Translate> + ) : ( + <i18n.Translate> + refund missed:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + : {e.reason} + </i18n.Translate> + ), type: "refund-missed", }); } else { events.push({ when: new Date(e.timestamp.t_s * 1000), - description: !e.reason - ? i18n.str`refund created: ${e.amount}` - : i18n.str`refund created: ${e.amount}: ${e.reason}`, + description: !e.reason ? ( + <i18n.Translate> + refund created:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + </i18n.Translate> + ) : ( + <i18n.Translate> + refund created:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + : {e.reason} + </i18n.Translate> + ), type: "refund-created", }); } } else { events.push({ when: new Date(e.timestamp.t_s * 1000), - description: !e.reason - ? i18n.str`refund taken: ${e.amount}` - : i18n.str`refund taken: ${e.amount}: ${e.reason}`, + description: !e.reason ? ( + <i18n.Translate> + refund taken:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + </i18n.Translate> + ) : ( + <i18n.Translate> + refund taken:{" "} + <RenderAmountBulma + value={Amounts.parseOrThrow(e.amount)} + specMap={config.currencies} + /> + : {e.reason} + </i18n.Translate> + ), type: "refund-taken", }); } @@ -555,10 +611,10 @@ function PaidPage({ > = {}; let hasUnconfirmedWireTransfer = false; - let totalWired = Amounts.zeroOfAmount(amount) + let totalWired = Amounts.zeroOfAmount(amount); if (order.wire_details.length) { order.wire_details.forEach((w) => { - totalWired = Amounts.add(totalWired, w.amount).amount + totalWired = Amounts.add(totalWired, w.amount).amount; if (!wireMap[w.wtid]) { const info = { time: @@ -583,15 +639,25 @@ function PaidPage({ Object.values(wireMap).forEach((info) => { events.push({ when: new Date(info.time), - description: i18n.str`wired ${Amounts.stringify(info.amount)}`, + description: ( + <i18n.Translate> + wired{" "} + <RenderAmountBulma + value={info.amount} + specMap={config.currencies} + /> + </i18n.Translate> + ), type: info.confirmed ? "wired-confirmed" : "wired-pending", }); }); } - const totalFee = Amounts.add(totalRefunded, wireFee).amount - const shouldBeWired = Amounts.sub(amount, totalFee).amount - const notAllHasBeenWired = Amounts.cmp(totalWired, shouldBeWired) < 0 // FIXME: should be updated with the protocol see #10971 - const w_deadline = AbsoluteTime.fromProtocolTimestamp(order.contract_terms.wire_transfer_deadline) + const totalFee = Amounts.add(totalRefunded, wireFee).amount; + const shouldBeWired = Amounts.sub(amount, totalFee).amount; + const notAllHasBeenWired = Amounts.cmp(totalWired, shouldBeWired) < 0; // FIXME: should be updated with the protocol see #10971 + const w_deadline = AbsoluteTime.fromProtocolTimestamp( + order.contract_terms.wire_transfer_deadline, + ); if (w_deadline.t_ms !== "never") { if (!AbsoluteTime.isExpired(w_deadline)) { events.push({ @@ -622,15 +688,9 @@ function PaidPage({ !order.refunded || wireDeadlineInThePast ? undefined : stringifyRefundUri({ - merchantBaseUrl: state.backendUrl.href as HostPortPath, - orderId: order.contract_terms.order_id, - }); - - 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); + merchantBaseUrl: state.backendUrl.href as HostPortPath, + orderId: order.contract_terms.order_id, + }); return ( <div> @@ -648,12 +708,15 @@ function PaidPage({ <i18n.Translate>Paid</i18n.Translate> </div> {order.wired ? ( - hasUnconfirmedWireTransfer ? - <div class="tag is-warning ml-4"> - <i18n.Translate>Unconfirmed</i18n.Translate> - </div> : <div class="tag is-info ml-4"> - <i18n.Translate>Confirmed</i18n.Translate> - </div> + hasUnconfirmedWireTransfer ? ( + <div class="tag is-warning ml-4"> + <i18n.Translate>Unconfirmed</i18n.Translate> + </div> + ) : ( + <div class="tag is-info ml-4"> + <i18n.Translate>Confirmed</i18n.Translate> + </div> + ) ) : null} {order.refunded ? ( <div class="tag is-danger ml-4"> @@ -666,7 +729,12 @@ function PaidPage({ <div class="level"> <div class="level-left"> <div class="level-item"> - <h1 class="title">{Amounts.stringify(amount)}</h1> + <h1 class="title"> + <RenderAmountBulma + value={amount} + specMap={config.currencies} + /> + </h1> </div> </div> <div class="level-right"> @@ -886,9 +954,9 @@ function UnpaidPage({ {order.creation_time.t_s === "never" ? i18n.str`Never` : format( - new Date(order.creation_time.t_s * 1000), - datetimeFormatForPreferences(preferences), - )} + new Date(order.creation_time.t_s * 1000), + datetimeFormatForPreferences(preferences), + )} </p> </div> </div> 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 @@ -16,7 +16,7 @@ import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; -import { h } from "preact"; +import { h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { datetimeFormatForPreferences, @@ -184,7 +184,7 @@ export function Timeline({ events: e }: Props) { } export interface Event { when: Date; - description: TranslatedString; + description: TranslatedString | VNode; type: | "start" | "refund-created" 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 @@ -19,19 +19,12 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AbsoluteTime, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { 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 { - dateFormatForPreferences, - usePreference, -} from "../../../../hooks/preference.js"; -import { CardTable } from "./Table.js"; import { WithId } from "../../../../declaration.js"; import { OrderListSection } from "./index.js"; +import { CardTable } from "./Table.js"; const TALER_SCREEN_ID = 47; 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 @@ -21,6 +21,7 @@ import { AccessToken, + AmountJson, Amounts, AmountString, assertUnreachable, @@ -345,13 +346,10 @@ export function RefundModal({ const [notification, safeFunctionHandler] = useLocalNotificationBetter(); const { state: session, lib } = useSessionContext(); - let amount: AmountString | undefined; + let amount: AmountJson | undefined; if (order.order_status === "paid") { const orderam = getOrderAmountAndWirefee(order); - amount = - typeof orderam === "string" - ? undefined - : Amounts.stringify(orderam.amount); + amount = typeof orderam === "string" ? undefined : orderam.amount; } const refunds = ( @@ -365,8 +363,7 @@ export function RefundModal({ (p, c) => Amounts.add(p, Amounts.parseOrThrow(c)).amount, Amounts.zeroOfCurrency(config.currency), ); - const orderPrice = - order.order_status === "paid" ? Amounts.parseOrThrow(amount!) : undefined; + const orderPrice = amount; const totalRefundable = !orderPrice ? Amounts.zeroOfCurrency(totalRefunded.currency) : refunds.length @@ -426,7 +423,7 @@ export function RefundModal({ case HttpStatusCode.Gone: return i18n.str`Gone`; case HttpStatusCode.UnavailableForLegalReasons: - return i18n.str`There are pending KYC requirements.`; + return i18n.str`There are pending KYC requirements. Please check the KYC status`; default: assertUnreachable(fail); } @@ -446,8 +443,16 @@ export function RefundModal({ <div class="columns"> <div class="column is-12"> <InputGroup - name="asd" - label={`${Amounts.stringify(totalRefunded)} was already refunded`} + name="not_really_used" //FIXME: maybe make "name" optional + label={ + <i18n.Translate> + <RenderAmountBulma + value={totalRefunded} + specMap={config.currencies} + />{" "} + was already refunded + </i18n.Translate> + } > <table class="table is-fullwidth"> <thead> @@ -499,7 +504,10 @@ export function RefundModal({ tooltip={i18n.str`Amount to be refunded`} > <i18n.Translate>Max refundable:</i18n.Translate>{" "} - {Amounts.stringify(totalRefundable)} + <RenderAmountBulma + value={totalRefundable} + specMap={config.currencies} + /> </InputCurrency> <InputSelector name="mainReason" diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx @@ -20,6 +20,7 @@ */ import { + AmountJson, AmountString, Amounts, HttpStatusCode, @@ -27,6 +28,7 @@ import { assertUnreachable, } from "@gnu-taler/taler-util"; import { + RenderAmountBulma, useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -148,7 +150,7 @@ function Table({ }: TableProps): VNode { const { i18n } = useTranslationContext(); const [preference] = usePreference(); - const { state: session, lib } = useSessionContext(); + const { state: session, lib, config } = useSessionContext(); const [notification, safeFunctionHandler] = useLocalNotificationBetter(); const update = safeFunctionHandler( i18n.str`update product`, @@ -277,7 +279,17 @@ function Table({ } style={{ cursor: "pointer" }} > - {isFree ? i18n.str`Free` : `${i.price} / ${i.unit}`} + {isFree ? ( + i18n.str`Free` + ) : ( + <Fragment> + <RenderAmountBulma + value={Amounts.parseOrThrow(i.price)} + specMap={config.currencies} + />{" "} + / {i.unit} + </Fragment> + )} </td> <FragmentPersonaFlag point={UIElement.option_inventoryTaxes}> <td @@ -286,7 +298,13 @@ function Table({ } style={{ cursor: "pointer" }} > - {sum(i.taxes)} + <RenderAmountBulma + value={sum( + i.taxes, + Amounts.zeroOfCurrency(config.currency), + )} + specMap={config.currencies} + /> </td> <td onClick={() => @@ -294,7 +312,13 @@ function Table({ } style={{ cursor: "pointer" }} > - {difference(i.price, sum(i.taxes))} + <RenderAmountBulma + value={difference( + Amounts.parseOrThrow(i.price), + sum(i.taxes, Amounts.zeroOfCurrency(config.currency)), + )} + specMap={config.currencies} + /> </td> </FragmentPersonaFlag> <td @@ -557,14 +581,16 @@ function EmptyTable(): VNode { ); } -function difference(price: string, tax: number) { +function difference(price: AmountJson, tax: AmountJson): AmountJson { if (!tax) return price; - const ps = price.split(":"); - const p = parseInt(ps[1], 10); - ps[1] = `${p - tax}`; - return ps.join(":"); + return Amounts.sub(price, tax).amount; } -function sum(taxes: TalerMerchantApi.Tax[] | undefined) { - if (taxes === undefined) return 0; - return taxes.reduce((p, c) => p + parseInt(c.tax.split(":")[1], 10), 0); +function sum( + taxes: TalerMerchantApi.Tax[] | undefined, + zero: AmountJson, +): AmountJson { + if (taxes === undefined) return zero; + return taxes.reduce((p, c) => { + return Amounts.add(p, c.tax).amount; + }, zero); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/create/CreatePage.tsx @@ -25,10 +25,13 @@ import { Duration, HttpStatusCode, TalerMerchantApi, + TalerProtocolDuration, + TalerProtocolTimestamp, } from "@gnu-taler/taler-util"; import { ButtonBetterBulma, LocalNotificationBannerBulma, + undefinedIfEmpty, useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -48,6 +51,7 @@ import { dateFormatForPreferences, usePreference, } from "../../../../hooks/preference.js"; +import { Input } from "../../../../components/form/Input.js"; const TALER_SCREEN_ID = 67; @@ -76,26 +80,41 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { description: undefined, description_i18n: {}, kind: TalerMerchantApi.TokenFamilyKind.Discount, - duration: Duration.toTalerProtocolDuration(Duration.getForever()), - valid_after: AbsoluteTime.toProtocolTimestamp(AbsoluteTime.never()), - valid_before: AbsoluteTime.toProtocolTimestamp(AbsoluteTime.never()), - validity_granularity: Duration.toTalerProtocolDuration( - Duration.getForever(), - ), + duration: TalerProtocolDuration.forever(), + valid_after: undefined, + valid_before: TalerProtocolTimestamp.never(), + validity_granularity: undefined, }); - const errors: FormErrors<Entity> = { + const errors = undefinedIfEmpty<FormErrors<Entity>>({ slug: !value.slug ? i18n.str`Required` : undefined, name: !value.name ? i18n.str`Required` : undefined, description: !value.description ? i18n.str`Required` : undefined, - valid_after: !value.valid_after ? undefined : undefined, - valid_before: !value.valid_before ? i18n.str`Required` : undefined, - validity_granularity: !value.validity_granularity + valid_after: + !value.valid_after || value.valid_after.t_s === "never" + ? i18n.str`Required` + : undefined, + valid_before: !value.valid_before ? i18n.str`Required` - : undefined, + : value.valid_after && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp(value.valid_after), + AbsoluteTime.fromProtocolTimestamp(value.valid_before), + ) > 0 + ? i18n.str`Expiration should be after start date` + : undefined, duration: !value.duration ? i18n.str`Required` : undefined, + validity_granularity: !value.validity_granularity + ? i18n.str`Required` + : value.duration && + Duration.cmp( + Duration.fromTalerProtocolDuration(value.duration), + Duration.fromTalerProtocolDuration(value.validity_granularity), + ) < 1 + ? i18n.str`Granularity can't be greater than duration.` + : undefined, kind: !value.kind ? i18n.str`Required` : undefined, - }; + }); const create = safeFunctionHandler( i18n.str`create token family`, @@ -109,6 +128,8 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { return i18n.str`Unauthorized`; case HttpStatusCode.NotFound: return i18n.str`Not found`; + case HttpStatusCode.Conflict: + return i18n.str`There is another token family with this ID. Choose another one.`; default: assertUnreachable(fail); } @@ -136,6 +157,18 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { label={i18n.str`Slug`} tooltip={i18n.str`Token family slug to use in URLs (for internal use only)`} /> + <Input<Entity> + name="name" + inputType="text" + label={i18n.str`Name`} + tooltip={i18n.str`User-readable token family name`} + /> + <Input<Entity> + name="description" + inputType="multiline" + label={i18n.str`Description`} + tooltip={i18n.str`Token family description for customers`} + /> <InputSelector<Entity> name="kind" label={i18n.str`Kind`} @@ -150,7 +183,9 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { firstDayNextMonth, dateFormatForPreferences(preferences), )}, it cannot be used before this date.`} - withTimestampSupport + withNever + withoutClear + useProtocolTimestamp /> <InputDate<Entity> @@ -164,7 +199,9 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { afterLastDayNextMonth, dateFormatForPreferences(preferences), )}.`} - withTimestampSupport + withNever + withoutClear + useProtocolTimestamp /> <InputDuration<Entity> name="duration" diff --git a/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/update/UpdatePage.tsx @@ -20,7 +20,9 @@ */ import { + AbsoluteTime, assertUnreachable, + Duration, HttpStatusCode, TalerMerchantApi, } from "@gnu-taler/taler-util"; @@ -67,12 +69,34 @@ const firstDayNextMonthAnd30Days = addDays(firstDayNextMonth, 30); export function UpdatePage({ onUpdated, onBack, tokenFamily }: Props) { const [value, valueHandler] = useState<Partial<Entity>>(tokenFamily); const { i18n } = useTranslationContext(); + const errors = undefinedIfEmpty<FormErrors<Entity>>({ name: !value.name ? i18n.str`Required` : undefined, description: !value.description ? i18n.str`Required` : undefined, - valid_after: !value.valid_after ? i18n.str`Required` : undefined, - valid_before: !value.valid_before ? i18n.str`Required` : undefined, + valid_after: + !value.valid_after || value.valid_after.t_s === "never" + ? i18n.str`Required` + : undefined, + valid_before: !value.valid_before + ? i18n.str`Required` + : value.valid_after && + AbsoluteTime.cmp( + AbsoluteTime.fromProtocolTimestamp(value.valid_after), + AbsoluteTime.fromProtocolTimestamp(value.valid_before), + ) > 0 + ? i18n.str`Expiration should be after start date` + : undefined, duration: !value.duration ? i18n.str`Required` : undefined, + // validity_granularity: !value.validity_granularity + // ? i18n.str`Required` + // : value.duration && + // Duration.cmp( + // Duration.fromTalerProtocolDuration(value.duration), + // Duration.fromTalerProtocolDuration(value.validity_granularity), + // ) < 1 + // ? i18n.str`Granularity can't be greater than duration.` + // : undefined, + // kind: !value.kind ? i18n.str`Required` : undefined, }); const { state: session, lib } = useSessionContext(); const [notification, safeFunctionHandler] = useLocalNotificationBetter(); @@ -150,7 +174,8 @@ export function UpdatePage({ onUpdated, onBack, tokenFamily }: Props) { firstDayNextMonth, datetimeFormatForPreferences(preferences), )}, it cannot be used before this date.`} - withTimestampSupport + withNever + useProtocolTimestamp /> <InputDate<Entity> name="valid_before" @@ -163,7 +188,8 @@ export function UpdatePage({ onUpdated, onBack, tokenFamily }: Props) { afterLastDayNextMonth, datetimeFormatForPreferences(preferences), )}.`} - withTimestampSupport + withNever + useProtocolTimestamp /> <InputDuration<Entity> name="duration" diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx @@ -19,9 +19,10 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { Amounts, TalerMerchantApi } from "@gnu-taler/taler-util"; import { ButtonBetterBulma, + RenderAmountBulma, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; @@ -31,6 +32,7 @@ import { datetimeFormatForPreferences, usePreference, } from "../../../../hooks/preference.js"; +import { useSessionContext } from "../../../../context/session.js"; const TALER_SCREEN_ID = 72; @@ -49,6 +51,7 @@ export function CardTableIncoming({ }: TablePropsIncoming): VNode { const { i18n } = useTranslationContext(); const [preferences] = usePreference(); + const { config } = useSessionContext(); return ( <div class="card has-table"> <header class="card-header"> @@ -92,9 +95,7 @@ export function CardTableIncoming({ <tbody> {transfers.map((i, idx) => { return ( - <tr - key={i.id} - > + <tr key={i.id}> <td> {i.execution_time ? i.execution_time.t_s == "never" @@ -106,7 +107,14 @@ export function CardTableIncoming({ : i18n.str`unknown`} </td> <td> - {i.expected_credit_amount ?? ( + {i.expected_credit_amount ? ( + <RenderAmountBulma + value={Amounts.parseOrThrow( + i.expected_credit_amount, + )} + specMap={config.currencies} + /> + ) : ( <span> <i18n.Translate> To be determined. @@ -167,6 +175,7 @@ export function CardTableVerified({ onLoadMoreBefore, }: TablePropsVerified): VNode { const { i18n } = useTranslationContext(); + const { config } = useSessionContext(); const [preferences] = usePreference(); return ( <div class="card has-table"> @@ -226,7 +235,12 @@ export function CardTableVerified({ ) : i18n.str`unknown`} </td> - <td>{i.credit_amount}</td> + <td> + <RenderAmountBulma + value={Amounts.parseOrThrow(i.credit_amount)} + specMap={config.currencies} + /> + </td> <td title={i.wtid} style={{ wordBreak: "break-word" }} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx @@ -20,6 +20,7 @@ */ import { + Amounts, ExpectedTransferDetails, HttpStatusCode, TalerError, @@ -29,15 +30,18 @@ import { import { LocalNotificationBannerBulma, PaginatedResult, + RenderAmountBulma, useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { Amount } from "../../../../components/Amount.js"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../../components/exception/loading.js"; +import { FormProvider } from "../../../../components/form/FormProvider.js"; +import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; import { ConfirmModal, Row } from "../../../../components/modal/index.js"; import { useSessionContext } from "../../../../context/session.js"; import { useInstanceBankAccounts } from "../../../../hooks/bank.js"; @@ -52,9 +56,6 @@ import { import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { ListPage } from "./ListPage.js"; -import { InputToggle } from "../../../../components/form/InputToggle.js"; -import { FormProvider } from "../../../../components/form/FormProvider.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; interface Props { // onCreate: () => void; @@ -69,7 +70,7 @@ interface Form { export default function ListTransfer({}: Props): VNode { const setFilter = (s?: boolean) => setForm({ ...form, verified: s }); - const { state: session, lib } = useSessionContext(); + const { state: session, lib, config } = useSessionContext(); const [position, setPosition] = useState<string | undefined>(undefined); const [preferences] = usePreference(); @@ -239,7 +240,12 @@ export default function ListTransfer({}: Props): VNode { <tbody> <Row name={i18n.str`Amount`} - value={<Amount value={selected.expected_credit_amount!} />} + value={ + <RenderAmountBulma + value={Amounts.parseOrThrow(selected.expected_credit_amount!)} + specMap={config.currencies} + /> + } /> {selected.execution_time && selected.execution_time.t_s !== "never" ? ( diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -2555,6 +2555,8 @@ export class TalerMerchantInstanceHttpClient { return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Conflict: + return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); }