taler-typescript-core

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

commit 5f0106be6bbb4fe9d3730646933eed36c26ca94a
parent d89f0bf6322a8b4540a633c0760cecbfa24e6355
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue,  6 May 2025 01:24:35 -0300

tops events and some fixes in VQF forms

Diffstat:
Mpackages/aml-backoffice-ui/src/hooks/server-info.ts | 130++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Dashboard.tsx | 261++++++++++++++++++++++++++-----------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Events.tsx | 84++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Properties.tsx | 17++++++++---------
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 14+++++---------
Dpackages/taler-util/src/aml/aml-events.ts | 178-------------------------------------------------------------------------------
Dpackages/taler-util/src/aml/aml-properties.ts | 161-------------------------------------------------------------------------------
Apackages/taler-util/src/aml/events.ts | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/aml/properties.ts | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-util/src/aml/reporting.ts | 387+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/index.ts | 5+++--
Mpackages/web-util/src/forms/fields/InputIsoDate.tsx | 7+++++++
Mpackages/web-util/src/forms/gana/GLS_Onboarding.ts | 17+++++++++++++++++
Mpackages/web-util/src/forms/gana/VQF_902_11_officer.ts | 3+++
Mpackages/web-util/src/forms/gana/VQF_902_1_customer.ts | 18++++++++++++++++++
Mpackages/web-util/src/forms/gana/VQF_902_1_officer.ts | 11++++++++++-
Mpackages/web-util/src/forms/gana/VQF_902_9.ts | 14+++++++++++++-
Mpackages/web-util/src/forms/gana/VQF_902_9_customer.ts | 15++++++++++++++-
Mpackages/web-util/src/forms/gana/VQF_902_9_officer.ts | 10++++++++++
20 files changed, 1190 insertions(+), 639 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/hooks/server-info.ts b/packages/aml-backoffice-ui/src/hooks/server-info.ts @@ -14,22 +14,28 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - AmlSpaDialect, - EventCounter, + AbsoluteTime, + CounterResultByEventName, + EventReporting_TOPS_calculation, + EventReporting_TOPS_queries, + EventReporting_VQF_calculation, + EventReporting_VQF_queries, + fetchTopsInfoFromServer, + fetchVqfInfoFromServer, GLS_AmlEventsName, OfficerAccount, OperationOk, opFixedSuccess, - TalerExchangeErrorsByMethod, + TalerExchangeHttpClient, TalerExchangeResultByMethod, TalerHttpError, TOPS_AmlEventsName, } from "@gnu-taler/taler-util"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import { UIHandlerId, useExchangeApiContext } from "@gnu-taler/web-util/browser"; +import { useExchangeApiContext } from "@gnu-taler/web-util/browser"; import _useSWR, { SWRHook } from "swr"; -import { Timeframe } from "../pages/Dashboard.js"; import { useOfficer } from "./officer.js"; +import { endOfYear, setYear, startOfYear } from "date-fns"; const useSWR = _useSWR as unknown as SWRHook; export function useServerMeasures() { @@ -64,19 +70,48 @@ export function useServerMeasures() { return undefined; } -export type ServerStats = { - [name: string]: EventCounter; -}; +const GLS_EVENTS: GLS_AmlEventsName[] = Object.values(GLS_AmlEventsName).map( + (c) => GLS_AmlEventsName[c], +); +const TESTING_EVENTS: TOPS_AmlEventsName[] = Object.values( + TOPS_AmlEventsName, +).map((c) => TOPS_AmlEventsName[c]); + +export function useTopsServerStatistics() { + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + + const { + unthrottledApi: { exchange: api }, + } = useExchangeApiContext(); + + async function fetcher([officer]: [OfficerAccount]) { + const final = await fetchTopsInfoFromServer(api, officer); + + return opFixedSuccess(final); + } + + const { data, error } = useSWR< + OperationOk<CounterResultByEventName<typeof EventReporting_TOPS_queries>>, + TalerHttpError + >(!session ? undefined : [session], fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); -const TOPS_EVENTS: TOPS_AmlEventsName[] = Object.values(TOPS_AmlEventsName).map(c => TOPS_AmlEventsName[c]); -const GLS_EVENTS: GLS_AmlEventsName[] = Object.values(GLS_AmlEventsName).map(c => GLS_AmlEventsName[c]); -const TESTING_EVENTS: TOPS_AmlEventsName[] = Object.values(TOPS_AmlEventsName).map(c => TOPS_AmlEventsName[c]); + if (data) return data; + if (error) return error; + return undefined; +} -export function useServerStatistics( - dialect: AmlSpaDialect, - current: Timeframe, - previous?: Timeframe, -) { +export function useVqfServerStatistics(year: number) { const officer = useOfficer(); const session = officer.state === "ready" ? officer.account : undefined; @@ -84,61 +119,22 @@ export function useServerStatistics( unthrottledApi: { exchange: api }, } = useExchangeApiContext(); - const keys = - dialect === AmlSpaDialect.GLS - ? GLS_EVENTS - : dialect === AmlSpaDialect.TOPS - ? TOPS_EVENTS - : TESTING_EVENTS; - - type Success = TalerExchangeResultByMethod<"getAmlKycStatistics">; - type Error = TalerExchangeErrorsByMethod<"getAmlKycStatistics">; - type Response = { - key: UIHandlerId; - current: Success | Error; - previous: Success | Error | undefined; - }; - - async function fetcher([officer, keys, current, previous]: [ - OfficerAccount, - UIHandlerId[], - Timeframe, - Timeframe | undefined, - ]) { - const firstQuery = await api.getAmlKycStatistics(officer, "test", { - since: current.start, - until: current.end, - }); - if (firstQuery.type === "fail") { - return firstQuery; - } - const queries = keys.map((key) => { - return Promise.all([ - api.getAmlKycStatistics(officer, key, { - since: current.start, - until: current.end, - }), - !previous - ? undefined - : api.getAmlKycStatistics(officer, key, { - since: previous.start, - until: previous.end, - }), - ]).then(([c, p]) => { - const ret: Response = { key, current: c, previous: p }; - return ret; - }); - }); - - const p = await Promise.all(queries); - - return opFixedSuccess(p); + async function fetcher([officer, year]: [OfficerAccount, number]) { + const date = setYear(new Date(), year); + const jan_1st = AbsoluteTime.fromMilliseconds(startOfYear(date).getTime()); + const dec_31st = AbsoluteTime.fromMilliseconds(endOfYear(date).getTime()); + + const final = await fetchVqfInfoFromServer(api, officer, jan_1st, dec_31st); + + return opFixedSuccess(final); } const { data, error } = useSWR< - OperationOk<Response[]> | Error, + OperationOk< + CounterResultByEventName<ReturnType<typeof EventReporting_VQF_queries>> + >, TalerHttpError - >(!session ? undefined : [session, keys, current, previous], fetcher, { + >(!session ? undefined : [session, year], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, diff --git a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx @@ -17,6 +17,8 @@ import { AbsoluteTime, AmlSpaDialect, assertUnreachable, + EventReporting_TOPS_calculation, + EventReporting_TOPS_queries, GLS_AmlEventsName, HttpStatusCode, OperationOk, @@ -42,7 +44,7 @@ import { useMemo, useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useOfficer } from "../hooks/officer.js"; import { usePreferences } from "../hooks/preferences.js"; -import { useServerStatistics } from "../hooks/server-info.js"; +import { useTopsServerStatistics } from "../hooks/server-info.js"; import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; import { Officer } from "./Officer.js"; @@ -76,14 +78,14 @@ function EventMetrics({ routeToDownloadStats: RouteDefinition; }): VNode { const { i18n, dateLocale } = useTranslationContext(); - const [metricType, setMetricType] = - useState<TalerCorebankApi.MonitorTimeframeParam>( - TalerCorebankApi.MonitorTimeframeParam.hour, - ); - const params = useMemo( - () => getTimeframesForDate(now, metricType), - [metricType], - ); + // const [metricType, setMetricType] = + // useState<TalerCorebankApi.MonitorTimeframeParam>( + // TalerCorebankApi.MonitorTimeframeParam.hour, + // ); + // const params = useMemo( + // () => getTimeframesForDate(now, metricType), + // [metricType], + // ); const [pref] = usePreferences(); const { config } = useExchangeApiContext(); @@ -92,7 +94,7 @@ function EventMetrics({ (pref.testingDialect ? undefined : config.config.aml_spa_dialect) ?? AmlSpaDialect.TESTING; - const resp = useServerStatistics(dialect, params.current, params.previous); + const resp = useTopsServerStatistics(); if (!resp) { return <Loading />; @@ -101,79 +103,6 @@ function EventMetrics({ return <ErrorLoadingWithDebug error={resp} />; } - // the request test failed? - const failReponse = resp.type === "fail" ? resp : undefined; - - // request test good, but did one of the event metric request failed? - const oneOftheRequestFailed = - resp.type === "ok" - ? resp.body.find( - (r) => - r.current.type === "fail" || - (r.previous && r.previous.type === "fail"), - ) - : undefined; - - // collect error on a single variable - const error = failReponse - ? failReponse - : oneOftheRequestFailed - ? oneOftheRequestFailed.current.type === "fail" - ? oneOftheRequestFailed.current - : (oneOftheRequestFailed.previous as TalerExchangeErrorsByMethod<"getAmlKycStatistics">) - : undefined; - - //check - if (error) { - switch (error.case) { - case HttpStatusCode.Forbidden: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - This account signature is invalid, contact administrator or - create a new one. - </i18n.Translate> - </Attention> - <Officer /> - </Fragment> - ); - case HttpStatusCode.NotFound: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML account is not known, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Officer /> - </Fragment> - ); - case HttpStatusCode.Conflict: - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Operation denied`}> - <i18n.Translate> - The designated AML account is not enabled, contact administrator - or create a new one. - </i18n.Translate> - </Attention> - <Officer /> - </Fragment> - ); - default: - assertUnreachable(error.case); - } - } - const result = resp as OperationOk< - { - key: UIHandlerId; - current: TalerExchangeResultByMethod<"getAmlKycStatistics">; - previous: TalerExchangeResultByMethod<"getAmlKycStatistics"> | undefined; - }[] - >; - return ( <div class="px-4 mt-4"> <div class="sm:flex sm:items-center mb-4"> @@ -184,9 +113,9 @@ function EventMetrics({ </div> </div> - <SelectTimeframe timeframe={metricType} setTimeframe={setMetricType} /> + {/* <SelectTimeframe timeframe={metricType} setTimeframe={setMetricType} /> */} - <div class="w-full flex justify-between"> + {/* <div class="w-full flex justify-between"> <h1 class="text-base text-gray-900 mt-5"> {i18n.str`Events from ${getDateStringForTimeframe( params.current.start, @@ -198,14 +127,12 @@ function EventMetrics({ dateLocale, )}`} </h1> - </div> + </div> */} <dl class="mt-5 grid grid-cols-1 md:grid-cols-4 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0"> - {result.body.map((ev) => { - const label = labelForEvent(ev.key, i18n, dialect); - const desc = descriptionForEvent(ev.key, i18n, dialect); - if (ev.current.type === "fail") return undefined; - if (ev.previous?.type === "fail") return undefined; + {Object.entries(resp.body).map(([name, number]) => { + const label = labelForEvent(name, i18n, dialect); + const desc = descriptionForEvent(name, i18n, dialect); return ( <div class="px-4 py-5 sm:p-6"> <dt class="text-base font-normal text-gray-900"> @@ -214,15 +141,13 @@ function EventMetrics({ <div class="text-xs text-gray-500">{desc}</div> )} </dt> - <MetricValueNumber - current={ev.current.body.counter} - previous={ev.previous?.body.counter} - /> + <MetricValueNumber current={number} previous={undefined} /> </div> ); })} </dl> - <div class="flex justify-end mt-4"> + + {/* <div class="flex justify-end mt-4"> <a href={routeToDownloadStats.url({})} name="download stats" @@ -230,65 +155,57 @@ function EventMetrics({ > <i18n.Translate>Download stats as CSV</i18n.Translate> </a> - </div> + </div> */} </div> ); } function labelForEvent( - name: UIHandlerId, + name: string, i18n: InternationalizationAPI, dialect: AmlSpaDialect, ) { switch (dialect) { case AmlSpaDialect.TOPS: - return labelForEvent_tops(name as TOPS_AmlEventsName, i18n); + return labelForEvent_tops(name as any, i18n); case AmlSpaDialect.GLS: return labelForEvent_gls(name as GLS_AmlEventsName, i18n); case AmlSpaDialect.TESTING: - return labelForEvent_tops(name as TOPS_AmlEventsName, i18n); + return labelForEvent_tops(name as any, i18n); default: { assertUnreachable(dialect); } } } function labelForEvent_tops( - name: TOPS_AmlEventsName, + name: keyof ReturnType<typeof EventReporting_TOPS_calculation>, i18n: InternationalizationAPI, ) { switch (name) { - case TOPS_AmlEventsName.ACCOUNT_OPENED: - return i18n.str`Account opened`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED: - return i18n.str`Account closed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_HIGH_RISK: - return i18n.str`High risk account incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_HIGH_RISK: - return i18n.str`High risk account removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_DOMESTIC_PEP: - return i18n.str`Account from domestic PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_DOMESTIC_PEP: - return i18n.str`Account from domestic PEP removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_FOREIGN_PEP: - return i18n.str`Account from foreign PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_FOREIGN_PEP: - return i18n.str`Account from foreign PEP removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_HR_COUNTRY: - return i18n.str`Account from high-risk country incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_HR_COUNTRY: - return i18n.str`Account from high-risk country removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_INT_ORG_PEP: - return i18n.str`Account from domestic PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_INT_ORG_PEP: - return i18n.str`Account from domestic PEP removed`; - case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE: - return i18n.str`MROS reported by obligation`; - case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED: - return i18n.str`MROS reported by right`; - case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_COMPLETED: - return i18n.str`Investigations after Art 6 Gwg completed`; - case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_STARTED: - return i18n.str`Investigations after Art 6 Gwg started`; + case "accounts_opened": + return i18n.str`Accounts open`; + + case "new_gwg_files_last_year": + return i18n.str`New files last year`; + + case "gwg_files_closed_last_year": + return i18n.str`Closes files last year`; + + case "mros_reports_art9_last_year": + return i18n.str`MROS substantaiated`; + + case "mros_reports_art305_last_year": + return i18n.str`MROS simple`; + + case "gwg_files_high_risk": + return i18n.str`High risk files`; + + case "gwg_files_pep": + return i18n.str`PEP files`; + + case "accounts_involed_in_proceedings_last_year": + return i18n.str`Under investiation`; + default: { assertUnreachable(name); } @@ -310,61 +227,52 @@ function labelForEvent_gls( } function descriptionForEvent( - name: UIHandlerId, + name: string, i18n: InternationalizationAPI, - dialect: AmlSpaDialect + dialect: AmlSpaDialect, ): TranslatedString | undefined { switch (dialect) { case AmlSpaDialect.TOPS: - return descriptionForEvent_tops(name as TOPS_AmlEventsName, i18n); + return descriptionForEvent_tops(name as any, i18n); case AmlSpaDialect.GLS: return descriptionForEvent_gls(name as GLS_AmlEventsName, i18n); case AmlSpaDialect.TESTING: - return descriptionForEvent_tops(name as TOPS_AmlEventsName, i18n); + return descriptionForEvent_tops(name as any, i18n); default: { assertUnreachable(dialect); } } - } function descriptionForEvent_tops( - name: TOPS_AmlEventsName, + name: keyof ReturnType<typeof EventReporting_TOPS_calculation>, i18n: InternationalizationAPI, ): TranslatedString | undefined { switch (name) { - case TOPS_AmlEventsName.ACCOUNT_OPENED: - return i18n.str`Account opened`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED: - return i18n.str`Account closed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_HIGH_RISK: - return i18n.str`High risk account incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_HIGH_RISK: - return i18n.str`High risk account removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_DOMESTIC_PEP: - return i18n.str`Account from domestic PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_DOMESTIC_PEP: - return i18n.str`Account from domestic PEP removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_FOREIGN_PEP: - return i18n.str`Account from foreign PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_FOREIGN_PEP: - return i18n.str`Account from foreign PEP removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_HR_COUNTRY: - return i18n.str`Account from high-risk country incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_HR_COUNTRY: - return i18n.str`Account from high-risk country removed`; - case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE: - return i18n.str`MROS reported by obligation`; - case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED: - return i18n.str`MROS reported by right`; - case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_COMPLETED: - return i18n.str`Investigations after Art 6 Gwg completed`; - case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_STARTED: - return i18n.str`Investigations after Art 6 Gwg started`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_INT_ORG_PEP: - return i18n.str`Investigations after Art 6 Gwg opened`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_INT_ORG_PEP: - return i18n.str`Investigations after Art 6 Gwg closed`; + case "accounts_opened": + return i18n.str`Number of accounts that are opened`; + + case "new_gwg_files_last_year": + return i18n.str`Number of new GwG files in the last year`; + + case "gwg_files_closed_last_year": + return i18n.str`Number of GwG files closed in the last year`; + + case "mros_reports_art9_last_year": + return i18n.str`Number of MROS reports based on Art 9 Abs. 1 GwG (per year)`; + + case "mros_reports_art305_last_year": + return i18n.str`Number of MROS reports based on Art 305ter Abs. 2 StGB (per year)`; + + case "gwg_files_high_risk": + return i18n.str`Number of GwG files of high-risk customers`; + + case "gwg_files_pep": + return i18n.str`Number of GwG files managed with “increased risk” due to PEP status`; + + case "accounts_involed_in_proceedings_last_year": + return i18n.str`Number of customers involved in proceedings for which Art 6 GwG did apply`; + default: { assertUnreachable(name); } @@ -386,8 +294,6 @@ function descriptionForEvent_gls( } } - - function MetricValueNumber({ current, previous, @@ -418,10 +324,11 @@ function MetricValueNumber({ </div> <div class="flex flex-col"> <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600"> - <small class="ml-2 text-sm font-medium text-gray-500"> - <i18n.Translate>previous</i18n.Translate>{" "} - {!previous ? 0 : previous} - </small> + {previous === undefined ? undefined : ( + <small class="ml-2 text-sm font-medium text-gray-500"> + <i18n.Translate>previous</i18n.Translate> {previous} + </small> + )} </div> {!!rate && ( <span diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx @@ -1,8 +1,8 @@ import { - AML_EVENTS_INFO, AmlDecision, AmlSpaDialect, assertUnreachable, + EventsDerivation_TOPS, GLS_AmlEventsName, HttpStatusCode, MeasureInformation, @@ -206,38 +206,56 @@ function labelForEvent_tops( i18n: InternationalizationAPI, ) { switch (event) { - case TOPS_AmlEventsName.ACCOUNT_OPENED: + case TOPS_AmlEventsName.INCR_ACCOUNT_OPEN: + case TOPS_AmlEventsName.DECR_ACCOUNT_OPEN: + case TOPS_AmlEventsName.INCR_HIGH_RISK_CUSTOMER: + case TOPS_AmlEventsName.DECR_HIGH_RISK_CUSTOMER: + case TOPS_AmlEventsName.INCR_HIGH_RISK_COUNTRY: + case TOPS_AmlEventsName.DECR_HIGH_RISK_COUNTRY: + case TOPS_AmlEventsName.INCR_PEP: + case TOPS_AmlEventsName.DECR_PEP: + case TOPS_AmlEventsName.INCR_PEP_FOREIGN: + case TOPS_AmlEventsName.DECR_PEP_FOREIGN: + case TOPS_AmlEventsName.INCR_PEP_DOMESTIC: + case TOPS_AmlEventsName.DECR_PEP_DOMESTIC: + case TOPS_AmlEventsName.INCR_PEP_INTERNATIONAL_ORGANIZATION: + case TOPS_AmlEventsName.DECR_PEP_INTERNATIONAL_ORGANIZATION: + case TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SIMPLE: + case TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SUBSTANTIATED: + case TOPS_AmlEventsName.INCR_INVESTIGATION_CONCLUDED: + case TOPS_AmlEventsName.DECR_INVESTIGATION_CONCLUDED: + // case TOPS_AmlEventsName.ACCOUNT_OPENED: return i18n.str`Account opened`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED: - return i18n.str`Account closed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_HIGH_RISK: - return i18n.str`High risk account incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_HIGH_RISK: - return i18n.str`High risk account removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_DOMESTIC_PEP: - return i18n.str`Account from dometic PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_DOMESTIC_PEP: - return i18n.str`Account from dometic PEP removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_FOREIGN_PEP: - return i18n.str`Account from foreign PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_FOREIGN_PEP: - return i18n.str`Account from foreign PEP removed`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_HR_COUNTRY: - return i18n.str`Account from high-risk country incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_HR_COUNTRY: - return i18n.str`Account from high-risk country removed`; - case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE: - return i18n.str`MROS reported by obligation`; - case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED: - return i18n.str`MROS reported by right`; - case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_COMPLETED: - return i18n.str`Investigations after Art 6 Gwg completed`; - case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_STARTED: - return i18n.str`Investigations after Art 6 Gwg started`; - case TOPS_AmlEventsName.ACCOUNT_OPENED_INT_ORG_PEP: - return i18n.str`Account from international organization PEP incorporated`; - case TOPS_AmlEventsName.ACCOUNT_CLOSED_INT_ORG_PEP: - return i18n.str`Account from international organization PEP removed`; + // case TOPS_AmlEventsName.ACCOUNT_CLOSED: + // return i18n.str`Account closed`; + // case TOPS_AmlEventsName.ACCOUNT_OPENED_HIGH_RISK: + // return i18n.str`High risk account incorporated`; + // case TOPS_AmlEventsName.ACCOUNT_CLOSED_HIGH_RISK: + // return i18n.str`High risk account removed`; + // case TOPS_AmlEventsName.ACCOUNT_OPENED_DOMESTIC_PEP: + // return i18n.str`Account from dometic PEP incorporated`; + // case TOPS_AmlEventsName.ACCOUNT_CLOSED_DOMESTIC_PEP: + // return i18n.str`Account from dometic PEP removed`; + // case TOPS_AmlEventsName.ACCOUNT_OPENED_FOREIGN_PEP: + // return i18n.str`Account from foreign PEP incorporated`; + // case TOPS_AmlEventsName.ACCOUNT_CLOSED_FOREIGN_PEP: + // return i18n.str`Account from foreign PEP removed`; + // case TOPS_AmlEventsName.ACCOUNT_OPENED_HR_COUNTRY: + // return i18n.str`Account from high-risk country incorporated`; + // case TOPS_AmlEventsName.ACCOUNT_CLOSED_HR_COUNTRY: + // return i18n.str`Account from high-risk country removed`; + // case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE: + // return i18n.str`MROS reported by obligation`; + // case TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED: + // return i18n.str`MROS reported by right`; + // case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_COMPLETED: + // return i18n.str`Investigations after Art 6 Gwg completed`; + // case TOPS_AmlEventsName.ACCOUNT_INVESTIGATION_STARTED: + // return i18n.str`Investigations after Art 6 Gwg started`; + // case TOPS_AmlEventsName.ACCOUNT_OPENED_INT_ORG_PEP: + // return i18n.str`Account from international organization PEP incorporated`; + // case TOPS_AmlEventsName.ACCOUNT_CLOSED_INT_ORG_PEP: + // return i18n.str`Account from international organization PEP removed`; default: { assertUnreachable(event); } @@ -266,7 +284,7 @@ function calculateEventsBasedOnState( dialect: AmlSpaDialect, ): Events { const init: Events = { triggered: [], rest: [] }; - return Object.entries(AML_EVENTS_INFO).reduce((prev, [name, info]) => { + return Object.entries(EventsDerivation_TOPS).reduce((prev, [name, info]) => { if ( info.shouldBeTriggered( currentState?.limits?.rules, diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx @@ -6,12 +6,11 @@ import { GLS_AML_PROPERTIES, HttpStatusCode, LegitimizationRuleSet, - PropertiesMapInfo, + PropertiesDerivation_TOPS, + PropertiesDerivationFunctionByPropertyName, TalerAmlProperties, TalerError, - TalerFormAttributes, - TOPS_AccountProperties, - TOPS_AML_PROPERTIES, + TOPS_AccountProperties } from "@gnu-taler/taler-util"; import { FormDesign, @@ -26,13 +25,13 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { DecisionRequest, useCurrentDecisionRequest, } from "../../hooks/decision-request.js"; import { useAccountActiveDecision } from "../../hooks/decisions.js"; import { usePreferences } from "../../hooks/preferences.js"; -import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; import { DEFAULT_LIMITS_WHEN_NEW_ACCOUNT } from "./Rules.js"; /** @@ -304,14 +303,14 @@ function calculatePropertiesBasedOnState( function mergeProperties<T extends string>( iv: Result, list: readonly T[], - info: PropertiesMapInfo<T>, + info: PropertiesDerivationFunctionByPropertyName<T>, ): Result { return list.reduce((result, prop) => { result[prop] = info[prop].deriveProperty( FORM_ID, + newNewAttributes, currentLimits, state, - newNewAttributes, ); return result; }, iv); @@ -322,7 +321,7 @@ function calculatePropertiesBasedOnState( return mergeProperties( initial, TOPS_AccountProperties, - TOPS_AML_PROPERTIES, + PropertiesDerivation_TOPS, ); } case AmlSpaDialect.GLS: { @@ -336,7 +335,7 @@ function calculatePropertiesBasedOnState( return mergeProperties( initial, TOPS_AccountProperties, - TOPS_AML_PROPERTIES, + PropertiesDerivation_TOPS, ); } } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -450,6 +450,22 @@ function UpdateRulesForm({ > <i18n.Translate>Premium</i18n.Translate> </button> + <button + onClick={() => { + updateRequestField("rules", E_COMMERCE(config.currency, isWallet)); + }} + 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>E-commerce</i18n.Translate> + </button> + <button + onClick={() => { + updateRequestField("rules", POINT_OF_SALE(config.currency, isWallet)); + }} + 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>Point-of-sale</i18n.Translate> + </button> <h2 class="mt-4 mb-2"> <i18n.Translate>On expiration behavior</i18n.Translate> </h2> @@ -878,3 +894,123 @@ const FREEZE_PLAN: TemplateRulesFunction = ( threshold: Amounts.stringify(Amounts.zeroOfCurrency(currency)), timeframe: Duration.toTalerProtocolDuration(Duration.getForever()), })); + +const POINT_OF_SALE: TemplateRulesFunction = (currency, isWallet) => + [ + { + display_priority: 1, + measures: ["VERBOTEN"], + operation_type: LimitOperationType.withdraw, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 0, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + { + display_priority: 1, + measures: ["VERBOTEN"], + operation_type: LimitOperationType.merge, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 25 * 1000, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + { + display_priority: 1, + measures: ["preserve-investigate"], + operation_type: LimitOperationType.deposit, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 25 * 1000, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + { + display_priority: 1, + measures: ["preserve-investigate"], + operation_type: LimitOperationType.aggregate, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 25 * 1000, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + ].filter((r) => { + return isWallet + ? WALLET_RULES.includes(r.operation_type) + : BANK_RULES.includes(r.operation_type); + }); + + const E_COMMERCE: TemplateRulesFunction = (currency, isWallet) => + [ + { + display_priority: 1, + measures: ["VERBOTEN"], + operation_type: LimitOperationType.withdraw, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 0, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + { + display_priority: 1, + measures: ["VERBOTEN"], + operation_type: LimitOperationType.merge, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 0, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + { + display_priority: 1, + measures: ["preserve-investigate"], + operation_type: LimitOperationType.deposit, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 25 * 1000, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + { + display_priority: 1, + measures: ["preserve-investigate"], + operation_type: LimitOperationType.aggregate, + threshold: Amounts.stringify({ + currency, + fraction: 0, + value: 25 * 1000, + }), + timeframe: Duration.toTalerProtocolDuration( + Duration.fromSpec({ months: 1 }), + ), + }, + ].filter((r) => { + return isWallet + ? WALLET_RULES.includes(r.operation_type) + : BANK_RULES.includes(r.operation_type); + }); +\ No newline at end of file diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -1,17 +1,14 @@ import { AbsoluteTime, - AML_EVENTS_INFO, AmlDecisionRequest, assertUnreachable, HttpStatusCode, - opEmptySuccess, opFixedSuccess, parsePaytoUri, PaytoString, stringifyPaytoUri, - succeedOrThrow, TalerError, - TOPS_AmlEventsName, + TOPS_AmlEventsName } from "@gnu-taler/taler-util"; import { Attention, @@ -22,14 +19,14 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; import { DECISION_REQUEST_EMPTY, useCurrentDecisionRequest, } from "../../hooks/decision-request.js"; import { useOfficer } from "../../hooks/officer.js"; import { - useServerMeasures, - useServerStatistics, + useServerMeasures } from "../../hooks/server-info.js"; import { computeAvailableMesaures, @@ -46,7 +43,6 @@ import { isRulesCompleted, WizardSteps, } from "./AmlDecisionRequestWizard.js"; -import { useState } from "preact/hooks"; /** * Mark for further investigation and explain decision @@ -96,10 +92,10 @@ export function Summary({ const MROS_REPORT_COMPLETED = !decision.triggering_events ? false : decision.triggering_events.includes( - TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE, + TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SIMPLE, ) || decision.triggering_events.includes( - TOPS_AmlEventsName.ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED, + TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SUBSTANTIATED, ); const CANT_SUBMIT = diff --git a/packages/taler-util/src/aml/aml-events.ts b/packages/taler-util/src/aml/aml-events.ts @@ -1,178 +0,0 @@ -import { Amounts } from "../amounts.js"; -import { LimitOperationType } from "../types-taler-exchange.js"; -import { - AccountProperties, - KycRule, - LegitimizationRuleSet, -} from "../types-taler-kyc-aml.js"; - -/** - * List of events triggered by TOPS - */ -export enum TOPS_AmlEventsName { - ACCOUNT_OPENED = "ACCOUNT_OPENED", - ACCOUNT_OPENED_HIGH_RISK = "ACCOUNT_OPENED_HIGH_RISK", - ACCOUNT_OPENED_DOMESTIC_PEP = "ACCOUNT_OPENED_DOMESTIC_PEP", - ACCOUNT_OPENED_FOREIGN_PEP = "ACCOUNT_OPENED_FOREIGN_PEP", - ACCOUNT_OPENED_INT_ORG_PEP = "ACCOUNT_OPENED_INT_ORG_PEP", - ACCOUNT_OPENED_HR_COUNTRY = "ACCOUNT_OPENED_HR_COUNTRY", - - ACCOUNT_CLOSED = "ACCOUNT_CLOSED", - ACCOUNT_CLOSED_HIGH_RISK = "ACCOUNT_CLOSED_HIGH_RISK", - ACCOUNT_CLOSED_DOMESTIC_PEP = "ACCOUNT_CLOSED_DOMESTIC_PEP", - ACCOUNT_CLOSED_FOREIGN_PEP = "ACCOUNT_CLOSED_FOREIGN_PEP", - ACCOUNT_CLOSED_INT_ORG_PEP = "ACCOUNT_CLOSED_INT_ORG_PEP", - ACCOUNT_CLOSED_HR_COUNTRY = "ACCOUNT_CLOSED_HR_COUNTRY", - - ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE = "ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE", - ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED = "ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED", - ACCOUNT_INVESTIGATION_STARTED = "ACCOUNT_INVESTIGATION_STARTED", - ACCOUNT_INVESTIGATION_COMPLETED = "ACCOUNT_INVESTIGATION_COMPLETED", -} - -/** - * List of events triggered by GLS - */ -export enum GLS_AmlEventsName { - ACCOUNT_OPENED = "ACCOUNT_OPENED", - ACCOUNT_CLOSED = "ACCOUNT_CLOSED", -} - -export type EventMapInfo<T> = { - [name in keyof T]: { - /** - * Based on the current properties and next properties, - * the current account limits and new attributes of the account - * calculate if the event should be triggered. - * - * return false if there is no enough information to decide. - * - * @param prevLimits current active decision limits, undefined if this account has no active decision yet - * @param nextLimits limits of the decision to be made, undefined if not yet decided - * @param prevState current active decision properties, undefined if this account has no active decision yet - * @param nextState new properties of the account defined by the decision, undefined if not yet decided - * @param newAttributes new information added by this decision - * @returns - */ - shouldBeTriggered: ( - prevLimits: KycRule[] | undefined, - nextLimits: KycRule[] | undefined, - prevState: AccountProperties | undefined, - nextState: AccountProperties | undefined, - newAttributes: Record<string, unknown>, - ) => boolean; - }; -}; - -function isAllowToMakeDeposits(limits: KycRule[]) { - const depositLimits = limits.filter( - (r) => r.operation_type === LimitOperationType.deposit, - ); - // no deposit limits - if (!depositLimits.length) return true; - const zero = depositLimits.find((d) => Amounts.isZero(d.threshold)); - // there is a rule that prohibit deposit - if (zero) return false; - // the cusomter can at least make some deposits - return true; -} - -export const AML_EVENTS_INFO: EventMapInfo<typeof TOPS_AmlEventsName> = { - ACCOUNT_OPENED: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - //FIXME: implement the correct rule, this is for testing - if (!nL) return false; - return pL === undefined - ? !isAllowToMakeDeposits(nL) - : isAllowToMakeDeposits(pL) && !isAllowToMakeDeposits(nL); - }, - }, - ACCOUNT_CLOSED: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_CLOSED_INT_ORG_PEP: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_OPENED_HIGH_RISK: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_CLOSED_HIGH_RISK: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_OPENED_DOMESTIC_PEP: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_CLOSED_DOMESTIC_PEP: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_OPENED_FOREIGN_PEP: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_CLOSED_FOREIGN_PEP: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_OPENED_HR_COUNTRY: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_CLOSED_HR_COUNTRY: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_INVESTIGATION_COMPLETED: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_INVESTIGATION_STARTED: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_MROS_REPORTED_SUSPICION_SIMPLE: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_MROS_REPORTED_SUSPICION_SUBSTANTIATED: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_OPENED_INT_ORG_PEP: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, -}; - -export const GLS_AML_EVENTS: EventMapInfo<typeof GLS_AmlEventsName> = { - ACCOUNT_OPENED: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, - ACCOUNT_CLOSED: { - shouldBeTriggered(pL, nL, pS, nS, attr) { - return false; - }, - }, -}; diff --git a/packages/taler-util/src/aml/aml-properties.ts b/packages/taler-util/src/aml/aml-properties.ts @@ -1,161 +0,0 @@ -import { TalerAmlProperties } from "../taler-account-properties.js"; -import { TalerFormAttributes } from "../taler-form-attributes.js"; -import { - AccountProperties, - LegitimizationRuleSet, -} from "../types-taler-kyc-aml.js"; - -/** - * List of account properties required by TOPS - */ - -export const TOPS_AccountProperties = [ - TalerAmlProperties.FILE_NOTE, - TalerAmlProperties.CUSTOMER_LABEL, - TalerAmlProperties.ACCOUNT_OPEN, - TalerAmlProperties.PEP_DOMESTIC, - TalerAmlProperties.PEP_FOREIGN, - TalerAmlProperties.PEP_INTERNATIONAL_ORGANIZATION, - TalerAmlProperties.HIGH_RISK_CUSTOMER, - TalerAmlProperties.HIGH_RISK_COUNTRY, - TalerAmlProperties.ACCOUNT_IDLE, - TalerAmlProperties.INVESTIGATION_TRIGGER, - TalerAmlProperties.INVESTIGATION_STATE, - TalerAmlProperties.SANCTION_LIST_BEST_MATCH, - TalerAmlProperties.SANCTION_LIST_RATING, - TalerAmlProperties.SANCTION_LIST_CONFIDENCE, - TalerAmlProperties.SANCTION_LIST_SUPPRESS, -] as const; - -/** - * List of account properties required by GLS - */ -export const GLS_AccountProperties = [TalerAmlProperties.FILE_NOTE] as const; - -export type PropertiesMapInfo<T extends string> = { - [name in T]: { - /** - * Based on all the current properties, the current account limits and - * new attributes of the account calculate if the property should - * change. The value "undefined" means no change. - * - * @param limits - * @param state - * @param newAttributes - * @returns - */ - deriveProperty: ( - formId: string, - limits: LegitimizationRuleSet, - state: AccountProperties, - newAttributes: Record<keyof typeof TalerFormAttributes, unknown>, - ) => string | boolean | undefined; - }; -}; - -export const TOPS_AML_PROPERTIES: PropertiesMapInfo< - (typeof TOPS_AccountProperties)[number] -> = { - ACCOUNT_OPEN: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - PEP_DOMESTIC: { - deriveProperty(formId, limits, state, attributes) { - if (formId === "vqf_902_4") { - return !!attributes[TalerFormAttributes.PEP_DOMESTIC]; - } - return undefined; - }, - }, - PEP_FOREIGN: { - deriveProperty(formId, limits, state, attributes) { - if (formId === "vqf_902_4") { - return !!attributes[TalerFormAttributes.PEP_FOREIGN]; - } - return undefined; - }, - }, - PEP_INTERNATIONAL_ORGANIZATION: { - deriveProperty(formId, limits, state, attributes) { - if (formId === "vqf_902_4") { - return !!attributes[TalerFormAttributes.PEP_INTERNATIONAL_ORGANIZATION]; - } - return undefined; - }, - }, - HIGH_RISK_CUSTOMER: { - deriveProperty(formId, limits, state, attributes) { - return ( - attributes[TalerFormAttributes.RISK_CLASSIFICATION_LEVEL] === - "HIGH_RISK" - ); - }, - }, - HIGH_RISK_COUNTRY: { - deriveProperty(formId, limits, state, attributes) { - return ( - attributes[TalerFormAttributes.COUNTRY_RISK_NATIONALITY_LEVEL] === - "HIGH" - ); - }, - }, - ACCOUNT_IDLE: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - CUSTOMER_LABEL: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - FILE_NOTE: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - INVESTIGATION_STATE: { - deriveProperty(formId, limits, state, attributes) { - // https://bugs.gnunet.org/view.php?id=9677 - - return undefined; - }, - }, - INVESTIGATION_TRIGGER: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - SANCTION_LIST_BEST_MATCH: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - SANCTION_LIST_CONFIDENCE: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - SANCTION_LIST_RATING: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, - SANCTION_LIST_SUPPRESS: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, -}; - -export const GLS_AML_PROPERTIES: PropertiesMapInfo< - (typeof GLS_AccountProperties)[number] -> = { - FILE_NOTE: { - deriveProperty(formId, limits, state, attributes) { - return undefined; - }, - }, -}; diff --git a/packages/taler-util/src/aml/events.ts b/packages/taler-util/src/aml/events.ts @@ -0,0 +1,196 @@ +import { Amounts } from "../amounts.js"; +import { LimitOperationType } from "../types-taler-exchange.js"; +import { + AccountProperties, + KycRule, + LegitimizationRuleSet, +} from "../types-taler-kyc-aml.js"; + +/** + * List of events triggered by TOPS + */ +export enum TOPS_AmlEventsName { + INCR_ACCOUNT_OPEN = "INCR_ACCOUNT_OPEN", + DECR_ACCOUNT_OPEN = "DECR_ACCOUNT_OPEN", + INCR_HIGH_RISK_CUSTOMER = "INCR_HIGH_RISK_CUSTOMER", + DECR_HIGH_RISK_CUSTOMER = "DECR_HIGH_RISK_CUSTOMER", + INCR_HIGH_RISK_COUNTRY = "INCR_HIGH_RISK_COUNTRY", + DECR_HIGH_RISK_COUNTRY = "DECR_HIGH_RISK_COUNTRY", + INCR_PEP = "INCR_PEP", + DECR_PEP = "DECR_PEP", + INCR_PEP_FOREIGN = "INCR_PEP_FOREIGN", + DECR_PEP_FOREIGN = "DECR_PEP_FOREIGN", + INCR_PEP_DOMESTIC = "INCR_PEP_DOMESTIC", + DECR_PEP_DOMESTIC = "DECR_PEP_DOMESTIC", + INCR_PEP_INTERNATIONAL_ORGANIZATION = "INCR_PEP_INTERNATIONAL_ORGANIZATION", + DECR_PEP_INTERNATIONAL_ORGANIZATION = "DECR_PEP_INTERNATIONAL_ORGANIZATION", + MROS_REPORTED_SUSPICION_SIMPLE = "MROS_REPORTED_SUSPICION_SIMPLE", + MROS_REPORTED_SUSPICION_SUBSTANTIATED = "MROS_REPORTED_SUSPICION_SUBSTANTIATED", + INCR_INVESTIGATION_CONCLUDED = "INCR_INVESTIGATION_CONCLUDED", + DECR_INVESTIGATION_CONCLUDED = "DECR_INVESTIGATION_CONCLUDED", +} + +/** + * List of events triggered by GLS + */ +export enum GLS_AmlEventsName { + ACCOUNT_OPENED = "ACCOUNT_OPENED", + ACCOUNT_CLOSED = "ACCOUNT_CLOSED", +} + +export type EventMapInfo<T> = { + [name in keyof T]: { + /** + * Based on the current properties and next properties, + * the current account limits and new attributes of the account + * calculate if the event should be triggered. + * + * return false if there is no enough information to decide. + * + * @param prevLimits current active decision limits, undefined if this account has no active decision yet + * @param nextLimits limits of the decision to be made, undefined if not yet decided + * @param prevState current active decision properties, undefined if this account has no active decision yet + * @param nextState new properties of the account defined by the decision, undefined if not yet decided + * @param newAttributes new information added by this decision + * @returns + */ + shouldBeTriggered: ( + prevLimits: KycRule[] | undefined, + nextLimits: KycRule[] | undefined, + prevState: AccountProperties | undefined, + nextState: AccountProperties | undefined, + newAttributes: Record<string, unknown>, + ) => boolean; + }; +}; + +function isAllowToMakeDeposits(limits: KycRule[]) { + const depositLimits = limits.filter( + (r) => r.operation_type === LimitOperationType.deposit, + ); + // no deposit limits + if (!depositLimits.length) return true; + const zero = depositLimits.find((d) => Amounts.isZero(d.threshold)); + // there is a rule that prohibit deposit + if (zero) return false; + // the cusomter can at least make some deposits + return true; +} + +/** + * Calculate if an event should be triggered for TOPS decisions + */ +export const EventsDerivation_TOPS: EventMapInfo<typeof TOPS_AmlEventsName> = { + // ACCOUNT_OPENED: { + // shouldBeTriggered(pL, nL, pS, nS, attr) { + // //FIXME: implement the correct rule, this is for testing + // if (!nL) return false; + // return pL === undefined + // ? !isAllowToMakeDeposits(nL) + // : isAllowToMakeDeposits(pL) && !isAllowToMakeDeposits(nL); + // }, + // }, + INCR_ACCOUNT_OPEN: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_ACCOUNT_OPEN: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + INCR_HIGH_RISK_CUSTOMER: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_HIGH_RISK_CUSTOMER: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + INCR_HIGH_RISK_COUNTRY: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_HIGH_RISK_COUNTRY: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + INCR_PEP: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_PEP: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + INCR_PEP_FOREIGN: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_PEP_FOREIGN: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + INCR_PEP_DOMESTIC: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_PEP_DOMESTIC: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + INCR_PEP_INTERNATIONAL_ORGANIZATION: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_PEP_INTERNATIONAL_ORGANIZATION: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + MROS_REPORTED_SUSPICION_SIMPLE: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + MROS_REPORTED_SUSPICION_SUBSTANTIATED: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + INCR_INVESTIGATION_CONCLUDED: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + DECR_INVESTIGATION_CONCLUDED: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, +}; + +export const GLS_AML_EVENTS: EventMapInfo<typeof GLS_AmlEventsName> = { + ACCOUNT_OPENED: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, + ACCOUNT_CLOSED: { + shouldBeTriggered(pL, nL, pS, nS, attr) { + return false; + }, + }, +}; diff --git a/packages/taler-util/src/aml/properties.ts b/packages/taler-util/src/aml/properties.ts @@ -0,0 +1,164 @@ +import { TalerAmlProperties } from "../taler-account-properties.js"; +import { TalerFormAttributes } from "../taler-form-attributes.js"; +import { + AccountProperties, + LegitimizationRuleSet, +} from "../types-taler-kyc-aml.js"; + +/** + * List of account properties required by TOPS + */ +export const TOPS_AccountProperties = [ + TalerAmlProperties.FILE_NOTE, + TalerAmlProperties.CUSTOMER_LABEL, + TalerAmlProperties.ACCOUNT_OPEN, + TalerAmlProperties.PEP_DOMESTIC, + TalerAmlProperties.PEP_FOREIGN, + TalerAmlProperties.PEP_INTERNATIONAL_ORGANIZATION, + TalerAmlProperties.HIGH_RISK_CUSTOMER, + TalerAmlProperties.HIGH_RISK_COUNTRY, + TalerAmlProperties.ACCOUNT_IDLE, + TalerAmlProperties.INVESTIGATION_TRIGGER, + TalerAmlProperties.INVESTIGATION_STATE, + TalerAmlProperties.SANCTION_LIST_BEST_MATCH, + TalerAmlProperties.SANCTION_LIST_RATING, + TalerAmlProperties.SANCTION_LIST_CONFIDENCE, + TalerAmlProperties.SANCTION_LIST_SUPPRESS, +] as const; + +/** + * List of account properties required by GLS + */ +export const GLS_AccountProperties = [TalerAmlProperties.FILE_NOTE] as const; + +export type PropertiesDerivationFunctionByPropertyName<T extends string> = { + [name in T]: { + /** + * Based on all the current properties, the current account limits and + * new attributes of the account calculate if the property should + * change. The value "undefined" means no change. + * + * @param formId the current form being filled by the officer + * @param newAttributes the values of the current form + * @param limits + * @param state the current state of the account + * @returns + */ + deriveProperty: ( + formId: string, + newAttributes: Record<keyof typeof TalerFormAttributes, unknown>, + limits: LegitimizationRuleSet, + state: AccountProperties, + ) => string | boolean | undefined; + }; +}; + +/** + * Calculate the value of the propertiy for TOPS account properties + */ +export const PropertiesDerivation_TOPS: PropertiesDerivationFunctionByPropertyName< + (typeof TOPS_AccountProperties)[number] +> = { + ACCOUNT_OPEN: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + PEP_DOMESTIC: { + deriveProperty(formId, attributes, limits, state) { + if (formId === "vqf_902_4") { + return !!attributes[TalerFormAttributes.PEP_DOMESTIC]; + } + return undefined; + }, + }, + PEP_FOREIGN: { + deriveProperty(formId, attributes, limits, state) { + if (formId === "vqf_902_4") { + return !!attributes[TalerFormAttributes.PEP_FOREIGN]; + } + return undefined; + }, + }, + PEP_INTERNATIONAL_ORGANIZATION: { + deriveProperty(formId, attributes, limits, state) { + if (formId === "vqf_902_4") { + return !!attributes[TalerFormAttributes.PEP_INTERNATIONAL_ORGANIZATION]; + } + return undefined; + }, + }, + HIGH_RISK_CUSTOMER: { + deriveProperty(formId, attributes, limits, state) { + return ( + attributes[TalerFormAttributes.RISK_CLASSIFICATION_LEVEL] === + "HIGH_RISK" + ); + }, + }, + HIGH_RISK_COUNTRY: { + deriveProperty(formId, attributes, limits, state) { + return ( + attributes[TalerFormAttributes.COUNTRY_RISK_NATIONALITY_LEVEL] === + "HIGH" + ); + }, + }, + ACCOUNT_IDLE: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + CUSTOMER_LABEL: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + FILE_NOTE: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + INVESTIGATION_STATE: { + deriveProperty(formId, attributes, limits, state) { + // https://bugs.gnunet.org/view.php?id=9677 + + return undefined; + }, + }, + INVESTIGATION_TRIGGER: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + SANCTION_LIST_BEST_MATCH: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + SANCTION_LIST_CONFIDENCE: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + SANCTION_LIST_RATING: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, + SANCTION_LIST_SUPPRESS: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, +}; + +export const GLS_AML_PROPERTIES: PropertiesDerivationFunctionByPropertyName< + (typeof GLS_AccountProperties)[number] +> = { + FILE_NOTE: { + deriveProperty(formId, attributes, limits, state) { + return undefined; + }, + }, +}; diff --git a/packages/taler-util/src/aml/reporting.ts b/packages/taler-util/src/aml/reporting.ts @@ -0,0 +1,387 @@ +import { AbsoluteTime, Duration, OfficerAccount, TalerExchangeHttpClient, TOPS_AmlEventsName } from "../index.js"; + +/** + * Define a set of parameters to make a request to the server + */ +export type EventQuery<Ev> = { + event: Ev; + start: AbsoluteTime | undefined; + end: AbsoluteTime | undefined; +}; + +/** + * Map between a name and a request parameter + */ +export type QueryModel<Ev> = { + [name: string]: EventQuery<Ev>; +}; + +/** + * All the request needed to create the Event Reporting (TOPS) + * https://docs.taler.net/deployments/tops.html#event-reporting-tops + * + * Maps a request key to request parameters + * + */ +export const EventReporting_TOPS_queries = { + // Number of accounts that are opened + accounts_open_incr: { + event: TOPS_AmlEventsName.INCR_ACCOUNT_OPEN, + start: undefined, + end: undefined, + }, + accounts_open_decr: { + event: TOPS_AmlEventsName.DECR_ACCOUNT_OPEN, + start: undefined, + end: undefined, + }, + // Number of new GwG files in the last year + gwg_files_new_last_year: { + event: TOPS_AmlEventsName.INCR_ACCOUNT_OPEN, + start: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: -1 }), + ), + end: AbsoluteTime.now(), + }, + // Number of GwG files closed in the last year + gwg_files_closed_last_year: { + event: TOPS_AmlEventsName.DECR_ACCOUNT_OPEN, + start: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: -1 }), + ), + end: AbsoluteTime.now(), + }, + // Number of GwG files of high-risk customers + gwg_files_high_risk_incr: { + event: TOPS_AmlEventsName.INCR_HIGH_RISK_CUSTOMER, //FIXME: spec refers to INCR_HIGH_RISK + start: undefined, + end: undefined, + }, + gwg_files_high_risk_decr: { + event: TOPS_AmlEventsName.DECR_HIGH_RISK_CUSTOMER, + start: undefined, + end: undefined, + }, + // Number of GwG files managed with “increased risk” due to PEP status + gwg_files_pep_incr: { + event: TOPS_AmlEventsName.INCR_PEP, + start: undefined, + end: undefined, + }, + gwg_files_pep_decr: { + event: TOPS_AmlEventsName.DECR_PEP, + start: undefined, + end: undefined, + }, + // Number of MROS reports based on Art 9 Abs. 1 GwG (per year) + mros_reports_art9_last_year: { + event: TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SUBSTANTIATED, + start: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: -1 }), + ), + end: AbsoluteTime.now(), + }, + // Number of MROS reports based on Art 305ter Abs. 2 StGB (per year) + mros_reports_art305_last_year: { + event: TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SIMPLE, + start: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: -1 }), + ), + end: AbsoluteTime.now(), + }, + // Number of customers involved in proceedings for which Art 6 GwG did apply + accounts_involed_in_proceedings_last_year: { + event: TOPS_AmlEventsName.INCR_INVESTIGATION_CONCLUDED, //FIXME: spec refers to INCR_INVESTIGATION + start: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: -1 }), + ), + end: AbsoluteTime.now(), + }, +} satisfies QueryModel<TOPS_AmlEventsName>; + +/** + * All the calculation needed to create the Event Reporting (TOPS) + * https://docs.taler.net/deployments/tops.html#event-reporting-tops + * + * Maps a event reporting name with a calculation which uses the + * result of a query to the server. + * + * @param events The result of event reporting query + * @returns + */ +export const EventReporting_TOPS_calculation = ( + events: CounterResultByEventName<typeof EventReporting_TOPS_queries>, +) => + ({ + // Number of accounts that are opened: + accounts_opened: safeSub( + events.accounts_open_incr, + events.accounts_open_decr, + ), + + // Number of new GwG files in the last year. + new_gwg_files_last_year: events.gwg_files_new_last_year, + + // Number of GwG files closed in the last year + gwg_files_closed_last_year: events.gwg_files_closed_last_year, + + // Number of GwG files of high-risk customers + gwg_files_high_risk: safeSub( + events.gwg_files_high_risk_incr, + events.gwg_files_high_risk_decr, + ), + + // Number of GwG files managed with “increased risk” due to PEP status + gwg_files_pep: safeSub( + events.gwg_files_pep_incr, + events.gwg_files_pep_decr, + ), + + // Number of MROS reports based on Art 9 Abs. 1 GwG (per year) + mros_reports_art9_last_year: events.mros_reports_art9_last_year, + + // Number of MROS reports based on Art 305ter Abs. 2 StGB (per year) + mros_reports_art305_last_year: events.mros_reports_art305_last_year, + + // Number of customers involved in proceedings for which Art 6 GwG did apply + accounts_involed_in_proceedings_last_year: + events.accounts_involed_in_proceedings_last_year, + }) as const; + +/** + * All the request needed to create the Event Reporting (CQF) + * https://docs.taler.net/deployments/tops.html#event-reporting-vqf + * + * Maps a request key to request parameters. + * Requires the times for the query range. + * + */ +export const EventReporting_VQF_queries = (jan_1st: AbsoluteTime, dec_31st: AbsoluteTime) => { + const zero = AbsoluteTime.fromMilliseconds(0); + + return { + // Number of open accounts on January 1st (self-declaration 3.1.1) + accounts_open_first_jan_incr: { + event: TOPS_AmlEventsName.INCR_ACCOUNT_OPEN, + start: zero, + end: jan_1st, + }, + accounts_open_first_jan_decr: { + event: TOPS_AmlEventsName.INCR_ACCOUNT_OPEN, + start: zero, + end: jan_1st, + }, + // Number of newly opened accounts between 01.01.20XX and 31.12.20XX (self-declaration 3.1.2.) + accounts_opened_on_year: { + event: TOPS_AmlEventsName.INCR_ACCOUNT_OPEN, + start: jan_1st, + end: dec_31st, + }, + // Number of AML files managed during the year 20XX (self-declaration 3.1.3.) + aml_files_managed_on_year_incr: { + event: TOPS_AmlEventsName.INCR_ACCOUNT_OPEN, + start: zero, + end: dec_31st, + }, + aml_files_managed_on_year_decr: { + event: TOPS_AmlEventsName.DECR_ACCOUNT_OPEN, + start: zero, + end: jan_1st, + }, + // Number of AML files closed between 01.01.20XX and 31.12.20XX (self-declaration 3.1.4) + aml_files_closed_on_year: { + event: TOPS_AmlEventsName.DECR_ACCOUNT_OPEN, + start: jan_1st, + end: dec_31st, + }, + // Were there business relationships in the year 20XX with high risk? (self-declaration 4.1) + accounts_high_risk_incr: { + event: TOPS_AmlEventsName.INCR_HIGH_RISK_CUSTOMER, + start: zero, + end: dec_31st, + }, + accounts_high_risk_decr: { + event: TOPS_AmlEventsName.DECR_HIGH_RISK_CUSTOMER, + start: zero, + end: dec_31st, + }, + // Of those, how many were with PEPs? (self-declaration 4.2.) + accounts_pep_incr: { + event: TOPS_AmlEventsName.INCR_PEP, + start: zero, + end: dec_31st, + }, + accounts_pep_decr: { + event: TOPS_AmlEventsName.DECR_PEP, + start: zero, + end: dec_31st, + }, + // Of those PEPs, how many were with foreign PEPs? (self-declaration 4.3.) + accounts_pep_foreign_incr: { + event: TOPS_AmlEventsName.INCR_PEP_FOREIGN, + start: zero, + end: dec_31st, + }, + accounts_pep_foreign_decr: { + event: TOPS_AmlEventsName.DECR_PEP_FOREIGN, + start: zero, + end: dec_31st, + }, + // Number of other additional (other than PEPs and foreign PEPs) high-risk business relationships in 20XX + // comment: no need to add extra query + // + // Number of high-risk business relationship n total in 20xx (self-declaration 4.5.) + // comment: we have this information already + // + // Number of reports (substantiated suspicion) to MROS during 20xx (self-declaration 5.1) + mros_suspicion_substantiated: { + event: TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SUBSTANTIATED, + start: jan_1st, + end: dec_31st, + }, + // Number of reports (simple suspicion) to MROS during 20xx (self-declaration 5.2) + mros_suspicion_simple: { + event: TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SIMPLE, + start: jan_1st, + end: dec_31st, + }, + // Total number of reports to MROS during 20xx (self-declaration 5.3) + // comment: no need to add extra query + } satisfies QueryModel<TOPS_AmlEventsName>; +}; + +export const EventReporting_VQF_calculation = ( + events: CounterResultByEventName<ReturnType<typeof EventReporting_VQF_queries>>, +) => { + return { + // Number of open accounts on January 1st (self-declaration 3.1.1) + accounts_open_first_jan: safeSub( + events.accounts_open_first_jan_incr, + events.accounts_open_first_jan_decr, + ), + + // Number of newly opened accounts between 01.01.20XX and 31.12.20XX (self-declaration 3.1.2.) + accounts_opened_on_year: events.accounts_opened_on_year, + + // Number of AML files managed during the year 20XX (self-declaration 3.1.3.) + aml_files_managed_on_year: safeSub( + events.aml_files_managed_on_year_incr, + events.aml_files_managed_on_year_decr, + ), + + // Number of AML files closed between 01.01.20XX and 31.12.20XX (self-declaration 3.1.4) + aml_files_closed_on_year: events.aml_files_closed_on_year, + + // Were there business relationships in the year 20XX with high risk? (self-declaration 4.1) + accounts_high_risk: safeSub( + events.accounts_high_risk_incr, + events.accounts_high_risk_decr, + ), + + // Of those, how many were with PEPs? (self-declaration 4.2.) + accounts_pep: safeSub(events.accounts_pep_incr, events.accounts_pep_decr), + + // Of those PEPs, how many were with foreign PEPs? (self-declaration 4.3.) + accounts_pep_foreign: safeSub( + events.accounts_pep_foreign_incr, + events.accounts_pep_foreign_decr, + ), + + // Number of other additional (other than PEPs and foreign PEPs) high-risk business relationships in 20XX (self-declaration 4.4.) + accounts_high_risk_other: safeSub( + safeSub(events.accounts_high_risk_incr, events.accounts_high_risk_decr), // 4.5 + safeSub(events.accounts_pep_incr, events.accounts_pep_decr), // 4.2 + ), + + // Number of high-risk business relationship n total in 20xx (self-declaration 4.5.) + // comment: already implemented on 4.1 + + // Number of reports (substantiated suspicion) to MROS during 20xx (self-declaration 5.1) + mros_suspicion_substantiated: events.mros_suspicion_substantiated, + + // Number of reports (simple suspicion) to MROS during 20xx (self-declaration 5.2) + mros_suspicion_simple: events.mros_suspicion_simple, + + // Total number of reports to MROS during 20xx (self-declaration 5.3) + mros_total: safeAdd( + events.mros_suspicion_substantiated, + events.mros_suspicion_simple, + ), + }; +}; + +export type CounterResultByEventName<T> = { + [name in keyof T]?: number; +}; + +export type EventQueryByEventName<T> = { + [name in keyof T]: EventQuery<string>; +}; + +function safeSub( + a: number | undefined, + b: number | undefined, +): number | undefined { + return a === undefined || b == undefined ? undefined : a - b; +} +function safeAdd( + a: number | undefined, + b: number | undefined, +): number | undefined { + return a === undefined || b == undefined ? undefined : a + b; +} + + +export async function fetchTopsInfoFromServer(api: TalerExchangeHttpClient, officer: OfficerAccount) { + type EventType = typeof EventReporting_TOPS_queries; + const eventList = Object.entries(EventReporting_TOPS_queries); + + const allQueries = eventList.map(async ([_key, value]) => { + const key = _key as keyof EventType; + const response = await api.getAmlKycStatistics(officer, value.event, { + since: value.start, + until: value.end, + }); + return { key, response }; + }); + + const allResponses = await Promise.all(allQueries); + + const resultMap = allResponses.reduce((prev, event) => { + prev[event.key] = + event.response.type === "ok" ? event.response.body.counter : undefined; + return prev; + }, {} as CounterResultByEventName<EventType>); + + return EventReporting_TOPS_calculation(resultMap); +} + +export async function fetchVqfInfoFromServer(api: TalerExchangeHttpClient, officer: OfficerAccount, jan_1st: AbsoluteTime, dec_31st: AbsoluteTime) { + const VQF_EVENTS_THIS_YEAR = EventReporting_VQF_queries(jan_1st, dec_31st); + type EventType = typeof VQF_EVENTS_THIS_YEAR; + const eventList = Object.entries(VQF_EVENTS_THIS_YEAR); + + const allQueries = eventList.map(async ([_key, value]) => { + const key = _key as keyof EventType; + const response = await api.getAmlKycStatistics(officer, value.event, { + since: value.start, + until: value.end, + }); + return { key, response }; + }); + + const allResponses = await Promise.all(allQueries); + + const resultMap = allResponses.reduce((prev, event) => { + prev[event.key] = + event.response.type === "ok" ? event.response.body.counter : undefined; + return prev; + }, {} as CounterResultByEventName<EventType>); + + return EventReporting_VQF_calculation(resultMap); +} diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts @@ -86,8 +86,9 @@ export * from "./taler-signatures.js"; export * from "./account-restrictions.js"; -export * from "./aml/aml-events.js"; -export * from "./aml/aml-properties.js"; +export * from "./aml/events.js"; +export * from "./aml/properties.js"; +export * from "./aml/reporting.js"; export * from "./taler-account-properties.js"; export * from "./taler-form-attributes.js"; diff --git a/packages/web-util/src/forms/fields/InputIsoDate.tsx b/packages/web-util/src/forms/fields/InputIsoDate.tsx @@ -132,6 +132,13 @@ export function InputIsoDate( value={AbsoluteTime.fromMilliseconds(calendarOpenTime)} onChange={(v) => { // The date is always *stored* as an ISO date. + // !!!!! why? + /** + * form and fields should only care about how the information is asked + * to the user and should not care about how is store or sent to the server + * + * this format here should always be the 'pattern' of the field + */ onChange( v.t_ms === "never" ? undefined : format(v.t_ms, "yyyy-MM-dd"), ); diff --git a/packages/web-util/src/forms/gana/GLS_Onboarding.ts b/packages/web-util/src/forms/gana/GLS_Onboarding.ts @@ -1,3 +1,4 @@ +import { isFuture, parse } from "date-fns"; import { DoubleColumnFormDesign, InternationalizationAPI, @@ -39,6 +40,14 @@ export function GLS_Onboarding( placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, { id: TalerFormAttributes.PERSON_NATIONALITY, @@ -103,6 +112,14 @@ export function GLS_Onboarding( placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, { id: TalerFormAttributes.BUSINESS_IS_NON_PROFIT, diff --git a/packages/web-util/src/forms/gana/VQF_902_11_officer.ts b/packages/web-util/src/forms/gana/VQF_902_11_officer.ts @@ -4,6 +4,7 @@ import { FormMetadata, InternationalizationAPI, } from "../../index.browser.js"; +import { format } from "date-fns"; export const form_vqf_902_11_officer = ( i18n: InternationalizationAPI, @@ -18,6 +19,8 @@ export const form_vqf_902_11_officer = ( export function VQF_902_11_officer( i18n: InternationalizationAPI, ): DoubleColumnFormDesign { + const today = format(new Date(), "yyyy-MM-dd"); + return { type: "double-column", title: i18n.str`Establishment of the controlling person (submitted by AML officer)`, diff --git a/packages/web-util/src/forms/gana/VQF_902_1_customer.ts b/packages/web-util/src/forms/gana/VQF_902_1_customer.ts @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { isFuture, parse } from "date-fns"; import { DoubleColumnFormDesign, InternationalizationAPI, @@ -146,6 +147,14 @@ export function design_VQF_902_1_customer( pattern: "dd/MM/yyyy", defaultCalendarValue: "1980-01-01", required: true, + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, { id: TalerFormAttributes.NATIONALITY, @@ -276,12 +285,21 @@ export function design_VQF_902_1_customer( pattern: "dd/MM/yyyy", defaultCalendarValue: "1980-01-01", required: true, + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, { id: TalerFormAttributes.NATIONALITY, label: i18n.str`Nationality`, type: "selectOne", choices: countryNationalityList(i18n), + preferredChoiceVals: ["CH"], required: true, }, fieldPersonalId(i18n), diff --git a/packages/web-util/src/forms/gana/VQF_902_1_officer.ts b/packages/web-util/src/forms/gana/VQF_902_1_officer.ts @@ -15,7 +15,7 @@ */ import { TalerFormAttributes } from "@gnu-taler/taler-util"; -import { format } from "date-fns"; +import { format, isFuture, isToday, parse } from "date-fns"; import { DoubleColumnFormDesign, FormMetadata, @@ -53,6 +53,7 @@ export function VQF_902_1_officer( placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", defaultValue: today, + disabled: true, required: true, }, { @@ -123,6 +124,14 @@ export function VQF_902_1_officer( defaultValue: today, placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, ], }, diff --git a/packages/web-util/src/forms/gana/VQF_902_9.ts b/packages/web-util/src/forms/gana/VQF_902_9.ts @@ -5,6 +5,7 @@ import { InternationalizationAPI, } from "../../index.browser.js"; import { countryNationalityList } from "../../utils/select-ui-lists.js"; +import { format, isFuture, parse } from "date-fns"; export const form_vqf_902_9 = ( i18n: InternationalizationAPI, @@ -18,6 +19,7 @@ export const form_vqf_902_9 = ( export function VQF_902_9( i18n: InternationalizationAPI, ): DoubleColumnFormDesign { + const today = format(new Date(), "yyyy-MM-dd"); return { type: "double-column", title: i18n.str`Declaration of identity of the beneficial owner`, @@ -95,12 +97,21 @@ export function VQF_902_9( placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, { id: TalerFormAttributes.NATIONALITY, label: i18n.str`Nationality`, type: "selectOne", choices: countryNationalityList(i18n), + preferredChoiceVals: ["CH"], required: true, }, ], @@ -128,11 +139,12 @@ export function VQF_902_9( { id: TalerFormAttributes.SIGN_DATE, label: i18n.str`Date`, - type: "absoluteTimeText", + type: "isoDateText", placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, disabled: true, + defaultValue: today, }, ], }, diff --git a/packages/web-util/src/forms/gana/VQF_902_9_customer.ts b/packages/web-util/src/forms/gana/VQF_902_9_customer.ts @@ -5,6 +5,7 @@ import { InternationalizationAPI, } from "../../index.browser.js"; import { countryNationalityList } from "../../utils/select-ui-lists.js"; +import { format, isFuture, parse } from "date-fns"; export const form_vqf_902_9_customer = ( i18n: InternationalizationAPI, @@ -18,6 +19,8 @@ export const form_vqf_902_9_customer = ( export function VQF_902_9_customer( i18n: InternationalizationAPI, ): DoubleColumnFormDesign { + const today = format(new Date(), "yyyy-MM-dd"); + return { type: "double-column", title: i18n.str`Declaration of identity of the beneficial owner`, @@ -68,12 +71,21 @@ export function VQF_902_9_customer( placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, { id: TalerFormAttributes.NATIONALITY, label: i18n.str`Nationality`, type: "selectOne", choices: countryNationalityList(i18n), + preferredChoiceVals: ["CH"], required: true, }, ], @@ -98,9 +110,10 @@ export function VQF_902_9_customer( { id: TalerFormAttributes.SIGN_DATE, label: i18n.str`Date`, - type: "absoluteTimeText", + type: "isoDateText", placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", + defaultValue: today, required: true, disabled: true, }, diff --git a/packages/web-util/src/forms/gana/VQF_902_9_officer.ts b/packages/web-util/src/forms/gana/VQF_902_9_officer.ts @@ -5,6 +5,7 @@ import { InternationalizationAPI, } from "../../index.browser.js"; import { countryNationalityList } from "../../utils/select-ui-lists.js"; +import { isFuture, parse } from "date-fns"; export const form_vqf_902_9_officer = ( i18n: InternationalizationAPI, @@ -68,12 +69,21 @@ export function VQF_902_9_officer( placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, + validator(text, form) { + //FIXME: why returning in this format even if pattern is in another? + const time = parse(text, "yyyy-MM-dd", new Date()); + if (isFuture(time)) { + return i18n.str`it can't be in the future`; + } + return undefined; + }, }, { id: TalerFormAttributes.NATIONALITY, label: i18n.str`Nationality`, type: "selectOne", choices: countryNationalityList(i18n), + preferredChoiceVals: ["CH"], required: true, }, ],