[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.