gnunet-svn
[Top][All Lists]
Advanced

[Date Prev][Date Next][Thread Prev][Thread Next][Date Index][Thread Index]

[taler-typescript-core] 02/04: new rules wizard


From: gnunet
Subject: [taler-typescript-core] 02/04: new rules wizard
Date: Mon, 20 Jan 2025 06:20:33 +0100

This is an automated email from the git hooks/post-receive script.

sebasjm pushed a commit to branch master
in repository taler-typescript-core.

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

    new rules wizard
---
 packages/aml-backoffice-ui/src/Routing.tsx         |   2 +-
 .../src/hooks/decision-request.ts                  |  82 +-
 .../src/pages/AmlDecisionRequestWizard.tsx         | 947 ---------------------
 .../aml-backoffice-ui/src/pages/CaseDetails.tsx    | 287 ++++---
 .../pages/decision/AmlDecisionRequestWizard.tsx    | 298 +++++++
 .../src/pages/decision/Events.tsx                  | 208 +++++
 .../src/pages/decision/Justification.tsx           |  14 +
 .../src/pages/decision/Measures.tsx                | 121 +++
 .../src/pages/decision/Properties.tsx              | 189 ++++
 .../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
index 148ece344..1358eda50 100644
--- 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
index f512e8359..9b67b5654 100644
--- 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
deleted file mode 100644
index c5037af9b..000000000
--- a/packages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx
+++ /dev/null
@@ -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
index cc20f36e3..dd5aea232 100644
--- 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
new file mode 100644
index 000000000..3d6f8a1b4
--- /dev/null
+++ 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
new file mode 100644
index 000000000..e65586a77
--- /dev/null
+++ 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
new file mode 100644
index 000000000..32c7110b2
--- /dev/null
+++ 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
new file mode 100644
index 000000000..1ef4d8859
--- /dev/null
+++ 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
new file mode 100644
index 000000000..ef685e857
--- /dev/null
+++ 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
new file mode 100644
index 000000000..db1653e02
--- /dev/null
+++ 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),
+  ],
+});

-- 
To stop receiving notification emails like this one, please contact
gnunet@gnunet.org.



reply via email to

[Prev in Thread] Current Thread [Next in Thread]