taler-typescript-core

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

commit 231eeef55c993c08aa9129d652efcb7a83ba53b3
parent a85af3425d935c8d454676a177f8fe72a150a336
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 20 Jan 2025 02:19:31 -0300

new rules wizard

Diffstat:
Mpackages/aml-backoffice-ui/src/Routing.tsx | 2+-
Mpackages/aml-backoffice-ui/src/hooks/decision-request.ts | 82+++++++------------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx | 947-------------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 287+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Apackages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx | 298+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/pages/decision/Events.tsx | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/pages/decision/Justification.tsx | 14++++++++++++++
Apackages/aml-backoffice-ui/src/pages/decision/Measures.tsx | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/pages/decision/Properties.tsx | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1228 insertions(+), 1126 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx @@ -41,7 +41,7 @@ import { Measures } from "./pages/Measures.js"; import { AmlDecisionRequestWizard, WizardSteps, -} from "./pages/AmlDecisionRequestWizard.js"; +} from "./pages/decision/AmlDecisionRequestWizard.js"; import { useCurrentDecisionRequest } from "./hooks/decision-request.js"; export function Routing(): VNode { diff --git a/packages/aml-backoffice-ui/src/hooks/decision-request.ts b/packages/aml-backoffice-ui/src/hooks/decision-request.ts @@ -16,16 +16,14 @@ import { AbsoluteTime, - AmountJson, Codec, - Duration, + KycRule, MeasureInformation, buildCodecForObject, codecForAbsoluteTime, - codecForAmountJson, codecForAny, codecForBoolean, - codecForDurationMs, + codecForKycRules, codecForList, codecForMap, codecForMeasureInformation, @@ -35,28 +33,8 @@ import { } from "@gnu-taler/taler-util"; import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -export type BalanceForm = { - balance: AmountJson | undefined; - balanceUnlimited: boolean; - withdrawalAmount: AmountJson | undefined; - withdrawalTimeframe: Duration | undefined; - withdrawalUnlimited: boolean; - depositAmount: AmountJson | undefined; - depositTimeframe: Duration | undefined; - depositUnlimited: boolean; - closeAmount: AmountJson | undefined; - closeTimeframe: Duration | undefined; - closeUnlimited: boolean; - mergeAmount: AmountJson | undefined; - mergeTimeframe: Duration | undefined; - mergeUnlimited: boolean; - aggregateAmount: AmountJson | undefined; - aggregateTimeframe: Duration | undefined; - aggregateUnlimited: boolean; -}; - export interface DecisionRequest { - rules: BalanceForm | undefined; + rules: KycRule[] | undefined; deadline: AbsoluteTime | undefined; properties: object | undefined; custom_properties: object | undefined; @@ -64,50 +42,12 @@ export interface DecisionRequest { inhibit_events: string[] | undefined; keep_investigating: boolean; justification: string | undefined; - next_measure: string[][] | undefined; - custom_measures: - | { - [measure_name: string]: MeasureInformation; - } - | undefined; + new_measures: string | undefined; } -export const codecForBalanceForm = (): Codec<BalanceForm> => - buildCodecForObject<BalanceForm>() - .property("balance", codecOptional(codecForAmountJson())) - .property( - "balanceUnlimited", - codecOptionalDefault(codecForBoolean(), false), - ) - .property("withdrawalAmount", codecOptional(codecForAmountJson())) - .property("withdrawalTimeframe", codecOptional(codecForDurationMs)) - .property( - "withdrawalUnlimited", - codecOptionalDefault(codecForBoolean(), false), - ) - .property("depositAmount", codecOptional(codecForAmountJson())) - .property("depositTimeframe", codecOptional(codecForDurationMs)) - .property( - "depositUnlimited", - codecOptionalDefault(codecForBoolean(), false), - ) - .property("closeAmount", codecOptional(codecForAmountJson())) - .property("closeTimeframe", codecOptional(codecForDurationMs)) - .property("closeUnlimited", codecOptionalDefault(codecForBoolean(), false)) - .property("mergeAmount", codecOptional(codecForAmountJson())) - .property("mergeTimeframe", codecOptional(codecForDurationMs)) - .property("mergeUnlimited", codecOptionalDefault(codecForBoolean(), false)) - .property("aggregateAmount", codecOptional(codecForAmountJson())) - .property("aggregateTimeframe", codecOptional(codecForDurationMs)) - .property( - "aggregateUnlimited", - codecOptionalDefault(codecForBoolean(), false), - ) - .build("BalanceForm"); - export const codecForDecisionRequest = (): Codec<DecisionRequest> => buildCodecForObject<DecisionRequest>() - .property("rules", codecOptional(codecForBalanceForm())) + .property("rules", codecOptional(codecForList(codecForKycRules()))) .property("deadline", codecOptional(codecForAbsoluteTime)) .property("properties", codecForAny()) .property("custom_properties", codecForAny()) @@ -118,24 +58,16 @@ export const codecForDecisionRequest = (): Codec<DecisionRequest> => "keep_investigating", codecOptionalDefault(codecForBoolean(), false), ) - .property( - "next_measure", - codecOptional(codecForList(codecForList(codecForString()))), - ) - .property( - "custom_measures", - codecOptional(codecForMap(codecForMeasureInformation())), - ) + .property("new_measures", codecOptional(codecForString())) .build("DecisionRequest"); const defaultDecisionRequest: DecisionRequest = { - custom_measures: undefined, deadline: undefined, custom_events: undefined, inhibit_events: undefined, justification: undefined, keep_investigating: false, - next_measure: undefined, + new_measures: undefined, properties: undefined, custom_properties: undefined, rules: undefined, diff --git a/packages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx @@ -1,947 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { - AbsoluteTime, - assertUnreachable, - MeasureInformation, - TalerError, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { - FormDesign, - FormUI, - InternationalizationAPI, - UIFormElementConfig, - UIHandlerId, - useExchangeApiContext, - useForm, - TalerFormAttributes, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; -import { - BalanceForm, - DecisionRequest, - useCurrentDecisionRequest, -} from "../hooks/decision-request.js"; -import { useAccountActiveDecision } from "../hooks/decisions.js"; -import { ShowDecisionLimitInfo, ShowMeasuresToSelect } from "./CaseDetails.js"; -import { useEffect, useRef } from "preact/hooks"; -import { useServerMeasures } from "../hooks/account.js"; -import { usePreferences } from "../hooks/preferences.js"; - -export type WizardSteps = - | "rules" // define the limits - | "measures" // define a new form/challenge - | "properties" // define account information - | "events" // define events to trigger - | "justification"; // finalize, investigate?; - -const STEPS_ORDER: WizardSteps[] = [ - "rules", - "measures", - "properties", - "events", - "justification", -]; - -const STEPS_ORDER_MAP = STEPS_ORDER.reduce( - (map, cur, idx, steps) => { - map[cur] = { - prev: idx === 0 ? undefined : steps[idx - 1], - next: idx === steps.length ? undefined : steps[idx + 1], - }; - return map; - }, - {} as { - [s in WizardSteps]: { - next: WizardSteps | undefined; - prev: WizardSteps | undefined; - }; - }, -); - -function isRulesCompleted(request: DecisionRequest): boolean { - return request.rules !== undefined; -} -function isPropertiesCompleted(request: DecisionRequest): boolean { - return request.properties !== undefined; -} -function isEventsCompleted(request: DecisionRequest): boolean { - return request.custom_events !== undefined; -} -function isMeasuresCompleted(request: DecisionRequest): boolean { - return ( - request.next_measure !== undefined && request.custom_measures !== undefined - ); -} -function isJustificationCompleted(request: DecisionRequest): boolean { - return ( - request.keep_investigating !== undefined && - request.justification !== undefined - ); -} - -export function AmlDecisionRequestWizard({ - account, - step, - onMove, -}: { - account?: string; - step?: WizardSteps; - onMove: (n: WizardSteps | undefined) => void; -}): VNode { - const { i18n } = useTranslationContext(); - const stepOrDefault = step ?? "rules"; - const content = (function () { - switch (stepOrDefault) { - case "rules": - return <Rules account={account} />; - case "properties": - return <Properties />; - case "events": - return <Events />; - case "measures": - return <Measures />; - case "justification": - return <Justification />; - } - assertUnreachable(stepOrDefault); - })(); - - return ( - <div> - <WizardSteps step={stepOrDefault} onMove={onMove} /> - <button - disabled={!STEPS_ORDER_MAP[stepOrDefault].prev} - onClick={() => { - onMove(STEPS_ORDER_MAP[stepOrDefault].prev); - }} - class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" - > - <i18n.Translate>Prev</i18n.Translate> - </button> - <button - disabled={!STEPS_ORDER_MAP[stepOrDefault].next} - onClick={() => { - onMove(STEPS_ORDER_MAP[stepOrDefault].next); - }} - class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" - > - <i18n.Translate>Next</i18n.Translate> - </button> - {content} - </div> - ); -} - -const rulesForm = ( - i18n: InternationalizationAPI, - currency: string, -): FormDesign<BalanceForm> => ({ - type: "double-column", - sections: [ - { - title: i18n.str`Wallet`, - description: i18n.str`Limit the state of the wallet.`, - fields: [ - { - id: "balance" as UIHandlerId, - type: "amount", - label: i18n.str`Balance`, - currency, - }, - { - id: "balanceUnlimited" as UIHandlerId, - type: "toggle", - label: i18n.str`Unlimited`, - }, - ], - }, - { - title: i18n.str`Operations`, - description: i18n.str`Limit the operation rate.`, - fields: [ - { - type: "group", - label: i18n.str`Withdrawal`, - fields: [ - { - id: "withdrawalAmount" as UIHandlerId, - type: "amount", - label: i18n.str`Amount`, - currency, - }, - { - id: "withdrawalTimeframe" as UIHandlerId, - type: "duration", - label: i18n.str`Timeframe`, - }, - { - id: "withdrawalUnlimited" as UIHandlerId, - type: "toggle", - label: i18n.str`Unlimited`, - }, - ], - }, - { - type: "group", - label: i18n.str`Deposit`, - fields: [ - { - id: "depositAmount" as UIHandlerId, - type: "amount", - label: i18n.str`Amount`, - - currency, - }, - { - id: "depositTimeframe" as UIHandlerId, - type: "duration", - label: i18n.str`Timeframe`, - }, - ], - }, - { - type: "group", - label: i18n.str`Aggregate`, - fields: [ - { - id: "aggregateAmount" as UIHandlerId, - type: "amount", - label: i18n.str`Amount`, - - currency, - }, - { - id: "aggregateTimeframe" as UIHandlerId, - type: "duration", - label: i18n.str`Timeframe`, - }, - ], - }, - { - type: "group", - label: i18n.str`Merge`, - fields: [ - { - id: "mergeAmount" as UIHandlerId, - type: "amount", - label: i18n.str`Amount`, - - currency, - }, - { - id: "mergeTimeframe" as UIHandlerId, - type: "duration", - label: i18n.str`Timeframe`, - }, - ], - }, - { - type: "group", - label: i18n.str`Close`, - fields: [ - { - id: "closeAmount" as UIHandlerId, - type: "amount", - label: i18n.str`Amount`, - - currency, - }, - { - id: "closeTimeframe" as UIHandlerId, - type: "duration", - label: i18n.str`Timeframe`, - }, - ], - }, - ], - }, - ], -}); - -function onComponentUnload(callback: () => void) { - /** - * we use a ref to avoid evaluating the effect function - * on every render and so the unload is called only once - */ - const ref = useRef<typeof callback>(); - ref.current = callback; - - useEffect(() => { - return () => { - ref.current!(); - }; - }, []); -} - -/** - * Defined new limits for the account - * @param param0 - * @returns - */ -function Rules({ account }: { account?: string }): VNode { - const activeDecision = useAccountActiveDecision(account); - - const { i18n } = useTranslationContext(); - const { config } = useExchangeApiContext(); - const [request, updateRequest] = useCurrentDecisionRequest(); - const currency = config.config.currency; - const design = rulesForm(i18n, currency); - const form = useForm<BalanceForm>(design, request.rules ?? {}); - - onComponentUnload(() => { - updateRequest("rules", form.status.result as any); - }); - - const info = - !activeDecision || - activeDecision instanceof TalerError || - activeDecision.type === "fail" - ? undefined - : activeDecision.body; - - return ( - <div> - <FormUI design={design} handler={form.handler} /> - {!info ? undefined : ( - <div> - <h2 class="mt-4 mb-2"> - <i18n.Translate>Current limits</i18n.Translate> - </h2> - <ShowDecisionLimitInfo - fixed - since={AbsoluteTime.fromProtocolTimestamp(info.decision_time)} - until={AbsoluteTime.fromProtocolTimestamp( - info.limits.expiration_time, - )} - rules={info.limits.rules} - startOpen - /> - </div> - )} - </div> - ); -} - -type PropertiesForm = { - defined: { [name: string]: string }; - custom: { name: string; value: string }[]; -}; - -const propertiesForm = ( - i18n: InternationalizationAPI, - props: UIFormElementConfig[], -): FormDesign<PropertiesForm> => ({ - type: "double-column", - sections: [ - { - title: i18n.str`Properties`, - description: i18n.str`Default properties are defined by the server dialect`, - fields: props.map((f) => - "id" in f ? { ...f, id: ("defined." + f.id) as UIHandlerId } : f, - ), - }, - { - title: i18n.str`Custom properties`, - description: i18n.str`Add more properties that not listed above.`, - fields: [ - { - id: "custom" as UIHandlerId, - type: "array", - label: i18n.str`New properties`, - labelFieldId: "name" as UIHandlerId, - fields: [ - { - type: "text", - label: i18n.str`Name`, - id: "name" as UIHandlerId, - }, - { - type: "text", - label: i18n.str`Value`, - id: "value" as UIHandlerId, - }, - ], - }, - ], - }, - ], -}); - -function propertiesByDialect( - i18n: InternationalizationAPI, - dialect: string | undefined, -): UIFormElementConfig[] { - if (!dialect) return []; - switch (dialect) { - case "testing": { - return [ - { - id: "ACCOUNT_PEP" satisfies keyof TalerFormAttributes.AccountProperties_Testing as UIHandlerId, - label: i18n.str`Public exposed person?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }, - ]; - } - case "gls": { - return [ - { - id: "ACCOUNT_REPORTED" satisfies keyof TalerFormAttributes.AccountProperties_GLS as UIHandlerId, - label: i18n.str`Is PEP`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }, - ]; - } - case "tops": { - return [ - { - id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Frozen?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }, - { - id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`High risk?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }, - { - id: "ACCOUNT_PEP" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Public exposed person?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }, - { - id: "ACCOUNT_REPORTED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is reported to authorities?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }, - { - id: "ACCOUNT_SANCTIONED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is PEP`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }, - { - id: "ACCOUNT_BUSINESS_DOMAIN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Business domain`, - // gana_type: "Boolean", - type: "text", - }, - ]; - } - default: { - return []; - } - } -} -/** - * Update account properites - * @param param0 - * @returns - */ -function Properties({}: {}): VNode { - const { i18n } = useTranslationContext(); - const [request, _, updateRequest] = useCurrentDecisionRequest(); - const { config } = useExchangeApiContext(); - const [pref] = usePreferences(); - const design = propertiesForm( - i18n, - propertiesByDialect( - i18n, - pref.testingDialect ? "testing" : config.config.aml_spa_dialect, - ), - ); - - const form = useForm<PropertiesForm>(design, { - defined: request.properties, - custom: Object.entries(request.custom_properties ?? {}).map( - ([name, value]) => { - return { name, value }; - }, - ), - }); - - onComponentUnload(() => { - updateRequest({ - ...request, - properties: form.status.result.defined ?? {}, - custom_properties: (form.status.result.custom ?? []).reduce( - (prev, cur) => { - if (!cur || !cur.name || !cur.value) return prev; - console.log(cur); - prev[cur.name] = cur.value; - return prev; - }, - {} as Record<string, string>, - ), - }); - }); - - return ( - <div> - <FormUI design={design} handler={form.handler} /> - </div> - ); -} - -type EventsForm = { - trigger: { name: string }[]; - inhibit: { [name: string]: boolean }; -}; - -const eventsForm = ( - i18n: InternationalizationAPI, - defaultEvents: UIFormElementConfig[], -): FormDesign<MeasureForm> => ({ - type: "double-column", - sections: [ - { - title: i18n.str`Inhibit default events`, - description: i18n.str`Use this form to prevent events to be triggered by the current status.`, - fields: !defaultEvents.length - ? [ - { - type: "caption", - label: i18n.str`No default events calculated.`, - }, - ] - : defaultEvents.map((f) => - "id" in f ? { ...f, id: ("inhibit." + f.id) as UIHandlerId } : f, - ), - }, - { - title: i18n.str`Custom event`, - description: i18n.str`Add more events to be triggered by this request.`, - fields: [ - { - id: "trigger" as UIHandlerId, - type: "array", - label: i18n.str`Event list`, - labelFieldId: "name" as UIHandlerId, - fields: [ - { - type: "text", - label: i18n.str`Name`, - id: "name" as UIHandlerId, - }, - ], - }, - ], - }, - ], - // fields: [ - // { - // id: "trigger" as UIHandlerId, - // type: "array", - // labelFieldId: "name" as UIHandlerId, - // label: i18n.str`Trigger`, - // fields: [], - // }, - // { - // id: "inhibit" as UIHandlerId, - // type: "array", - // labelFieldId: "name" as UIHandlerId, - // label: i18n.str`Inhibit`, - // fields: [], - // }, - // ], -}); - -function eventsByDialect( - i18n: InternationalizationAPI, - dialect: string | undefined, - properties: object, -): UIFormElementConfig[] { - if (!dialect) return []; - const result: UIFormElementConfig[] = []; - switch (dialect) { - case "testing": { - const props = properties as TalerFormAttributes.AccountProperties_TOPS; - if (props.ACCOUNT_FROZEN) { - result.push({ - id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is froozen?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - if (props.ACCOUNT_SANCTIONED) { - result.push({ - id: "ACCOUNT_SANCTIONED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is sacntioned?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - if (props.ACCOUNT_HIGH_RISK) { - result.push({ - id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is high risk?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - break; - } - case "gls": { - const props = properties as TalerFormAttributes.AccountProperties_TOPS; - if (props.ACCOUNT_FROZEN) { - result.push({ - id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is frozen?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - break; - } - case "tops": { - const props = properties as TalerFormAttributes.AccountProperties_TOPS; - if (props.ACCOUNT_HIGH_RISK) { - result.push({ - id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, - label: i18n.str`Is high risk?`, - // gana_type: "Boolean", - type: "toggle", - required: true, - }); - } - break; - } - } - return result; -} -/** - * Trigger additional events - * @param param0 - * @returns - */ -function Events({}: {}): VNode { - const { i18n } = useTranslationContext(); - const [request, _, updateRequest] = useCurrentDecisionRequest(); - const [pref] = usePreferences(); - const { config } = useExchangeApiContext(); - - const calculatedProps = { - ...(request.properties ?? {}), - ...(request.custom_properties ?? {}), - }; - - const calculatedEvents = eventsByDialect( - i18n, - pref.testingDialect ? "testing" : config.config.aml_spa_dialect, - calculatedProps, - ); - - const design = eventsForm(i18n, calculatedEvents); - - const form = useForm<EventsForm>(design, { - inhibit: calculatedEvents.reduce( - (prev, cur) => { - if (cur.type !== "toggle") return prev; - const isInhibit = - request.inhibit_events !== undefined && - request.inhibit_events.indexOf(cur.id) !== -1; - prev[cur.id] = isInhibit; - return prev; - }, - {} as EventsForm["inhibit"], - ), - trigger: !request.custom_events - ? [] - : request.custom_events.map((name) => ({ name })), - }); - - onComponentUnload(() => { - updateRequest({ - ...request, - custom_events: !form.status.result.trigger - ? [] - : form.status.result.trigger.map((t) => t?.name!), - inhibit_events: Object.entries(form.status.result.inhibit ?? {}) - .filter(([key, inhibit]) => !!inhibit) - .map(([key]) => key), - }); - }); - - return ( - <div> - <FormUI design={design} handler={form.handler} /> - </div> - ); -} - -type MeasureForm = { - paths: { steps: Array<string> }[]; -}; - -const measureForm = ( - i18n: InternationalizationAPI, - mi: (MeasureInformation & { id: string })[], -): FormDesign<MeasureForm> => ({ - type: "single-column", - fields: [ - { - type: "array", - id: "paths" as UIHandlerId, - label: i18n.str`Paths`, - help: i18n.str`For every entry the customer will have a different path to satify checks.`, - labelFieldId: "steps" as UIHandlerId, - fields: [ - { - type: "selectMultiple", - choices: mi.map((m) => { - return { - value: m.id, - label: m.id, - }; - }), - id: "steps" as UIHandlerId, - label: i18n.str`Steps`, - help: i18n.str`The checks that the customer will need to satisfy for this path.`, - }, - ], - }, - ], -}); - -/** - * Ask for more information, define new paths to proceed - * @param param0 - * @returns - */ -function Measures({}: {}): VNode { - const { i18n } = useTranslationContext(); - const [request, _, updateRequest] = useCurrentDecisionRequest(); - const measures = useServerMeasures(); - const measureList = - !measures || measures instanceof TalerError || measures.type === "fail" - ? [] - : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); - - const initValue: MeasureForm = !request.next_measure - ? { paths: [] } - : { paths: request.next_measure.map((steps) => ({ steps })) }; - - const design = measureForm(i18n, measureList); - const form = useForm<MeasureForm>(design, initValue); - - onComponentUnload(() => { - const r = !form.status.result.paths - ? [] - : (form.status.result.paths.map( - (path) => path?.steps ?? [], - ) as string[][]); - - updateRequest({ ...request, next_measure: r, custom_measures: {} }); - }); - - return ( - <div> - <FormUI design={design} handler={form.handler} /> - <ShowMeasuresToSelect /> - </div> - ); -} - -/** - * Mark for further investigation and explain decision - * @param param0 - * @returns - */ -function Justification({}: {}): VNode { - const { i18n } = useTranslationContext(); - const [request] = useCurrentDecisionRequest(); - return <div> not yet impltemented: justification and investigation</div>; -} - -function WizardSteps({ - step: currentStep, - onMove, -}: { - step: WizardSteps; - onMove: (n: WizardSteps | undefined) => void; -}): VNode { - const [request] = useCurrentDecisionRequest(); - const { i18n } = useTranslationContext(); - const STEP_INFO: { - [s in WizardSteps]: { - label: TranslatedString; - description: TranslatedString; - isCompleted: (r: DecisionRequest) => boolean; - }; - } = { - rules: { - label: i18n.str`Rules`, - description: i18n.str`Set the limit of the operations`, - isCompleted: isRulesCompleted, - }, - events: { - label: i18n.str`Events`, - description: i18n.str`Trigger notifications about the account.`, - isCompleted: isEventsCompleted, - }, - measures: { - label: i18n.str`Measures`, - description: i18n.str`Ask the customer to take action.`, - isCompleted: isMeasuresCompleted, - }, - justification: { - label: i18n.str`Justification`, - description: i18n.str`Describe the decision.`, - isCompleted: isJustificationCompleted, - }, - properties: { - label: i18n.str`Properties`, - description: i18n.str`Add information about the account.`, - isCompleted: isPropertiesCompleted, - }, - }; - return ( - <div class="lg:border-b lg:border-t lg:border-gray-200"> - <nav class="mx-auto max-w-7xl " aria-label="Progress"> - <ol - role="list" - class="overflow-hidden rounded-md lg:flex lg:rounded-none lg:border-l lg:border-r lg:border-gray-200" - > - {STEPS_ORDER.map((stepLabel) => { - const info = STEP_INFO[stepLabel]; - const st = info.isCompleted(request) - ? "completed" - : currentStep === stepLabel - ? "current" - : "incomplete"; - - const pos = !STEPS_ORDER_MAP[stepLabel].prev - ? "first" - : !STEPS_ORDER_MAP[stepLabel].next - ? "last" - : "middle"; - - return ( - <li class="relative lg:flex-1"> - <div - data-pos={pos} - class="overflow-hidden data-[pos=first]:rounded-t-md border data-[pos=first]:border-b-0 border-gray-200 lg:border-0" - > - {currentStep === stepLabel ? ( - <span - class="absolute left-0 top-0 h-full w-1 bg-indigo-600 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full" - aria-hidden="true" - ></span> - ) : undefined} - <button - aria-current="step" - class="group" - onClick={() => { - onMove(stepLabel); - }} - > - <span - data-status={st} - class="absolute left-0 top-0 h-full w-1 data-[status=current]:bg-indigo-600 data-[status=current]:bg-transparent group-hover:bg-gray-200 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full" - aria-hidden="true" - ></span> - <div> - <span class="flex items-start px-4 pt-4 text-sm font-medium"> - <span class="shrink-0"> - <span - data-status={st} - class="flex size-6 items-center justify-center rounded-full data-[status=completed]:bg-indigo-600 border-2 data-[status=current]:border-indigo-600 data-[status=incomplete]:border-gray-300" - > - <svg - class="size-4 text-white " - viewBox="0 0 24 24" - fill="currentColor" - aria-hidden="true" - data-slot="icon" - > - <path - fill-rule="evenodd" - d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" - clip-rule="evenodd" - /> - </svg> - </span> - </span> - <span - data-status={st} - class="ml-4 data-[status=current]:text-indigo-600" - > - {info.label} - </span> - </span> - </div> - <div class="p-2 text-start"> - <span class="ml-4 mt-0.5 flex min-w-0 flex-col"> - <span - data-current={currentStep === stepLabel} - class="text-sm font-medium data-[current=true]:text-indigo-600" - ></span> - <span class="text-sm font-medium text-gray-500"> - {info.description} - </span> - </span> - </div> - </button> - {pos === "first" ? undefined : ( - <div - data-pos={pos} - class="absolute inset-0 left-0 top-0 hidden w-2 lg:block" - aria-hidden="true" - > - <svg - data-pos={pos} - class="size-full text-gray-300 data-[pos=middle]:h-full data-[pos=middle]:w-full" - viewBox="0 0 12 82" - fill="none" - preserveAspectRatio="none" - > - <path - d="M0.5 0V31L10.5 41L0.5 51V82" - stroke="currentcolor" - vector-effect="non-scaling-stroke" - /> - </svg> - </div> - )} - </div> - </li> - ); - })} - </ol> - </nav> - </div> - ); -} diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -27,6 +27,7 @@ import { CurrencySpecification, HttpStatusCode, KycRule, + LimitOperationType, OperationFail, OperationOk, TalerError, @@ -251,14 +252,13 @@ export function CaseDetails({ <button onClick={async () => { onNewDecision({ - custom_measures: undefined, deadline: undefined, custom_properties: undefined, custom_events: undefined, inhibit_events: undefined, justification: undefined, keep_investigating: false, - next_measure: undefined, + new_measures: undefined, properties: undefined, rules: undefined, }); @@ -795,6 +795,169 @@ function ShowMesaureInfo({ ); } +export function RulesInfo({ + rules, + onEdit, + onRemove, +}: { + rules: KycRule[]; + onEdit?: (k: KycRule, idx: number) => void; + onRemove?: (k: KycRule, idx: number) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useExchangeApiContext(); + + if (!rules.length) { + return ( + <Attention + title={i18n.str`There are no rules for operations`} + type="warning" + /> + ); + } + + const balanceLimitIdx = rules.findIndex( + (r) => r.operation_type === "BALANCE", + ); + const balanceLimit = rules[balanceLimitIdx]; + + const hasActions = !!onEdit || !!onRemove; + + return ( + <Fragment> + <div class=""> + <div class="flex mt-2 rounded-md w-fit shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> + <div class="whitespace-nowrap pointer-events-none bg-gray-200 inset-y-0 items-center px-3 flex"> + <i18n.Translate>Max balance</i18n.Translate> + </div> + <div class="p-2 disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"> + {!balanceLimit ? ( + <i18n.Translate>Unlimited</i18n.Translate> + ) : ( + <RenderAmount + value={Amounts.parseOrThrow(balanceLimit.threshold)} + spec={config.config.currency_specification} + /> + )} + </div> + </div> + </div> + <div class=""> + <table class="min-w-full divide-y divide-gray-300"> + <thead class="bg-gray-50"> + <tr> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6" + > + <i18n.Translate>Operation</i18n.Translate> + </th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate>Timeframe</i18n.Translate> + </th> + <th + scope="col" + class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right" + > + <i18n.Translate>Amount</i18n.Translate> + </th> + <th + scope="col" + class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right" + > + <i18n.Translate>Measures</i18n.Translate> + </th> + {!hasActions ? undefined : ( + <th + scope="col" + class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right" + > + <i18n.Translate>Actions</i18n.Translate> + </th> + )} + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + {rules.map((r, idx) => { + if (r.operation_type === "BALANCE") return; + return ( + <tr> + <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left"> + {r.operation_type} + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {r.timeframe.d_us === "forever" ? ( + <i18n.Translate>Forever</i18n.Translate> + ) : ( + formatDuration( + intervalToDuration({ + start: 0, + end: r.timeframe.d_us / 1000, + }), + ) + )} + </td> + <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right"> + <RenderAmount + value={Amounts.parseOrThrow(r.threshold)} + spec={config.config.currency_specification} + /> + </td> + <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right"> + {r.measures} + </td> + {!hasActions ? undefined : ( + <td class="relative flex justify-end whitespace-nowrap py-4 pl-3 pr-4 text-sm font-medium sm:pr-6"> + {!onEdit ? undefined : ( + <button onClick={() => onEdit(r, idx)}> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6 text-green-700" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" + /> + </svg> + </button> + )} + {!onRemove ? undefined : ( + <button onClick={() => onRemove(r, idx)}> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6 text-red-700" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" + /> + </svg> + </button> + )} + </td> + )} + </tr> + ); + })} + </tbody> + </table> + </div> + </Fragment> + ); +} export function ShowDecisionLimitInfo({ rules, since, @@ -811,7 +974,6 @@ export function ShowDecisionLimitInfo({ fixed?: boolean; }): VNode { const { i18n } = useTranslationContext(); - const { config } = useExchangeApiContext(); const [opened, setOpened] = useState(startOpen ?? false); function Header() { @@ -897,7 +1059,6 @@ export function ShowDecisionLimitInfo({ </div> ); } - const balanceLimit = rules.find((r) => r.operation_type === "BALANCE"); return ( <div class="overflow-hidden border border-gray-800 rounded-xl"> @@ -925,87 +1086,7 @@ export function ShowDecisionLimitInfo({ </div> )} - <div class=""> - <div class="flex mt-2 rounded-md w-fit shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> - <div class="whitespace-nowrap pointer-events-none bg-gray-200 inset-y-0 items-center px-3 flex"> - <i18n.Translate>Max balance</i18n.Translate> - </div> - <div class="p-2 disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"> - {!balanceLimit ? ( - <i18n.Translate>Unlimited</i18n.Translate> - ) : ( - <RenderAmount - value={Amounts.parseOrThrow(balanceLimit.threshold)} - spec={config.config.currency_specification} - /> - )} - </div> - </div> - </div> - - {!rules.length ? ( - <Attention - title={i18n.str`There are no rules for operations`} - type="warning" - /> - ) : ( - <div class=""> - <table class="min-w-full divide-y divide-gray-300"> - <thead class="bg-gray-50"> - <tr> - <th - scope="col" - class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6" - > - <i18n.Translate>Operation</i18n.Translate> - </th> - <th - scope="col" - class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" - > - <i18n.Translate>Timeframe</i18n.Translate> - </th> - <th - scope="col" - class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right" - > - <i18n.Translate>Amount</i18n.Translate> - </th> - </tr> - </thead> - <tbody class="divide-y divide-gray-200"> - {rules.map((r) => { - if (r.operation_type === "BALANCE") return; - return ( - <tr> - <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left"> - {r.operation_type} - </td> - <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> - {r.timeframe.d_us === "forever" ? ( - <i18n.Translate>Forever</i18n.Translate> - ) : ( - formatDuration( - intervalToDuration({ - start: 0, - end: r.timeframe.d_us / 1000, - }), - ) - )} - </td> - <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6 text-right"> - <RenderAmount - value={Amounts.parseOrThrow(r.threshold)} - spec={config.config.currency_specification} - /> - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - )} + <RulesInfo rules={rules} /> </div> </div> ); @@ -1346,7 +1427,7 @@ const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = ( currency, ) => [ { - operation_type: "WITHDRAW", + operation_type: LimitOperationType.withdraw, threshold: `${currency}:2000`, timeframe: { d_us: 7 * 24 * 60 * 60 * 1000 * 1000, @@ -1357,7 +1438,7 @@ const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "DEPOSIT", + operation_type: LimitOperationType.deposit, threshold: `${currency}:2000`, timeframe: { d_us: 7 * 24 * 60 * 60 * 1000 * 1000, @@ -1368,7 +1449,7 @@ const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "AGGREGATE", + operation_type: LimitOperationType.aggregate, threshold: `${currency}:2000`, timeframe: { d_us: 7 * 24 * 60 * 60 * 1000 * 1000, @@ -1379,7 +1460,7 @@ const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "MERGE", + operation_type: LimitOperationType.merge, threshold: `${currency}:2000`, timeframe: { d_us: 7 * 24 * 60 * 60 * 1000 * 1000, @@ -1390,7 +1471,7 @@ const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "BALANCE", + operation_type: LimitOperationType.balance, threshold: `${currency}:2000`, timeframe: { d_us: 7 * 24 * 60 * 60 * 1000 * 1000, @@ -1401,7 +1482,7 @@ const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "CLOSE", + operation_type: LimitOperationType.close, threshold: `${currency}:2000`, timeframe: { d_us: 7 * 24 * 60 * 60 * 1000 * 1000, @@ -1417,7 +1498,7 @@ const THRESHOLD_100_HOUR: (currency: string) => TalerExchangeApi.KycRule[] = ( currency, ) => [ { - operation_type: "WITHDRAW", + operation_type: LimitOperationType.withdraw, threshold: `${currency}:100`, timeframe: { d_us: 1 * 60 * 60 * 1000 * 1000, @@ -1428,7 +1509,7 @@ const THRESHOLD_100_HOUR: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "DEPOSIT", + operation_type: LimitOperationType.deposit, threshold: `${currency}:100`, timeframe: { d_us: 1 * 60 * 60 * 1000 * 1000, @@ -1439,7 +1520,7 @@ const THRESHOLD_100_HOUR: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "AGGREGATE", + operation_type: LimitOperationType.aggregate, threshold: `${currency}:100`, timeframe: { d_us: 1 * 60 * 60 * 1000 * 1000, @@ -1450,7 +1531,7 @@ const THRESHOLD_100_HOUR: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "MERGE", + operation_type: LimitOperationType.merge, threshold: `${currency}:100`, timeframe: { d_us: 1 * 60 * 60 * 1000 * 1000, @@ -1461,7 +1542,7 @@ const THRESHOLD_100_HOUR: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "BALANCE", + operation_type: LimitOperationType.balance, threshold: `${currency}:100`, timeframe: { d_us: 1 * 60 * 60 * 1000 * 1000, @@ -1472,7 +1553,7 @@ const THRESHOLD_100_HOUR: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "CLOSE", + operation_type: LimitOperationType.close, threshold: `${currency}:100`, timeframe: { d_us: 1 * 60 * 60 * 1000 * 1000, @@ -1488,7 +1569,7 @@ const FREEZE_RULES: (currency: string) => TalerExchangeApi.KycRule[] = ( currency, ) => [ { - operation_type: "WITHDRAW", + operation_type: LimitOperationType.withdraw, threshold: `${currency}:0`, timeframe: { d_us: "forever", @@ -1499,7 +1580,7 @@ const FREEZE_RULES: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "DEPOSIT", + operation_type: LimitOperationType.deposit, threshold: `${currency}:0`, timeframe: { d_us: "forever", @@ -1510,7 +1591,7 @@ const FREEZE_RULES: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "AGGREGATE", + operation_type: LimitOperationType.aggregate, threshold: `${currency}:0`, timeframe: { d_us: "forever", @@ -1521,7 +1602,7 @@ const FREEZE_RULES: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "MERGE", + operation_type: LimitOperationType.merge, threshold: `${currency}:0`, timeframe: { d_us: "forever", @@ -1532,7 +1613,7 @@ const FREEZE_RULES: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "BALANCE", + operation_type: LimitOperationType.balance, threshold: `${currency}:0`, timeframe: { d_us: "forever", @@ -1543,7 +1624,7 @@ const FREEZE_RULES: (currency: string) => TalerExchangeApi.KycRule[] = ( is_and_combinator: true, }, { - operation_type: "CLOSE", + operation_type: LimitOperationType.close, threshold: `${currency}:0`, timeframe: { d_us: "forever", diff --git a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx @@ -0,0 +1,298 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + assertUnreachable, + MeasureInformation, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { + FormDesign, + InternationalizationAPI, + UIHandlerId, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { + DecisionRequest, + useCurrentDecisionRequest, +} from "../../hooks/decision-request.js"; +import { Events } from "./Events.js"; +import { Properties } from "./Properties.js"; +import { Rules } from "./Rules.js"; +import { Measures } from "./Measures.js"; +import { Justification } from "./Justification.js"; + +export type WizardSteps = + | "rules" // define the limits + | "measures" // define a new form/challenge + | "properties" // define account information + | "events" // define events to trigger + | "justification"; // finalize, investigate?; + +const STEPS_ORDER: WizardSteps[] = [ + "rules", + "measures", + "properties", + "events", + "justification", +]; + +const STEPS_ORDER_MAP = STEPS_ORDER.reduce( + (map, cur, idx, steps) => { + map[cur] = { + prev: idx === 0 ? undefined : steps[idx - 1], + next: idx === steps.length ? undefined : steps[idx + 1], + }; + return map; + }, + {} as { + [s in WizardSteps]: { + next: WizardSteps | undefined; + prev: WizardSteps | undefined; + }; + }, +); + +function isRulesCompleted(request: DecisionRequest): boolean { + return request.rules !== undefined; +} +function isPropertiesCompleted(request: DecisionRequest): boolean { + return request.properties !== undefined; +} +function isEventsCompleted(request: DecisionRequest): boolean { + return request.custom_events !== undefined; +} +function isMeasuresCompleted(request: DecisionRequest): boolean { + return request.new_measures !== undefined; +} +function isJustificationCompleted(request: DecisionRequest): boolean { + return ( + request.keep_investigating !== undefined && + request.justification !== undefined + ); +} + +export function AmlDecisionRequestWizard({ + account, + step, + onMove, +}: { + account?: string; + step?: WizardSteps; + onMove: (n: WizardSteps | undefined) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const stepOrDefault = step ?? "rules"; + const content = (function () { + switch (stepOrDefault) { + case "rules": + return <Rules account={account} />; + case "properties": + return <Properties />; + case "events": + return <Events />; + case "measures": + return <Measures />; + case "justification": + return <Justification />; + } + assertUnreachable(stepOrDefault); + })(); + + return ( + <div> + <WizardSteps step={stepOrDefault} onMove={onMove} /> + <button + disabled={!STEPS_ORDER_MAP[stepOrDefault].prev} + onClick={() => { + onMove(STEPS_ORDER_MAP[stepOrDefault].prev); + }} + class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" + > + <i18n.Translate>Prev</i18n.Translate> + </button> + <button + disabled={!STEPS_ORDER_MAP[stepOrDefault].next} + onClick={() => { + onMove(STEPS_ORDER_MAP[stepOrDefault].next); + }} + class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" + > + <i18n.Translate>Next</i18n.Translate> + </button> + {content} + </div> + ); +} +function WizardSteps({ + step: currentStep, + onMove, +}: { + step: WizardSteps; + onMove: (n: WizardSteps | undefined) => void; +}): VNode { + const [request] = useCurrentDecisionRequest(); + const { i18n } = useTranslationContext(); + const STEP_INFO: { + [s in WizardSteps]: { + label: TranslatedString; + description: TranslatedString; + isCompleted: (r: DecisionRequest) => boolean; + }; + } = { + rules: { + label: i18n.str`Rules`, + description: i18n.str`Set the limit of the operations`, + isCompleted: isRulesCompleted, + }, + events: { + label: i18n.str`Events`, + description: i18n.str`Trigger notifications about the account.`, + isCompleted: isEventsCompleted, + }, + measures: { + label: i18n.str`Measures`, + description: i18n.str`Ask the customer to take action.`, + isCompleted: isMeasuresCompleted, + }, + justification: { + label: i18n.str`Justification`, + description: i18n.str`Describe the decision.`, + isCompleted: isJustificationCompleted, + }, + properties: { + label: i18n.str`Properties`, + description: i18n.str`Add information about the account.`, + isCompleted: isPropertiesCompleted, + }, + }; + return ( + <div class="lg:border-b lg:border-t lg:border-gray-200"> + <nav class="mx-auto max-w-7xl " aria-label="Progress"> + <ol + role="list" + class="overflow-hidden rounded-md lg:flex lg:rounded-none lg:border-l lg:border-r lg:border-gray-200" + > + {STEPS_ORDER.map((stepLabel) => { + const info = STEP_INFO[stepLabel]; + const st = info.isCompleted(request) + ? "completed" + : currentStep === stepLabel + ? "current" + : "incomplete"; + + const pos = !STEPS_ORDER_MAP[stepLabel].prev + ? "first" + : !STEPS_ORDER_MAP[stepLabel].next + ? "last" + : "middle"; + + return ( + <li class="relative lg:flex-1"> + <div + data-pos={pos} + class="overflow-hidden data-[pos=first]:rounded-t-md border data-[pos=first]:border-b-0 border-gray-200 lg:border-0" + > + {currentStep === stepLabel ? ( + <span + class="absolute left-0 top-0 h-full w-1 bg-indigo-600 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full" + aria-hidden="true" + ></span> + ) : undefined} + <button + aria-current="step" + class="group" + onClick={() => { + onMove(stepLabel); + }} + > + <span + data-status={st} + class="absolute left-0 top-0 h-full w-1 data-[status=current]:bg-indigo-600 data-[status=current]:bg-transparent group-hover:bg-gray-200 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full" + aria-hidden="true" + ></span> + <div> + <span class="flex items-start px-4 pt-4 text-sm font-medium"> + <span class="shrink-0"> + <span + data-status={st} + class="flex size-6 items-center justify-center rounded-full data-[status=completed]:bg-indigo-600 border-2 data-[status=current]:border-indigo-600 data-[status=incomplete]:border-gray-300" + > + <svg + class="size-4 text-white " + viewBox="0 0 24 24" + fill="currentColor" + aria-hidden="true" + data-slot="icon" + > + <path + fill-rule="evenodd" + d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" + clip-rule="evenodd" + /> + </svg> + </span> + </span> + <span + data-status={st} + class="ml-4 data-[status=current]:text-indigo-600" + > + {info.label} + </span> + </span> + </div> + <div class="p-2 text-start"> + <span class="ml-4 mt-0.5 flex min-w-0 flex-col"> + <span + data-current={currentStep === stepLabel} + class="text-sm font-medium data-[current=true]:text-indigo-600" + ></span> + <span class="text-sm font-medium text-gray-500"> + {info.description} + </span> + </span> + </div> + </button> + {pos === "first" ? undefined : ( + <div + data-pos={pos} + class="absolute inset-0 left-0 top-0 hidden w-2 lg:block" + aria-hidden="true" + > + <svg + data-pos={pos} + class="size-full text-gray-300 data-[pos=middle]:h-full data-[pos=middle]:w-full" + viewBox="0 0 12 82" + fill="none" + preserveAspectRatio="none" + > + <path + d="M0.5 0V31L10.5 41L0.5 51V82" + stroke="currentcolor" + vector-effect="non-scaling-stroke" + /> + </svg> + </div> + )} + </div> + </li> + ); + })} + </ol> + </nav> + </div> + ); +} diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx @@ -0,0 +1,208 @@ +import { + useTranslationContext, + useExchangeApiContext, + useForm, + FormUI, + TalerFormAttributes, + UIHandlerId, + InternationalizationAPI, + UIFormElementConfig, + FormDesign, + onComponentUnload, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; +import { usePreferences } from "../../hooks/preferences.js"; +import { MeasureInformation } from "@gnu-taler/taler-util"; + +/** + * Trigger additional events + * @param param0 + * @returns + */ +export function Events({}: {}): VNode { + const { i18n } = useTranslationContext(); + const [request, _, updateRequest] = useCurrentDecisionRequest(); + const [pref] = usePreferences(); + const { config } = useExchangeApiContext(); + + const calculatedProps = { + ...(request.properties ?? {}), + ...(request.custom_properties ?? {}), + }; + + const calculatedEvents = eventsByDialect( + i18n, + pref.testingDialect ? "testing" : config.config.aml_spa_dialect, + calculatedProps, + ); + + const design = eventsForm(i18n, calculatedEvents); + + const form = useForm<EventsForm>(design, { + inhibit: calculatedEvents.reduce( + (prev, cur) => { + if (cur.type !== "toggle") return prev; + const isInhibit = + request.inhibit_events !== undefined && + request.inhibit_events.indexOf(cur.id) !== -1; + prev[cur.id] = isInhibit; + return prev; + }, + {} as EventsForm["inhibit"], + ), + trigger: !request.custom_events + ? [] + : request.custom_events.map((name) => ({ name })), + }); + + onComponentUnload(() => { + updateRequest({ + ...request, + custom_events: !form.status.result.trigger + ? [] + : form.status.result.trigger.map((t) => t?.name!), + inhibit_events: Object.entries(form.status.result.inhibit ?? {}) + .filter(([key, inhibit]) => !!inhibit) + .map(([key]) => key), + }); + }); + + return ( + <div> + <FormUI design={design} handler={form.handler} /> + </div> + ); +} + +export type EventsForm = { + trigger: { name: string }[]; + inhibit: { [name: string]: boolean }; +}; + +export const eventsForm = ( + i18n: InternationalizationAPI, + defaultEvents: UIFormElementConfig[], +): FormDesign<MeasureInformation> => ({ + type: "double-column", + sections: [ + { + title: i18n.str`Inhibit default events`, + description: i18n.str`Use this form to prevent events to be triggered by the current status.`, + fields: !defaultEvents.length + ? [ + { + type: "caption", + label: i18n.str`No default events calculated.`, + }, + ] + : defaultEvents.map((f) => + "id" in f ? { ...f, id: ("inhibit." + f.id) as UIHandlerId } : f, + ), + }, + { + title: i18n.str`Custom event`, + description: i18n.str`Add more events to be triggered by this request.`, + fields: [ + { + id: "trigger" as UIHandlerId, + type: "array", + label: i18n.str`Event list`, + labelFieldId: "name" as UIHandlerId, + fields: [ + { + type: "text", + label: i18n.str`Name`, + id: "name" as UIHandlerId, + }, + ], + }, + ], + }, + ], + // fields: [ + // { + // id: "trigger" as UIHandlerId, + // type: "array", + // labelFieldId: "name" as UIHandlerId, + // label: i18n.str`Trigger`, + // fields: [], + // }, + // { + // id: "inhibit" as UIHandlerId, + // type: "array", + // labelFieldId: "name" as UIHandlerId, + // label: i18n.str`Inhibit`, + // fields: [], + // }, + // ], +}); + +export function eventsByDialect( + i18n: InternationalizationAPI, + dialect: string | undefined, + properties: object, +): UIFormElementConfig[] { + if (!dialect) return []; + const result: UIFormElementConfig[] = []; + switch (dialect) { + case "testing": { + const props = properties as TalerFormAttributes.AccountProperties_TOPS; + if (props.ACCOUNT_FROZEN) { + result.push({ + id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is froozen?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + if (props.ACCOUNT_SANCTIONED) { + result.push({ + id: "ACCOUNT_SANCTIONED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is sacntioned?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + if (props.ACCOUNT_HIGH_RISK) { + result.push({ + id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is high risk?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + break; + } + case "gls": { + const props = properties as TalerFormAttributes.AccountProperties_TOPS; + if (props.ACCOUNT_FROZEN) { + result.push({ + id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is frozen?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + break; + } + case "tops": { + const props = properties as TalerFormAttributes.AccountProperties_TOPS; + if (props.ACCOUNT_HIGH_RISK) { + result.push({ + id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is high risk?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + break; + } + } + return result; +} diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx @@ -0,0 +1,14 @@ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; + +/** + * Mark for further investigation and explain decision + * @param param0 + * @returns + */ +export function Justification({}: {}): VNode { + const { i18n } = useTranslationContext(); + const [request] = useCurrentDecisionRequest(); + return <div> not yet impltemented: justification and investigation</div>; +} diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx @@ -0,0 +1,121 @@ +import { MeasureInformation, TalerError } from "@gnu-taler/taler-util"; +import { + useTranslationContext, + useForm, + onComponentUnload, + FormUI, + InternationalizationAPI, + FormDesign, + UIHandlerId, + UIFormElementConfig, + RecursivePartial, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useServerMeasures } from "../../hooks/account.js"; +import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; +import { ShowMeasuresToSelect } from "../CaseDetails.js"; + +export function serializeMeasures( + paths?: RecursivePartial<MeasurePath[]>, +): string { + if (!paths) return ""; + return paths + .map((p) => { + if (!p?.steps) return ""; + return p.steps.join("+"); + }) + .join(" "); +} +export function deserializeMeasures( + measures: string | undefined, +): MeasurePath[] { + if (!measures) return []; + return measures.split(" ").map((path) => ({ steps: path.split("+") })); +} + +/** + * Ask for more information, define new paths to proceed + * @param param0 + * @returns + */ +export function Measures({}: {}): VNode { + const { i18n } = useTranslationContext(); + const [request, _, updateRequest] = useCurrentDecisionRequest(); + const measures = useServerMeasures(); + const measureList = + !measures || measures instanceof TalerError || measures.type === "fail" + ? [] + : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); + + const initValue: MeasureForm = !request.new_measures + ? { paths: [] } + : { paths: deserializeMeasures(request.new_measures) }; + + const design = measureForm(i18n, measureList); + const form = useForm<MeasureForm>(design, initValue); + + onComponentUnload(() => { + const r = !form.status.result.paths + ? [] + : (form.status.result.paths.map( + (path) => path?.steps ?? [], + ) as string[][]); + + updateRequest({ + ...request, + new_measures: serializeMeasures( + (form.status.result.paths ?? []) as MeasurePath[], + ), + }); + }); + + return ( + <div> + <FormUI design={design} handler={form.handler} /> + <ShowMeasuresToSelect /> + </div> + ); +} + +type MeasurePath = { steps: string[] }; + +type MeasureForm = { + paths: MeasurePath[]; +}; + +export function measureArrayField( + i18n: InternationalizationAPI, + mi: (MeasureInformation & { id: string })[], +): UIFormElementConfig { + return { + type: "array", + id: "paths" as UIHandlerId, + label: i18n.str`Measures`, + help: i18n.str`For every entry the customer will have a different path to satify checks.`, + labelFieldId: "steps" as UIHandlerId, + fields: [ + { + type: "selectMultiple", + choices: mi.map((m) => { + return { + value: m.id, + label: m.id, + }; + }), + id: "steps" as UIHandlerId, + label: i18n.str`Steps`, + help: i18n.str`The checks that the customer will need to satisfy for this path.`, + }, + ], + }; +} + +function measureForm( + i18n: InternationalizationAPI, + mi: (MeasureInformation & { id: string })[], +): FormDesign<MeasureForm> { + return { + type: "single-column", + fields: [measureArrayField(i18n, mi)], + }; +} diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx @@ -0,0 +1,189 @@ +import { + useTranslationContext, + useExchangeApiContext, + useForm, + onComponentUnload, + FormUI, + InternationalizationAPI, + UIFormElementConfig, + UIHandlerId, + TalerFormAttributes, + FormDesign, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; +import { usePreferences } from "../../hooks/preferences.js"; + +/** + * Update account properites + * @param param0 + * @returns + */ +export function Properties({}: {}): VNode { + const { i18n } = useTranslationContext(); + const [request, _, updateRequest] = useCurrentDecisionRequest(); + const { config } = useExchangeApiContext(); + const [pref] = usePreferences(); + const design = propertiesForm( + i18n, + propertiesByDialect( + i18n, + pref.testingDialect ? "testing" : config.config.aml_spa_dialect, + ), + ); + + const form = useForm<PropertiesForm>(design, { + defined: request.properties, + custom: Object.entries(request.custom_properties ?? {}).map( + ([name, value]) => { + return { name, value }; + }, + ), + }); + + onComponentUnload(() => { + updateRequest({ + ...request, + properties: form.status.result.defined ?? {}, + custom_properties: (form.status.result.custom ?? []).reduce( + (prev, cur) => { + if (!cur || !cur.name || !cur.value) return prev; + console.log(cur); + prev[cur.name] = cur.value; + return prev; + }, + {} as Record<string, string>, + ), + }); + }); + + return ( + <div> + <FormUI design={design} handler={form.handler} /> + </div> + ); +} + +export type PropertiesForm = { + defined: { [name: string]: string }; + custom: { name: string; value: string }[]; +}; + +export const propertiesForm = ( + i18n: InternationalizationAPI, + props: UIFormElementConfig[], +): FormDesign<PropertiesForm> => ({ + type: "double-column", + sections: [ + { + title: i18n.str`Properties`, + description: i18n.str`Default properties are defined by the server dialect`, + fields: props.map((f) => + "id" in f ? { ...f, id: ("defined." + f.id) as UIHandlerId } : f, + ), + }, + { + title: i18n.str`Custom properties`, + description: i18n.str`Add more properties that not listed above.`, + fields: [ + { + id: "custom" as UIHandlerId, + type: "array", + label: i18n.str`New properties`, + labelFieldId: "name" as UIHandlerId, + fields: [ + { + type: "text", + label: i18n.str`Name`, + id: "name" as UIHandlerId, + }, + { + type: "text", + label: i18n.str`Value`, + id: "value" as UIHandlerId, + }, + ], + }, + ], + }, + ], +}); + +export function propertiesByDialect( + i18n: InternationalizationAPI, + dialect: string | undefined, +): UIFormElementConfig[] { + if (!dialect) return []; + switch (dialect) { + case "testing": { + return [ + { + id: "ACCOUNT_PEP" satisfies keyof TalerFormAttributes.AccountProperties_Testing as UIHandlerId, + label: i18n.str`Public exposed person?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, + ]; + } + case "gls": { + return [ + { + id: "ACCOUNT_REPORTED" satisfies keyof TalerFormAttributes.AccountProperties_GLS as UIHandlerId, + label: i18n.str`Is PEP`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, + ]; + } + case "tops": { + return [ + { + id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Frozen?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, + { + id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`High risk?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, + { + id: "ACCOUNT_PEP" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Public exposed person?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, + { + id: "ACCOUNT_REPORTED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is reported to authorities?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, + { + id: "ACCOUNT_SANCTIONED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is PEP`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }, + { + id: "ACCOUNT_BUSINESS_DOMAIN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Business domain`, + // gana_type: "Boolean", + type: "text", + }, + ]; + } + default: { + return []; + } + } +} diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -0,0 +1,206 @@ +import { + AbsoluteTime, + AmountJson, + Amounts, + Duration, + KycRule, + LimitOperationType, + MeasureInformation, + TalerError, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { + FormDesign, + FormUI, + InternationalizationAPI, + onComponentUnload, + UIHandlerId, + useExchangeApiContext, + useForm, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useServerMeasures } from "../../hooks/account.js"; +import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; +import { useAccountActiveDecision } from "../../hooks/decisions.js"; +import { RulesInfo, ShowDecisionLimitInfo } from "../CaseDetails.js"; +import { measureArrayField, serializeMeasures } from "./Measures.js"; + +/** + * Defined new limits for the account + * @param param0 + * @returns + */ +export function Rules({ account }: { account?: string }): VNode { + const activeDecision = useAccountActiveDecision(account); + + const { i18n } = useTranslationContext(); + const { config } = useExchangeApiContext(); + const [request, updateRequest] = useCurrentDecisionRequest(); + const measures = useServerMeasures(); + // const [rules, setRules] = useState<KycRule[]>([]); + const measureList = + !measures || measures instanceof TalerError || measures.type === "fail" + ? [] + : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); + const design = formDesign(i18n, config.config.currency, measureList); + + const form = useForm<FormType>(design, {}); + + const info = + !activeDecision || + activeDecision instanceof TalerError || + activeDecision.type === "fail" + ? undefined + : activeDecision.body; + + function addNewRule(nr: FormType) { + const result = !request.rules ? [] : [...request.rules]; + result.push({ + timeframe: Duration.toTalerProtocolDuration(nr.timeframe), + threshold: Amounts.stringify(nr.threshold), + operation_type: nr.operation_type, + display_priority: 1, + measures: [serializeMeasures(nr.paths)], // FIXME: change how server expect new measures + }); + updateRequest("rules", result); + } + + return ( + <div> + <h2 class="mt-4 mb-2"> + <i18n.Translate>Add a new rule</i18n.Translate> + </h2> + + <FormUI design={design} handler={form.handler} /> + + <button + disabled={form.status.status === "fail"} + onClick={() => { + console.log(form); + addNewRule(form.status.result as FormType); + }} + class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700 disabled:bg-gray-600" + > + <i18n.Translate>Add</i18n.Translate> + </button> + + <h2 class="mt-4 mb-2"> + <i18n.Translate>New rules</i18n.Translate> + </h2> + + <RulesInfo + rules={request.rules ?? []} + // onEdit={(r, idx) => { + // const nr = !request.rules ? [] : [...request.rules]; + // nr.splice(idx, 1); + // updateRequest("rules", nr); + // }} + onRemove={(r, idx) => { + const nr = !request.rules ? [] : [...request.rules]; + nr.splice(idx, 1); + updateRequest("rules", nr); + }} + /> + + {!info ? undefined : ( + <div> + <h2 class="mt-4 mb-2"> + <i18n.Translate>Current rules</i18n.Translate> + </h2> + <ShowDecisionLimitInfo + fixed + since={AbsoluteTime.fromProtocolTimestamp(info.decision_time)} + until={AbsoluteTime.fromProtocolTimestamp( + info.limits.expiration_time, + )} + rules={info.limits.rules} + startOpen + /> + </div> + )} + </div> + ); +} + +type FormType = { + operation_type: LimitOperationType; + threshold: AmountJson; + timeframe: Duration; + exposed: boolean; + paths: { steps: Array<string> }[]; +}; + +// operation_type: LimitOperationType; +// threshold: AmountString; +// timeframe: RelativeTime; +// measures: string[]; +// display_priority: Integer; +// exposed?: boolean; +// is_and_combinator?: boolean; +function labelForOperationType( + op: LimitOperationType, + i18n: InternationalizationAPI, +): TranslatedString { + switch (op) { + case LimitOperationType.withdraw: + return i18n.ctx("operation type")`Withdraw`; + case LimitOperationType.deposit: + return i18n.ctx("operation type")`Deposit`; + case LimitOperationType.merge: + return i18n.ctx("operation type")`Merge`; + case LimitOperationType.aggregate: + return i18n.ctx("operation type")`Aggregate`; + case LimitOperationType.balance: + return i18n.ctx("operation type")`Balance`; + case LimitOperationType.refund: + return i18n.ctx("operation type")`Refund`; + case LimitOperationType.close: + return i18n.ctx("operation type")`Close`; + case LimitOperationType.transaction: + return i18n.ctx("operation type")`Transaction`; + } +} + +const formDesign = ( + i18n: InternationalizationAPI, + currency: string, + mi: (MeasureInformation & { id: string })[], +): FormDesign<KycRule> => ({ + type: "single-column", + fields: [ + { + id: "operation_type" as UIHandlerId, + type: "choiceHorizontal", + label: i18n.str`Operation type`, + required: true, + choices: Object.values(LimitOperationType).map((op) => { + return { + value: op, + label: labelForOperationType(op, i18n), + }; + }), + }, + { + id: "threshold" as UIHandlerId, + type: "amount", + required: true, + label: i18n.str`Threshold`, + currency, + }, + { + id: "timeframe" as UIHandlerId, + type: "duration", + required: true, + label: i18n.str`Timeframe`, + }, + { + id: "exposed" as UIHandlerId, + type: "toggle", + label: i18n.str`Exposed`, + help: i18n.str`Is the customer aware of this limit?`, + }, + measureArrayField(i18n, mi), + ], +});