taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 6a166fc986a02c2f44c61d39d51812dfaefd4193
parent d03eaf4f3da56b1f0131c848d458b0054620c268
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Mon, 27 Apr 2026 17:43:20 -0300

fix #11026

Diffstat:
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 10++++++++++
Mpackages/merchant-backoffice-ui/src/components/menu/SideBar.tsx | 37+++++++++++++++++++++++++++----------
Apackages/merchant-backoffice-ui/src/hooks/exchanges.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/hooks/preference.ts | 1+
Apackages/merchant-backoffice-ui/src/paths/instance/exchanges/list/ListPage.tsx | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/exchanges/list/index.tsx | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/merchant.ts | 37+++++++++++++++++++++++++++++--------
Mpackages/taler-util/src/types-taler-merchant.ts | 61++++++++++++++++++++++++++++++++++++++++++++++++++++---------
8 files changed, 523 insertions(+), 27 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -98,6 +98,7 @@ import { NewAccount } from "./paths/newAccount/index.js"; import { ResetAccount } from "./paths/resetAccount/index.js"; import { Settings } from "./paths/settings/index.js"; import PosTokenCreatePage from "./paths/instance/accessTokens/create-pos/index.js"; +import ListExchanges from "./paths/instance/exchanges/list/index.js"; const TALER_SCREEN_ID = 3; @@ -167,6 +168,8 @@ export enum InstancePaths { reports_list = "/reports", reports_update = "/reports/:cid/update", reports_new = "/reports/new", + + exchanges_list = "/exchanges" } export enum AdminPaths { @@ -470,6 +473,13 @@ export function Routing(_p: Props): VNode { * Scheduled report pages */} <Route + path={InstancePaths.exchanges_list} + component={ListExchanges} + /> + {/** + * Scheduled report pages + */} + <Route path={InstancePaths.reports_list} component={ListScheduledReport} onCreate={() => { diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -382,6 +382,18 @@ export function Sidebar({ mobile }: Props): VNode { <span class="menu-item-label">{state.instance}</span> </div> </li> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_exchanges} + > + <a href={"#/exchanges"} class="has-icon"> + <i class="icon mdi mdi-key" /> + + <span class="menu-item-label"> + <i18n.Translate>Exchanges</i18n.Translate> + </span> + </a> + </HtmlPersonaFlag> {state.isAdmin && ( <Fragment> <p class="menu-label"> @@ -467,8 +479,9 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.option_paymentTimeoutOnTemplate]: true, [UIElement.action_useRevenueApi]: true, [UIElement.option_inventoryTaxes]: true, + [UIElement.sidebar_exchanges]: true, + [UIElement.sidebar_statistics]: false, - [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_discounts]: false, [UIElement.sidebar_subscriptions]: false, @@ -487,6 +500,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_settings]: true, [UIElement.sidebar_password]: true, + [UIElement.sidebar_exchanges]: false, [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_accessTokens]: false, [UIElement.sidebar_categories]: false, @@ -521,15 +535,16 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_templates]: true, [UIElement.sidebar_inventory]: true, [UIElement.sidebar_categories]: true, - [UIElement.sidebar_group]: false, - [UIElement.sidebar_pots]: false, - [UIElement.sidebar_reports]: false, - [UIElement.sidebar_accessTokens]: false, [UIElement.sidebar_kycStatus]: true, [UIElement.sidebar_bankAccounts]: true, [UIElement.sidebar_settings]: true, [UIElement.sidebar_password]: true, - + + [UIElement.sidebar_group]: false, + [UIElement.sidebar_pots]: false, + [UIElement.sidebar_reports]: false, + [UIElement.sidebar_accessTokens]: false, + [UIElement.sidebar_exchanges]: false, [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_discounts]: false, [UIElement.sidebar_subscriptions]: false, @@ -554,13 +569,14 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { case "digital-publishing": return { [UIElement.sidebar_orders]: true, - [UIElement.sidebar_accessTokens]: false, [UIElement.sidebar_kycStatus]: true, [UIElement.sidebar_bankAccounts]: true, [UIElement.sidebar_settings]: true, [UIElement.sidebar_password]: true, + + [UIElement.sidebar_accessTokens]: false, [UIElement.sidebar_statistics]: false, - + [UIElement.sidebar_exchanges]: false, [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_templates]: false, [UIElement.sidebar_categories]: false, @@ -591,13 +607,14 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { return { [UIElement.sidebar_orders]: true, [UIElement.sidebar_webhooks]: true, - [UIElement.sidebar_accessTokens]: false, [UIElement.sidebar_kycStatus]: true, [UIElement.sidebar_bankAccounts]: true, [UIElement.sidebar_settings]: true, [UIElement.sidebar_password]: true, + + [UIElement.sidebar_accessTokens]: false, [UIElement.sidebar_statistics]: false, - + [UIElement.sidebar_exchanges]: false, [UIElement.sidebar_multicurrency]: false, [UIElement.sidebar_templates]: false, [UIElement.sidebar_categories]: false, diff --git a/packages/merchant-backoffice-ui/src/hooks/exchanges.ts b/packages/merchant-backoffice-ui/src/hooks/exchanges.ts @@ -0,0 +1,65 @@ +/* + 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/> + */ + +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import { + AccessToken, + TalerHttpError, + TalerMerchantApi, + TalerMerchantManagementResultByMethod, +} from "@gnu-taler/taler-util"; +import _useSWR, { SWRHook, mutate } from "swr"; +import { useSessionContext } from "../context/session.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export function revalidateExchanges() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "listExchanges", + undefined, + { revalidate: true }, + ); +} +export function useBackendExchanges() { + const { lib } = useSessionContext(); + + async function fetcher([]: [AccessToken, string]) { + const list = await lib.instance.listExchanges(); + if (list.type === "ok") { + list.body.exchanges.sort(importantFirst); + } + return list; + } + + const { data, error } = useSWR< + TalerMerchantManagementResultByMethod<"listExchanges">, + TalerHttpError + >(["listExchanges"], fetcher, { + refreshInterval: 5000, + }); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return data; +} + +function importantFirst( + a: TalerMerchantApi.ExchangeStatusDetail, + b: TalerMerchantApi.ExchangeStatusDetail, +): 0 | 1 | -1 { + return 0; +} diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts @@ -30,6 +30,7 @@ import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; export enum UIElement { sidebar_multicurrency, + sidebar_exchanges, sidebar_orders, sidebar_inventory, sidebar_categories, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/exchanges/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/exchanges/list/ListPage.tsx @@ -0,0 +1,169 @@ +/* + 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, URL } from "@gnu-taler/taler-util"; +import { Time, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { Tooltip } from "../../../../components/Tooltip.js"; + +const TALER_SCREEN_ID = 41; + +export interface Props { + exchanges: TalerMerchantApi.ExchangeStatusDetail[]; + onSelect: (e: TalerMerchantApi.ExchangeStatusDetail) => void; +} + +export function ListPage({ exchanges, onSelect }: Props): VNode { + const { i18n } = useTranslationContext(); + + return ( + <section class="section is-main-section"> + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <i class="icon mdi mdi-clock" /> + <i18n.Translate>Supported exchange status</i18n.Translate> + </p> + + <div class="card-header-icon" aria-label="more options" /> + </header> + <div class="card-content"> + <div class=" has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {exchanges.length > 0 ? ( + <PendingTable exchanges={exchanges} onSelect={onSelect} /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + </section> + ); +} +interface PendingTableProps { + exchanges: TalerMerchantApi.ExchangeStatusDetail[]; + onSelect: (e: TalerMerchantApi.ExchangeStatusDetail) => void; +} + +function PendingTable({ exchanges, onSelect }: PendingTableProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class=""> + <table class="table is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Base URL</i18n.Translate> + </th> + <th> + <i18n.Translate>Status</i18n.Translate> + </th> + <th> + <i18n.Translate>Next update</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {exchanges.map((e, i) => { + const isWorking = + e.keys_http_status >= 200 && e.keys_http_status < 300; + const legend = isWorking + ? i18n.str`This exchange is ready to be used.` + : i18n.str`This exchange can't be used due to an error. Contact the service provider.`; + const url = new URL(`/config`, e.exchange_url); + return ( + <tr key={i}> + <td> + <Tooltip text={legend}> + <a + href={url.href} + target="_blank" + referrerpolicy="no-referrer" + > + {e.exchange_url} + </a> + </Tooltip> + </td> + <td> + <Tooltip text={legend}> + {isWorking ? ( + <div style={{ color: "green" }}> + <i class="icon mdi mdi-check" /> + {e.keys_http_status} + </div> + ) : ( + <div style={{ color: "red" }}> + <i class="icon mdi mdi-close" /> + {e.keys_http_status} + </div> + )} + </Tooltip> + </td> + <td> + <Tooltip text={legend}> + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + e.next_download, + )} + /> + </Tooltip> + </td> + <td class="is-actions-cell right-sticky"> + <div key={i} class="buttons is-right"> + <Tooltip text={i18n.str`Details of the status`}> + <button + class="button is-small is-info" + type="button" + onClick={() => onSelect(e)} + > + <i18n.Translate>Details</i18n.Translate> + </button> + </Tooltip> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </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="icon mdi mdi-emoticon-happy mdi-48px" /> + </span> + </p> + <p> + <i18n.Translate>No pending kyc verification!</i18n.Translate> + </p> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/exchanges/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/exchanges/list/index.tsx @@ -0,0 +1,170 @@ +/* + This file is part of GNU Taler + (C) 2021-2026 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, + Paytos, + TalerError, + TalerMerchantApi, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + NotificationCardBulma, + Time, + useCommonPreferences, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; +import { Loading } from "../../../../components/exception/loading.js"; +import { + ConfirmModal, + ValidBankAccount, +} from "../../../../components/modal/index.js"; +import { useBackendExchanges } from "../../../../hooks/exchanges.js"; +import { ListPage } from "./ListPage.js"; +import { useState } from "preact/hooks"; + +const TALER_SCREEN_ID = 40; + +interface Props { + // onGetInfo: (id: string) => void; + // onShowInstructions: (id: string) => void; +} + +export default function ListExchanges(_p: Props): VNode { + const result = useBackendExchanges(); + const { i18n } = useTranslationContext(); + const [showState, setShowState] = useState< + TalerMerchantApi.ExchangeStatusDetail | undefined + >(undefined); + + if (!result) return <Loading />; + if (result instanceof TalerError) { + return <ErrorLoadingMerchant error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.NotFound: + return <div />; + case HttpStatusCode.InternalServerError: + return <div />; + default: { + assertUnreachable(result); + } + } + } + const { exchanges } = result.body; + + if (!exchanges.length) { + return ( + <NotificationCardBulma + notification={{ + message: i18n.str`No exchanges supported`, + description: i18n.str`Currently the list of exchanges in the configuration is empty. Without this this server can process any payment.".`, + type: "WARN", + }} + /> + ); + } + return ( + <Fragment> + {showState !== undefined ? ( + <ShowExchangeStatus + e={showState} + onCancel={() => setShowState(undefined)} + /> + ) : undefined} + <ListPage exchanges={exchanges} onSelect={(e) => setShowState(e)} /> + </Fragment> + ); +} + +function ShowExchangeStatus({ + e, + onCancel, +}: { + e: TalerMerchantApi.ExchangeStatusDetail; + onCancel: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const isWorking = e.keys_http_status >= 200 && e.keys_http_status < 300; + if (isWorking) { + return ( + <ConfirmModal + label={i18n.str`Ok`} + description={e.exchange_url as TranslatedString} + active + onCancel={onCancel} + > + <p style={{ paddingTop: 0 }}> + {e.keys_expiration ? ( + <i18n.Translate> + Ready until{" "} + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp( + e.keys_expiration, + )} + /> + </i18n.Translate> + ) : undefined} + </p> + <p style={{ paddingTop: 0 }}> + <i18n.Translate> + Next update will be at{" "} + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp(e.next_download)} + /> + </i18n.Translate> + </p> + </ConfirmModal> + ); + } + return ( + <ConfirmModal + label={i18n.str`Error`} + description={e.exchange_url as TranslatedString} + active + onCancel={onCancel} + > + <p style={{ paddingTop: 0 }}> + <b><i18n.Translate>This exchange is down.</i18n.Translate></b>{" "} + <i18n.Translate> + Next update will be at{" "} + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={AbsoluteTime.fromProtocolTimestamp(e.next_download)} + /> + </i18n.Translate> + </p> + <p style={{ paddingTop: 0 }}> + <i18n.Translate> + Last HTTP status was {e.keys_http_status}: {e.keys_hint} (#{e.keys_ec} + ) + </i18n.Translate> + </p> + </ConfirmModal> + ); +} diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -46,6 +46,7 @@ import { codecForChallengeRequestResponse, codecForChallengeResponse, codecForClaimResponse, + codecForExchangeStatusResponse, codecForExpectedTansferList, codecForExpectedTransferDetails, codecForFullInventoryDetailsResponse, @@ -237,6 +238,26 @@ export class TalerMerchantInstanceHttpClient { } } + /** + * https://docs.taler.net/core/api-merchant.html#get--exchanges + */ + async listExchanges() { + const url = new URL(`exchanges`, this.baseUrl); + const resp = await this.httpLib.fetch(url.href, { + method: "GET", + }); + switch (resp.status) { + case HttpStatusCode.Ok: + return opSuccessFromHttp(resp, codecForExchangeStatusResponse()); + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.InternalServerError: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + // // Auth // @@ -256,17 +277,17 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.NotFound> | OperationOk<TalerMerchantApi.LoginTokenSuccessResponse> | OperationAlternative< - HttpStatusCode.Accepted, - TalerMerchantApi.ChallengeResponse - > + HttpStatusCode.Accepted, + TalerMerchantApi.ChallengeResponse + > | OperationFail<HttpStatusCode.Unauthorized> > { const url = new URL(`private/token`, this.baseUrl); const headers = authHeaders({ type: "basic", username: instance, - password - }) + password, + }); if (params.challengeIds && params.challengeIds.length > 0) { headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); } @@ -931,9 +952,9 @@ export class TalerMerchantInstanceHttpClient { | OperationOk<TalerMerchantApi.AccountAddResponse> | OperationFail<HttpStatusCode.NotFound> | OperationAlternative< - HttpStatusCode.Accepted, - TalerMerchantApi.ChallengeResponse - > + HttpStatusCode.Accepted, + TalerMerchantApi.ChallengeResponse + > | OperationFail<HttpStatusCode.Unauthorized> | OperationFail<HttpStatusCode.Conflict> > { diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -87,7 +87,7 @@ import { codecForInternationalizedString, codecForURLString, } from "./types-taler-common.js"; -import { PayWalletData } from "./types-taler-wallet.js"; +import { codecForCanonBaseUrl, PayWalletData } from "./types-taler-wallet.js"; /** * Proposal returned from the contract URL. @@ -3710,10 +3710,14 @@ export interface TemplateDetails { required_currency?: string; } -export type UsingTemplateDetailsRequest = (UsingTemplateFixedOrderRequest | UsingTemplateInventoryCartRequest | UsingTemplatePaivanaRequest) & UsingTemplateCommonRequest; +export type UsingTemplateDetailsRequest = ( + | UsingTemplateFixedOrderRequest + | UsingTemplateInventoryCartRequest + | UsingTemplatePaivanaRequest +) & + UsingTemplateCommonRequest; export interface UsingTemplateCommonRequest { - // Type of the template being instantiated. // Possible values include "fixed-order", // "inventory-cart" and "paivana". @@ -3733,12 +3737,10 @@ export interface UsingTemplateCommonRequest { // fixed template currency. // Since protocol **v25**. tip?: AmountString; - } export interface UsingTemplateFixedOrderRequest { - template_type: TemplateType.FIXED_ORDER; - + template_type: TemplateType.FIXED_ORDER; } export interface UsingTemplateInventoryCartRequest { @@ -3768,10 +3770,8 @@ export interface UsingTemplatePaivanaRequest { // This becomes the "session_id" for session-based // access control. paivana_id: string; - } - export interface WebhookAddDetails { // Webhook ID to use. webhook_id: string; @@ -4473,6 +4473,49 @@ export interface MerchantTemplateAddDetails { otp_id?: string; } +export interface ExchangeStatusResponse { + exchanges: ExchangeStatusDetail[]; +} + +export interface ExchangeStatusDetail { + // Base URL of the exchange this is about. + exchange_url: string; + + // Time when the backend will download /keys next. + next_download: Timestamp; + + // Time when the current /keys response is expected to + // expire. Missing if we do not have one. + keys_expiration?: Timestamp; + + // HTTP status code returned by the exchange when we asked for + // /keys. 0 if we did not receive an HTTP status code. + // Usually 200 for success. + keys_http_status: Integer; + + // Numeric error code indicating an + // error we had processing the /keys response. + keys_ec: Integer; + + // Human-readable error description matching keys_ec. + keys_hint: string; +} + +export const codecForExchangeStatusResponse = (): Codec<ExchangeStatusResponse> => + buildCodecForObject<ExchangeStatusResponse>() + .property("exchanges", codecForList(codecForExchangeStatusDetail())) + .build("TalerMerchantApi.ExchangeStatusResponse"); + +export const codecForExchangeStatusDetail = (): Codec<ExchangeStatusDetail> => + buildCodecForObject<ExchangeStatusDetail>() + .property("exchange_url", codecForCanonBaseUrl()) + .property("next_download", codecForTimestamp) + .property("keys_expiration", codecOptional(codecForTimestamp)) + .property("keys_http_status", codecForNumber()) + .property("keys_ec", codecForNumber()) + .property("keys_hint", codecForString()) + .build("TalerMerchantApi.ExchangeStatusDetail"); + const codecForExchangeConfigInfo = (): Codec<ExchangeConfigInfo> => buildCodecForObject<ExchangeConfigInfo>() .property("base_url", codecForString()) @@ -5315,7 +5358,7 @@ export const codecForTemplateContractInventoryCart = "selected_products", codecOptionalDefault(codecForList(codecForString()), []), ) - .property("inventory_payload", codecOptional(codecForAny())) // FIXME: validate + .property("inventory_payload", codecOptional(codecForAny())) // FIXME: validate .property("currency", codecOptional(codecForString())) .property("max_pickup_duration", codecOptional(codecForDuration))