commit f246313524e5f6dbb31bed65d54f28cea1582f34 parent 40b4850b6392c5bd0edd39cbeccb6df7979255d6 Author: Sebastian <sebasjm@taler-systems.com> Date: Thu, 23 Apr 2026 14:54:28 -0300 fix #10542 Diffstat:
20 files changed, 339 insertions(+), 257 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx @@ -21,8 +21,12 @@ import { useMerchantApiContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, h, VNode } from "preact"; import { InputWithAddon } from "./InputWithAddon.js"; -import { InputProps } from "./useField.js"; -import { AmountString } from "@gnu-taler/taler-util"; +import { InputProps, useField } from "./useField.js"; +import { + Amounts, + AmountString, + CurrencySpecification, +} from "@gnu-taler/taler-util"; import { useSessionContext } from "../../context/session.js"; export interface Props<T> extends InputProps<T> { @@ -44,14 +48,27 @@ export function InputCurrency<T>({ children, side, }: Props<keyof T>): VNode { - const { config } = useSessionContext(); - const id = config.currencies[config.currency].num_fractional_input_digits; - const step = Math.pow(10, -1 * (id ?? 0)); + const { config, state } = useSessionContext(); + const { value } = useField<T>(name); + const parsedValue = !value ? undefined : Amounts.parse(value); + + // if the field already has a value, use the + // currency of that value + // fallback to the selected currency on the session + const cSpec = parsedValue?.currency + ? config.currencies[parsedValue.currency] + : state.currency; + + const cId = parsedValue?.currency ?? state.currency?.id; + + const id = cSpec?.num_fractional_input_digits; + const step = !id ? 0.1 : Math.pow(10, -1 * (id ?? 0)); + return ( <InputWithAddon<T> name={name} - readonly={readonly} - addonBefore={config.currency} + readonly={readonly || cId === undefined} + addonBefore={cId ?? ""} side={side} label={label} placeholder={placeholder} @@ -61,7 +78,9 @@ export function InputCurrency<T>({ inputType="decimal" expand={expand} toStr={(v?: AmountString) => v?.split(":")[1] || ""} - fromStr={(v: string) => (!v ? undefined : `${config.currency}:${v}`)} + fromStr={(v: string) => + !v ? undefined : `${cId ?? ""}:${v}` + } inputExtra={{ min: 0, step }} > {children} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -27,9 +27,12 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useSessionContext } from "../../context/session.js"; import { useSettingsContext } from "../../context/settings.js"; +import { + useAvailableCurrencies, + useInstanceKYCSimplifiedWorstStatusLongPolling +} from "../../hooks/instance.js"; import { UIElement, usePreference } from "../../hooks/preference.js"; import { LangSelector } from "./LangSelector.js"; -import { useInstanceKYCSimplifiedWorstStatusLongPolling } from "../../hooks/instance.js"; const TALER_SCREEN_ID = 17; // const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; @@ -42,7 +45,7 @@ interface Props { export function Sidebar({ mobile }: Props): VNode { const { i18n } = useTranslationContext(); - const { state, logOut, config } = useSessionContext(); + const { state, logOut, config, switchCurrency } = useSessionContext(); const worstKycStatus = useInstanceKYCSimplifiedWorstStatusLongPolling(); const [{ persona }] = usePreference(); @@ -55,6 +58,9 @@ export function Sidebar({ mobile }: Props): VNode { const { isTestingEnvironment } = useSettingsContext(); + const cList = useAvailableCurrencies() + const hasMultiCurrency = cList.length > 1; + return ( <aside class="aside is-placed-left is-expanded" @@ -90,6 +96,25 @@ export function Sidebar({ mobile }: Props): VNode { {isLoggedIn ? ( <Fragment> <ul class="menu-list"> + {!hasMultiCurrency ? undefined : ( + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_multicurrency} + > + <div class="has-icon"> + <i class="icon mdi mdi-currency-eur" /> + <select + onChange={(ev) => { + switchCurrency(ev.currentTarget.value); + }} + > + {cList.map((c) => ( + <option selected={c === state.currency?.id}>{c}</option> + ))} + </select> + </div> + </HtmlPersonaFlag> + )} <HtmlPersonaFlag htmlElement="li" point={UIElement.sidebar_orders} @@ -444,6 +469,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.option_inventoryTaxes]: true, [UIElement.sidebar_statistics]: false, + [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_discounts]: false, [UIElement.sidebar_subscriptions]: false, [UIElement.sidebar_tokenFamilies]: false, @@ -461,6 +487,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_settings]: true, [UIElement.sidebar_password]: true, + [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_accessTokens]: false, [UIElement.sidebar_categories]: false, [UIElement.sidebar_group]: false, @@ -503,6 +530,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_settings]: true, [UIElement.sidebar_password]: true, + [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_discounts]: false, [UIElement.sidebar_subscriptions]: false, [UIElement.sidebar_tokenFamilies]: false, @@ -533,6 +561,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_password]: true, [UIElement.sidebar_statistics]: false, + [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_templates]: false, [UIElement.sidebar_categories]: false, [UIElement.sidebar_group]: false, @@ -569,6 +598,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_password]: true, [UIElement.sidebar_statistics]: false, + [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_templates]: false, [UIElement.sidebar_categories]: false, [UIElement.sidebar_group]: false, diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -408,11 +408,13 @@ interface ValidateBankAccountModalProps { onCancel: () => void; origin: Paytos.URI; targets: Paytos.URI[]; + currency: string | undefined; } export function ValidBankAccount({ onCancel, origin, targets, + currency: exchangeCurrency, }: ValidateBankAccountModalProps): VNode { const { i18n } = useTranslationContext(); const payto = targets[0]; @@ -450,10 +452,14 @@ export function ValidBankAccount({ ? `${origin.address.substring(0, 8)}...` : origin.displayName; - const { config } = useSessionContext(); // FIXME: define a minimum by currency somewhere https://bugs.gnunet.org/view.php?id=11169 - const sendAmount = Amounts.parseOrThrow(`${config.currency}:0.01`); - payto.params["amount"] = Amounts.stringify(sendAmount); + const sendAmount = !exchangeCurrency + ? undefined + : Amounts.parseOrThrow(`${exchangeCurrency}:0.01`); + + if (sendAmount) { + payto.params["amount"] = Amounts.stringify(sendAmount); + } const qrs = getQrCodesForPayto(Paytos.toFullString(payto)); const strPayto = Paytos.toFullString(payto); const [{ showDebugInfo }] = useCommonPreferences(); diff --git a/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx b/packages/merchant-backoffice-ui/src/components/product/ProductList.tsx @@ -13,7 +13,11 @@ 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 { Amounts, TalerMerchantApi, TranslatedString } from "@gnu-taler/taler-util"; +import { + Amounts, + TalerMerchantApi, + TranslatedString, +} from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import emptyImage from "../../assets/empty.png"; import { @@ -62,12 +66,11 @@ export function ProductList({ list, actions = [] }: Props): VNode { <tbody> {list.map((entry, index) => { const unitPrice = !entry.price - ? Amounts.zeroOfAmount(config.currency) + ? undefined : Amounts.parseOrThrow(entry.price); - const totalPrice = Amounts.mult( - unitPrice, - entry.quantity ?? 0, - ).amount; + const totalPrice = !unitPrice + ? undefined + : Amounts.mult(unitPrice, entry.quantity ?? 0).amount; return ( <tr key={index}> @@ -84,16 +87,20 @@ export function ProductList({ list, actions = [] }: Props): VNode { : `${entry.quantity} ${entry.unit}`} </td> <td> - <RenderAmountBulma - value={unitPrice} - specMap={config.currencies} - /> + {!unitPrice ? undefined : ( + <RenderAmountBulma + value={unitPrice} + specMap={config.currencies} + /> + )} </td> <td> - <RenderAmountBulma - value={totalPrice} - specMap={config.currencies} - /> + {!totalPrice ? undefined : ( + <RenderAmountBulma + value={totalPrice} + specMap={config.currencies} + /> + )} </td> <td class="is-actions-cell right-sticky"> {actions.map((a, i) => { diff --git a/packages/merchant-backoffice-ui/src/context/session.ts b/packages/merchant-backoffice-ui/src/context/session.ts @@ -17,12 +17,14 @@ import { AccessToken, Codec, + CurrencySpecification, MerchantVersionResponse, TalerMerchantManagementHttpClient, buildCodecForObject, codecForString, codecForURL, codecOptional, + codecOptionalDefault, } from "@gnu-taler/taler-util"; import { buildStorageKey, @@ -59,6 +61,8 @@ interface LoggedIn { //instance access token token: AccessToken | undefined; + + currency: undefined | CurrencySpecification & { id: string }; } interface LoggedOut { @@ -67,18 +71,23 @@ interface LoggedOut { instance: string; isAdmin: boolean; token: AccessToken | undefined; + currency: undefined | CurrencySpecification & { id: string }; } interface SavedSession { backendUrl: URL; token: AccessToken | undefined; prevToken: AccessToken | undefined; + + //selected currency; + currency: string | undefined; } export const codecForSessionState = (): Codec<SavedSession> => buildCodecForObject<SavedSession>() .property("backendUrl", codecForURL()) .property("token", codecOptional(codecForString() as Codec<AccessToken>)) + .property("currency", codecOptional(codecForString())) .property( "prevToken", codecOptional(codecForString() as Codec<AccessToken>), @@ -90,7 +99,7 @@ function inferInstanceName(url: URL) { return !match || !match[1] ? DEFAULT_ADMIN_USERNAME : match[1]; } -function recalculateUrlForAnotherUser(original: URL, user:string) { +function recalculateUrlForAnotherUser(original: URL, user: string) { const match = INSTANCE_ID_LOOKUP.exec(original.href); return !match || !match[1] ? DEFAULT_ADMIN_USERNAME : match[1]; } @@ -100,6 +109,7 @@ export const defaultState = (url: URL): SavedSession => { backendUrl: url, token: undefined, prevToken: undefined, + currency: undefined, }; }; @@ -109,6 +119,11 @@ export interface SessionStateHandler { state: SessionState; /** + * change the UI to work with the selected + * supported currency + */ + switchCurrency(c: string): void; + /** * from every state to logout state */ logOut(): void; @@ -164,8 +179,7 @@ export const SessionContextProvider = ({ url: merchantUrl, } = useMerchantApiContext(); const [status, setStatus] = useState<"loggedIn" | "loggedOut">("loggedIn"); - const [currentConfig, setCurrentConfig] = - useState<MerchantVersionResponse>(); + const [currentConfig, setCurrentConfig] = useState<MerchantVersionResponse>(); const SESSION_STATE_KEY = buildStorageKey( `merchant-session-${merchantUrl.pathname}`, codecForSessionState(), @@ -208,6 +222,10 @@ export const SessionContextProvider = ({ state: { backendUrl: state.backendUrl, token: state.token, + currency: state.currency ? { + ...rootConfig.currencies[state.currency], + id: state.currency, + } : undefined, impersonated: false, // doingImpersonation, FIXME: removing impersonation feature for 1.2 instance: currentInstance, isAdmin: currentInstance === DEFAULT_ADMIN_USERNAME, @@ -215,12 +233,22 @@ export const SessionContextProvider = ({ }, lib, config, + switchCurrency(newCurrency: string) { + if (!rootConfig.currencies[newCurrency]) return; + update({ + backendUrl: merchantUrl, + token: state.token, + prevToken: state.prevToken, + currency: newCurrency, + }); + }, logOut() { setStatus("loggedOut"); update({ backendUrl: merchantUrl, token: undefined, prevToken: undefined, + currency: state.currency, }); cleanAllCache(); }, @@ -230,6 +258,7 @@ export const SessionContextProvider = ({ backendUrl: merchantUrl, token: state.prevToken, prevToken: undefined, + currency: state.currency, }); setStatus("loggedIn"); }, @@ -241,6 +270,7 @@ export const SessionContextProvider = ({ backendUrl: baseUrl, token: undefined, prevToken: state.token, + currency: state.currency, }); setStatus("loggedIn"); cleanAllCache(); @@ -250,19 +280,22 @@ export const SessionContextProvider = ({ setStatus("loggedIn"); let backendUrl: URL; if (currentInstance !== username) { - backendUrl = new URL(rootLib.subInstanceApi(username).instance.baseUrl) + backendUrl = new URL(rootLib.subInstanceApi(username).instance.baseUrl); } else { - backendUrl = state.backendUrl + backendUrl = state.backendUrl; } update({ backendUrl, token, prevToken: state.prevToken, + currency: state.currency, }); }, - getInstanceForUsername(username:string) { - return username !== currentInstance ? rootLib.subInstanceApi(username).instance : lib.instance - } + getInstanceForUsername(username: string) { + return username !== currentInstance + ? rootLib.subInstanceApi(username).instance + : lib.instance; + }, }; return h(Context.Provider, { diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -1,96 +0,0 @@ -/* - 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) - */ - -export interface WithId { - id: string; -} - -type Amount = string; -type UUID = string; -type Integer = number; - -interface WireAccount { - // payto:// URI identifying the account and wire method - payto_uri: string; - - // URI to convert amounts from or to the currency used by - // this wire account of the exchange. Missing if no - // conversion is applicable. - conversion_url?: string; - - // Restrictions that apply to bank accounts that would send - // funds to the exchange (crediting this exchange bank account). - // Optional, empty array for unrestricted. - credit_restrictions: AccountRestriction[]; - - // Restrictions that apply to bank accounts that would receive - // funds from the exchange (debiting this exchange bank account). - // Optional, empty array for unrestricted. - debit_restrictions: AccountRestriction[]; - - // Signature using the exchange's offline key over - // a TALER_MasterWireDetailsPS - // with purpose TALER_SIGNATURE_MASTER_WIRE_DETAILS. - master_sig: EddsaSignature; -} - -type AccountRestriction = RegexAccountRestriction | DenyAllAccountRestriction; - -// Account restriction that disables this type of -// account for the indicated operation categorically. -interface DenyAllAccountRestriction { - type: "deny"; -} - -// Accounts interacting with this type of account -// restriction must have a payto://-URI matching -// the given regex. -interface RegexAccountRestriction { - type: "regex"; - - // Regular expression that the payto://-URI of the - // partner account must follow. The regular expression - // should follow posix-egrep, but without support for character - // classes, GNU extensions, back-references or intervals. See - // https://www.gnu.org/software/findutils/manual/html_node/find_html/posix_002degrep-regular-expression-syntax.html - // for a description of the posix-egrep syntax. Applications - // may support regexes with additional features, but exchanges - // must not use such regexes. - payto_regex: string; - - // Hint for a human to understand the restriction - // (that is hopefully easier to comprehend than the regex itself). - human_hint: string; - - // Map from IETF BCP 47 language tags to localized - // human hints. - human_hint_i18n?: { [lang_tag: string]: string }; -} -interface LoginToken { - token: string, - expiration: Timestamp, -} -// token used to get loginToken -// must forget after used -declare const __ac_token: unique symbol; -type AccessToken = string & { - [__ac_token]: true; -}; diff --git a/packages/merchant-backoffice-ui/src/declaration.ts b/packages/merchant-backoffice-ui/src/declaration.ts @@ -0,0 +1,24 @@ +/* + 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) + */ + +export interface WithId { + id: string; +} diff --git a/packages/merchant-backoffice-ui/src/hooks/instance.ts b/packages/merchant-backoffice-ui/src/hooks/instance.ts @@ -17,6 +17,7 @@ import { AccessToken, getMerchantAccountKycStatusSimplified, + MerchantAccountKycStatus, MerchantAccountKycStatusSimplified, Paytos, TalerError, @@ -156,11 +157,32 @@ export function useInstanceKYCSimplifiedBestStatusLongPolling() { ? kycStatus.body.kyc_data : []; - return allKycData.reduce((prev, cur) => { - const st = getMerchantAccountKycStatusSimplified(cur.status); - if (st < prev) return st; - return prev; - }, MerchantAccountKycStatusSimplified.ERROR); + return allKycData.reduce( + (prev, cur) => { + const st = getMerchantAccountKycStatusSimplified(cur.status); + if (!prev) return st; + if (st < prev) return st; + return prev; + }, + undefined as MerchantAccountKycStatusSimplified | undefined, + ); +} + +export function useAvailableCurrencies(): string[] { + const kyc = useInstanceKYCDetails(); + + const status = + kyc instanceof TalerError || !kyc || kyc.type === "fail" + ? undefined + : kyc.body; + if (!status) return []; + + const cs = status.kyc_data.map((st) => + st.status === MerchantAccountKycStatus.READY + ? st.exchange_currency + : undefined, + ); + return cs.filter((c): c is string => !!c); } export function useInstanceKYCDetails() { diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts @@ -29,6 +29,7 @@ import { import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; export enum UIElement { + sidebar_multicurrency, sidebar_orders, sidebar_inventory, sidebar_categories, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx @@ -24,7 +24,7 @@ import { MerchantAccountKycStatusSimplified, Paytos, TalerError, - assertUnreachable + assertUnreachable, } from "@gnu-taler/taler-util"; import { NotificationCardBulma, @@ -36,7 +36,7 @@ import { Loading } from "../../../../components/exception/loading.js"; import { useInstanceBankAccounts } from "../../../../hooks/bank.js"; import { useInstanceKYCSimplifiedBestStatusLongPolling, - useInstanceKYCSimplifiedWorstStatusLongPolling + useInstanceKYCSimplifiedWorstStatusLongPolling, } from "../../../../hooks/instance.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; @@ -66,8 +66,8 @@ export function MissingBankAccountsWarning(): VNode { <NotificationCardBulma notification={{ type: "WARN", - message: i18n.str`You must provide a bank account to receive payments.`, - description: i18n.str`Without this information, you cannot create new payment orders that are transferred to a bank account.`, + message: i18n.str`No bank account configured yet.`, + description: i18n.str`Without this information you cannot create new payment orders.`, }} /> ); @@ -85,16 +85,21 @@ export function UnableToUseBankAccountWarning({ switch (status) { case MerchantAccountKycStatusSimplified.ACTION_REQUIRED: case MerchantAccountKycStatusSimplified.WARNING: - case MerchantAccountKycStatusSimplified.ERROR: - return <NotificationCardBulma - notification={{ - type: "WARN", - message: i18n.str`This account is not ready to be used.`, - description: <i18n.Translate> - There are pending actions related to KYC. <a href="#/kyc">More details.</a> - </i18n.Translate>, - }} - /> + case MerchantAccountKycStatusSimplified.ERROR: + return ( + <NotificationCardBulma + notification={{ + type: "WARN", + message: i18n.str`This account is not ready to be used.`, + description: ( + <i18n.Translate> + There are pending actions related to KYC.{" "} + <a href="#/kyc">More details.</a> + </i18n.Translate> + ), + }} + /> + ); case MerchantAccountKycStatusSimplified.OK: return <Fragment />; @@ -108,27 +113,19 @@ export function LimitedKycActionWarning(): VNode { const status = useInstanceKYCSimplifiedBestStatusLongPolling(); switch (status) { - case MerchantAccountKycStatusSimplified.ACTION_REQUIRED: - return ( - <NotificationCardBulma - notification={{ - type: "WARN", - message: i18n.str`You must complete kyc requirements to receive payments.`, - description: i18n.str`Without this information, you cannot create new payment orders that are transferred to a bank account.`, - }} - /> - ); case MerchantAccountKycStatusSimplified.WARNING: case MerchantAccountKycStatusSimplified.ERROR: + case MerchantAccountKycStatusSimplified.ACTION_REQUIRED: return ( <NotificationCardBulma notification={{ type: "WARN", - message: i18n.str`You must must pass KYC to receive payments.`, - description: i18n.str`Without this information, you cannot create new payment orders that are transferred to a bank account.`, + message: i18n.str`You must complete kyc requirements to receive payments.`, + description: i18n.str`Without this information you cannot create new payment orders.`, }} /> ); + case undefined: case MerchantAccountKycStatusSimplified.OK: return <Fragment />; default: diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx @@ -166,6 +166,7 @@ function ShowInstructionForKycRedirect({ origin={uri.value} targets={tgs} onCancel={onCancel} + currency={e.exchange_currency} /> ); case TalerMerchantApi.MerchantAccountKycStatus.NO_EXCHANGE_KEY: { 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 @@ -30,7 +30,6 @@ import { TalerMerchantApi, TalerProtocolDuration, assertUnreachable, - durationAdd, } from "@gnu-taler/taler-util"; import { ButtonBetterBulma, @@ -50,23 +49,22 @@ import { import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputDate } from "../../../../components/form/InputDate.js"; -import { InputDurationSelector } from "../../../../components/form/InputDurationSelector.js"; +import { InputDurationDropdown } from "../../../../components/form/InputDurationDropdown.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 { FragmentPersonaFlag } from "../../../../components/menu/SideBar.js"; import { InventoryProductForm } from "../../../../components/product/InventoryProductForm.js"; import { NonInventoryProductFrom } from "../../../../components/product/NonInventoryProductForm.js"; import { ProductList } from "../../../../components/product/ProductList.js"; +import { Tooltip } from "../../../../components/Tooltip.js"; import { useSessionContext } from "../../../../context/session.js"; import { WithId } from "../../../../declaration.js"; -import { UIElement, usePreference } from "../../../../hooks/preference.js"; +import { UIElement } from "../../../../hooks/preference.js"; import { rate } from "../../../../utils/amount.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; -import { FragmentPersonaFlag } from "../../../../components/menu/SideBar.js"; -import { Tooltip } from "../../../../components/Tooltip.js"; -import { MissingBankAccountsWarning } from "../../accounts/list/index.js"; -import { InputDurationDropdown } from "../../../../components/form/InputDurationDropdown.js"; +import { LimitedKycActionWarning, MissingBankAccountsWarning } from "../../accounts/list/index.js"; const TALER_SCREEN_ID = 42; @@ -83,10 +81,7 @@ interface InstanceConfig { default_wire_transfer_delay: TalerProtocolDuration; } -function with_defaults( - config: InstanceConfig, - _currency: string, -): Partial<Entity> { +function with_defaults(config: InstanceConfig): Partial<Entity> { const defaultPayDelay = Duration.fromTalerProtocolDuration( config.default_pay_delay, ); @@ -166,9 +161,9 @@ export function CreatePage({ instanceInventory, }: Props): VNode { const { config, lib, state: session } = useSessionContext(); - const instance_default = with_defaults(instanceConfig, config.currency); + const instance_default = with_defaults(instanceConfig); const [value, valueHandler] = useState(instance_default); - const zero = Amounts.zeroOfCurrency(config.currency); + const inventoryList = Object.values(value.inventoryProducts || {}); const productList = Object.values(value.products || {}); @@ -385,21 +380,32 @@ export function CreatePage({ TalerMerchantApi.ProductSold | undefined >(undefined); + const zero = !session.currency + ? undefined + : Amounts.zeroOfCurrency(session.currency.name); + const totalPriceInventory = inventoryList.reduce((prev, cur) => { const p = Amounts.parseOrThrow(cur.product.price); + if (!prev) return p; 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); + if (!prev) return p; 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 totalPrice = + !totalPriceInventory || !totalPriceProducts + ? undefined + : Amounts.add(totalPriceInventory, totalPriceProducts); - const totalAsString = Amounts.stringify(totalPrice.amount); + const totalAsString = !totalPrice + ? undefined + : Amounts.stringify(totalPrice.amount); const allProducts = productList.concat(inventoryList.map(asProduct)); const [newField, setNewField] = useState(""); @@ -417,12 +423,17 @@ export function CreatePage({ }); }, [hasProducts, totalAsString]); - const discountOrRise = rate( - parsedPrice ?? Amounts.zeroOfCurrency(config.currency), - totalPrice.amount, - ); + const discountOrRise = + !parsedPrice || !totalPrice + ? 1 + : rate(parsedPrice ?? zero, totalPrice.amount); const discountOrRiseRounded = Math.round((discountOrRise - 1) * 100); + const currencyInventory = !zero + ? [] + : instanceInventory.filter((d) => { + return Amounts.parse(d.price)?.currency === zero.currency; + }); const minAgeByProducts = inventoryList.reduce( (cur, prev) => !prev.product.minimum_age || cur > prev.product.minimum_age @@ -446,17 +457,18 @@ export function CreatePage({ <div> <LocalNotificationBannerBulma notification={notification} /> <MissingBankAccountsWarning /> + <LimitedKycActionWarning /> <section class="section is-main-section"> <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> {/* // FIXME: translating plural singular */} - <FragmentPersonaFlag point={UIElement.option_advanceOrderCreation}> + <FragmentPersonaFlag point={UIElement.option_advanceOrderCreation} > <InputGroup name="inventory_products" label={i18n.str`Manage products in order`} alternative={ - allProducts.length > 0 && ( + allProducts.length > 0 && totalPrice && ( <p> <i18n.Translate> {allProducts.length} products with a total price of{" "} @@ -474,7 +486,7 @@ export function CreatePage({ <InventoryProductForm currentProducts={value.inventoryProducts || {}} onAddProduct={addProductToTheInventoryList} - inventory={instanceInventory} + inventory={currencyInventory} /> <NonInventoryProductFrom 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 @@ -450,23 +450,15 @@ function PaidPage({ }: { id: string; order: TalerMerchantApi.CheckPaymentPaidResponse; - onRefund: (id: string) => void; + onRefund: (id: TalerMerchantApi.CheckPaymentPaidResponse) => void; onWireTransferSelection: (id: number) => void; }) { const { state, config } = useSessionContext(); const [verifyingWiretransfer, setVerifyingWiretransfer] = useState< TransactionWireTransfer[] >([]); - 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 [value, valueHandler] = useState<Partial<Paid>>(order); const { i18n } = useTranslationContext(); const now = new Date(); @@ -674,6 +666,7 @@ function PaidPage({ }); }); } + const refundTaken = Amounts.stringify(totalRefundedTaken) const maxTotalFee = Amounts.add(totalRefundedTaken, maxDepositFee).amount; const shouldBeWiredMinimum = Amounts.sub(amount, maxTotalFee).amount; // we have a minimum but not a price value @@ -792,7 +785,7 @@ function PaidPage({ type="button" class="button is-danger" disabled={!merchantCanRefund} - onClick={() => onRefund(id)} + onClick={() => onRefund(order)} > <i18n.Translate>Refund</i18n.Translate> </button> @@ -901,7 +894,6 @@ function PaidPage({ object={value} valueHandler={valueHandler} > - {/* <InputCurrency<Paid> name="deposit_total" readonly label={i18n.str`Deposit total`} /> */} {order.refunded && ( <InputCurrency<Paid> name="refund_amount" @@ -1120,7 +1112,7 @@ export function DetailPage({ onBack, onSelectWireTransfer, }: Props): VNode { - const [showRefund, setShowRefund] = useState<string | undefined>(undefined); + const [showRefund, setShowRefund] = useState<TalerMerchantApi.CheckPaymentPaidResponse | undefined>(undefined); const { i18n } = useTranslationContext(); const DetailByStatus = function () { switch (selected.order_status) { @@ -1154,7 +1146,7 @@ export function DetailPage({ {DetailByStatus()} {showRefund && ( <RefundModal - order={selected} + order={showRefund} id={id} onCancel={() => setShowRefund(undefined)} onConfirmed={() => { 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 @@ -188,7 +188,11 @@ function Table({ <div class=""> {onLoadMoreBefore && ( - <button type="button" class="button is-fullwidth" onClick={onLoadMoreBefore}> + <button + type="button" + class="button is-fullwidth" + onClick={onLoadMoreBefore} + > <i18n.Translate>Load first page</i18n.Translate> </button> )} @@ -326,7 +330,7 @@ function EmptyTable(): VNode { interface RefundModalProps { onCancel: () => void; onConfirmed: () => void; - order: TalerMerchantApi.MerchantOrderStatusResponse; + order: TalerMerchantApi.CheckPaymentPaidResponse; id: string; } @@ -342,31 +346,25 @@ export function RefundModal({ const { i18n } = useTranslationContext(); // const [errors, setErrors] = useState<FormErrors<State>>({}); const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const { state: session, lib } = useSessionContext(); + const { state: session, lib, config } = useSessionContext(); - let amount: AmountJson | undefined; - if (order.order_status === "paid") { - const orderam = getOrderAmountAndMaxDepositFee(order); - amount = typeof orderam === "string" ? undefined : orderam.amount; + const orderam = getOrderAmountAndMaxDepositFee(order); + if (typeof orderam === "string") { + throw Error("this order contract is imcomplete: " + orderam); } + const orderPrice = orderam.amount; - const refunds = ( - order.order_status === "paid" ? order.refund_details : [] - ).reduce(mergeRefunds, []); + const refunds = 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), + Amounts.zeroOfCurrency(orderPrice.currency), ); - const orderPrice = amount; - const totalRefundable = !orderPrice - ? Amounts.zeroOfCurrency(totalRefunded.currency) - : refunds.length - ? Amounts.sub(orderPrice, totalRefunded).amount - : orderPrice; + const totalRefundable = refunds.length + ? Amounts.sub(orderPrice, totalRefunded).amount + : orderPrice; const isRefundable = Amounts.isNonZero(totalRefundable); const duplicatedText = i18n.str`Duplicated`; 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 @@ -29,6 +29,7 @@ import { } from "@gnu-taler/taler-util"; import { LocalNotificationBannerBulma, + NotificationCardBulma, useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -41,10 +42,7 @@ import { JumpToElementById } from "../../../../components/form/JumpToElementById import { DatePicker } from "../../../../components/picker/DatePicker.js"; import { Tooltip } from "../../../../components/Tooltip.js"; import { useSessionContext } from "../../../../context/session.js"; -import { - useInstanceOrders, - useOrderDetails -} from "../../../../hooks/order.js"; +import { useInstanceOrders, useOrderDetails } from "../../../../hooks/order.js"; import { dateFormatForPreferences, usePreference, @@ -306,6 +304,17 @@ function RefundModalForTable({ } } } + if (result.body.order_status !== "paid") { + return ( + <NotificationCardBulma + notification={{ + type: "WARN", + message: i18n.str`This order is not refundable`, + description: i18n.str`The order status is not "paid" so we are unable to process any refund action.`, + }} + /> + ); + } return ( <RefundModal diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx @@ -45,6 +45,7 @@ import { } from "../../../../hooks/pots.js"; import { useInstanceProductGroups } from "../../../../hooks/groups.js"; import { Tooltip } from "../../../../components/Tooltip.js"; +import { LimitedKycActionWarning, MissingBankAccountsWarning } from "../../accounts/list/index.js"; export interface Props { onCreate: () => void; @@ -110,6 +111,8 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { return ( <div> <LocalNotificationBannerBulma notification={notification} /> + <MissingBankAccountsWarning /> + <LimitedKycActionWarning /> <section class="section is-main-section"> <div class="columns"> <div class="column" /> 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 @@ -167,7 +167,11 @@ function Table({ return ( <div class=""> {onLoadMoreBefore && ( - <button type="button" class="button is-fullwidth" onClick={onLoadMoreBefore}> + <button + type="button" + class="button is-fullwidth" + onClick={onLoadMoreBefore} + > <i18n.Translate>Load first page</i18n.Translate> </button> )} @@ -225,7 +229,8 @@ function Table({ ); } - const isFree = Amounts.isZero(Amounts.parseOrThrow(i.price)); + const price = Amounts.parseOrThrow(i.price); + const isFree = Amounts.isZero(price); return ( <Fragment key={i.id}> @@ -294,7 +299,7 @@ function Table({ <RenderAmountBulma value={sum( i.taxes, - Amounts.zeroOfCurrency(config.currency), + Amounts.zeroOfCurrency(price.currency), )} specMap={config.currencies} /> @@ -308,7 +313,7 @@ function Table({ <RenderAmountBulma value={difference( Amounts.parseOrThrow(i.price), - sum(i.taxes, Amounts.zeroOfCurrency(config.currency)), + sum(i.taxes, Amounts.zeroOfCurrency(price.currency)), )} specMap={config.currencies} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/statistics/list/index.tsx @@ -79,17 +79,20 @@ export default function Statistics({}: Props): VNode { Duration.fromSpec({ months: 1 }), ); + const availableCurrencies = Object.keys(config.currencies); + // FIXME: throw meaningful error if backend is missconfigured + // it should always have at least 1 currency + const [revenueChartFilter, setRevenueChartFilter] = useState<RevenueChartFilter>({ range: StatisticBucketRange.Quarter, rangeCount: 4, - currency: config.currency, + currency: availableCurrencies[0], }); const [startOrdersFromDate, setStartOrdersFromDate] = useState< AbsoluteTime | undefined >(lastMonthAbs); - const availableCurrencies = Object.keys(config.currencies); return ( <section class="section is-main-section"> <div> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -58,6 +58,11 @@ import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; import { UIElement } from "../../../../hooks/preference.js"; import { Tooltip } from "../../../../components/Tooltip.js"; import { InputDurationDropdown } from "../../../../components/form/InputDurationDropdown.js"; +import { useAvailableCurrencies } from "../../../../hooks/instance.js"; +import { + LimitedKycActionWarning, + MissingBankAccountsWarning, +} from "../../accounts/list/index.js"; const TALER_SCREEN_ID = 61; @@ -146,46 +151,55 @@ export function CreatePage({ : undefined, }; - const cList = Object.values(config.currencies).map((d) => d.name); + const cList = useAvailableCurrencies(); const hasErrors = Object.keys(errors).some( (k) => (errors as Record<string, unknown>)[k] !== undefined, ); - const zero = Amounts.stringify(Amounts.zeroOfCurrency(config.currency)); + const zero = !session.currency + ? undefined + : Amounts.zeroOfCurrency(session.currency.name); const contract_amount = state.amount_editable ? undefined : (state.amount as AmountString); const contract_summary = state.summary_editable ? undefined : state.summary; - const template_contract: TalerMerchantApi.TemplateContractDetails = { - minimum_age: state.minimum_age!, - pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), - amount: contract_amount, - summary: contract_summary, - currency: - cList.length > 1 && state.currency_editable ? undefined : config.currency, - }; - const data: TalerMerchantApi.TemplateAddDetails = { - template_id: state.id!, - template_description: state.description!, - template_contract, - editable_defaults: { - amount: !state.amount_editable ? undefined : state.amount ?? zero, - summary: !state.summary_editable ? undefined : state.summary ?? "", - currency: - cList.length === 1 || !state.currency_editable - ? undefined - : config.currency, - }, - otp_id: state.otpId!, - }; + + const data: undefined | TalerMerchantApi.TemplateAddDetails = !zero + ? undefined + : { + template_id: state.id!, + template_description: state.description!, + template_contract: { + minimum_age: state.minimum_age!, + pay_duration: Duration.toTalerProtocolDuration(state.pay_duration!), + amount: contract_amount, + summary: contract_summary, + currency: + cList.length > 1 && state.currency_editable + ? undefined + : zero.currency, + }, + editable_defaults: { + amount: !state.amount_editable + ? undefined + : state.amount ?? Amounts.stringify(zero), + summary: !state.summary_editable ? undefined : state.summary ?? "", + currency: + cList.length === 1 || !state.currency_editable + ? undefined + : zero.currency, + }, + otp_id: state.otpId!, + }; const create = safeFunctionHandler( i18n.str`add template`, lib.instance.addTemplate.bind(lib.instance), - !session.token || hasErrors ? undefined : [session.token, data], + !session.token || !data || hasErrors ? undefined : [session.token, data], ); + create.onSuccess = onCreated; create.onFail = (fail) => { switch (fail.case) { @@ -211,6 +225,7 @@ export function CreatePage({ }, {} as Record<string, TranslatedString>, ); + return ( <div> <LocalNotificationBannerBulma notification={notification} /> @@ -219,6 +234,8 @@ export function CreatePage({ <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> + <LimitedKycActionWarning /> + <MissingBankAccountsWarning /> <FormProvider object={state} valueHandler={updateState} @@ -340,12 +357,9 @@ export function CreatePage({ : i18n.str`Confirm operation` } > - <ButtonBetterBulma - type="submit" - onClick={create} - > - <i18n.Translate>Confirm</i18n.Translate> - </ButtonBetterBulma> + <ButtonBetterBulma type="submit" onClick={create}> + <i18n.Translate>Confirm</i18n.Translate> + </ButtonBetterBulma> </Tooltip> </div> </FormProvider> 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 @@ -47,6 +47,7 @@ import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { ListPage } from "./ListPage.js"; import { useSessionContext } from "../../../../context/session.js"; +import { MissingBankAccountsWarning } from "../../accounts/list/index.js"; interface Props { onTransferDetails: (id: number) => void; @@ -187,6 +188,7 @@ function ListTransferInternal({ return ( <Fragment> + <MissingBankAccountsWarning /> <section class="section is-main-section"> <div class="columns"> <div class="column" />