taler-typescript-core

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

commit 18c9a37b0a382402de20d1ea1cb1ab55faa5d8e0
parent 2ba33b6e71d21f523f1730f2ae2cf7803a8f67c0
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 14:23:33 +0200

exchange AML web UI: constrain instant measures

Diffstat:
Mpackages/taler-exchange-aml-webui/src/pages/DecisionWizard.stories.tsx | 162++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/taler-exchange-aml-webui/src/pages/decision/Measures.tsx | 404++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
2 files changed, 439 insertions(+), 127 deletions(-)

diff --git a/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.stories.tsx b/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.stories.tsx @@ -19,6 +19,7 @@ import { AmlDecision, AmountString, AvailableMeasureSummary, + CustomerAccountSummary, LimitOperationType, TalerAmlProperties, TalerFormAttributes, @@ -28,6 +29,7 @@ import { import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import * as tests from "@gnu-taler/web-util/testing"; import { h, VNode } from "preact"; +import { AccountCaseShell } from "../components/AccountCaseShell.js"; import { DecisionRequest, DecisionRequestProvider, @@ -77,25 +79,65 @@ const currentDecision: AmlDecision = { is_active: true, }; +const accountSummary: CustomerAccountSummary = { + h_payto: account as never, + full_payto: fullPayto as never, + customer_label: "Example Trading AG", + high_risk: true, + comments: "Verify the source of funds.", + rowid: 43 as never, + open_time: timestamp(now - 90 * 24 * 60 * 60), + close_time: { t_s: "never" }, + to_investigate: true, +}; + const measureSummary: AvailableMeasureSummary = { roots: { "review-documents": { check_name: "identity-form", prog_name: "review-documents", context: { requested_document: "proof-of-address" }, + operation_type: LimitOperationType.deposit, + }, + "show-terms": { + check_name: "terms-notice", + context: { terms_version: "2026-08" }, + voluntary: true, }, "preserve-investigate": { check_name: "SKIP", prog_name: "preserve-investigate", }, + "freeze-account": { + check_name: "skip", + prog_name: "freeze-account", + }, + "broken-reference": { + check_name: "missing-check", + prog_name: "missing-program", + context: { reason: "storybook example" }, + }, }, checks: { "identity-form": { description: "Collect proof of address", + description_i18n: { de: "Adressnachweis erfassen" }, requires: ["requested_document"], outputs: ["full_name", "address"], fallback: "manual-review", }, + "terms-notice": { + description: "Show the current terms of service", + requires: ["terms_version"], + outputs: [], + fallback: "manual-review", + }, + "manual-acknowledgement": { + description: "Ask the customer for a simple acknowledgement", + requires: [], + outputs: [], + fallback: "manual-review", + }, }, programs: { "review-documents": { @@ -108,6 +150,11 @@ const measureSummary: AvailableMeasureSummary = { context: [], inputs: [], }, + "freeze-account": { + description: "Apply an immediate account freeze", + context: [], + inputs: [], + }, }, default_rules: [rule], }; @@ -156,27 +203,43 @@ function Story({ step, initial = completeDecision, formId, + newAccount = false, + accountId = account, + payto = fullPayto, }: { step: WizardSteps; initial?: DecisionRequest; formId?: string; + newAccount?: boolean; + accountId?: string; + payto?: string; }): VNode { return ( <DecisionRequestProvider initial={initial}> - <DecisionWizard - account={account} - fullPayto={fullPayto} - newAccount={false} - step={step} - formId={formId} - officer={officer} - onMove={() => undefined} - /> + <AccountCaseShell + presentation="decision" + account={accountId} + fullPayto={payto} + routeToAccount={`#/account/${accountId}`} + newAccount={newAccount} + > + <DecisionWizard + account={accountId} + fullPayto={payto} + newAccount={newAccount} + step={step} + formId={formId} + officer={officer} + onMove={() => undefined} + /> + </AccountCaseShell> </DecisionRequestProvider> ); } const exchange = { + getAmlAccounts: async () => + opFixedSuccess(dummyHttpResponse, { accounts: [accountSummary] }), getAmlDecisions: async () => opFixedSuccess(dummyHttpResponse, { records: [currentDecision] }), getAmlMeasures: async () => opFixedSuccess(dummyHttpResponse, measureSummary), @@ -232,11 +295,92 @@ export const ImmediateMeasures = tests.createExample( { step: "measures" }, { exchange }, ); +export const ImmediateMeasuresAnyOne = tests.createExample( + Story, + { + step: "measures", + initial: { + ...completeDecision, + new_measures: ["review-documents", "show-terms"], + measures_and: false, + }, + }, + { exchange }, +); +export const ImmediateMeasuresAllRequired = tests.createExample( + Story, + { + step: "measures", + initial: { + ...completeDecision, + new_measures: ["review-documents", "show-terms"], + measures_and: true, + }, + }, + { exchange }, +); +export const ImmediateMeasureInstantSelected = tests.createExample( + Story, + { + step: "measures", + initial: { + ...completeDecision, + new_measures: ["preserve-investigate"], + }, + }, + { exchange }, +); +export const ImmediateMeasuresInvalidLegacySelection = tests.createExample( + Story, + { + step: "measures", + initial: { + ...completeDecision, + new_measures: ["preserve-investigate", "freeze-account"], + measures_and: false, + }, + }, + { exchange }, +); export const TransactionRules = tests.createExample( Story, { step: "rules" }, { exchange }, ); +export const TransactionRulesWithSuccessorMeasure = tests.createExample( + Story, + { + step: "rules", + initial: { + ...completeDecision, + onExpire_measure: "review-documents", + }, + }, + { exchange }, +); +export const NewWalletDecision = tests.createExample( + Story, + { + step: "rules", + newAccount: true, + accountId: "2222222222222222222222222222222222222222222222222222", + payto: `payto://taler-reserve/exchange.example/${"0".repeat(52)}?receiver-name=New%20wallet%20customer`, + initial: { + ...completeDecision, + original: undefined, + accountName: "New wallet customer", + }, + }, + { + exchange: { + ...exchange, + getAmlAccounts: async () => + opFixedSuccess(dummyHttpResponse, { accounts: [] }), + getAmlDecisions: async () => + opFixedSuccess(dummyHttpResponse, { records: [] }), + }, + }, +); export const CaseDisposition = tests.createExample( Story, { step: "justification" }, diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Measures.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Measures.tsx @@ -34,12 +34,15 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { CurrentMeasureTable } from "../../components/MeasuresTable.js"; +import { useEffect, useState } from "preact/hooks"; +import { + MeasureCard, + MeasureCategory, +} from "../../components/MeasureDetails.js"; +import { descriptionForMeasureDefinition } from "../../components/MeasureSelection.js"; import { MeasureDefinition, NewMeasure } from "../../components/NewMeasure.js"; import { useCurrentDecisionRequest } from "../../hooks/decision-request.js"; import { useServerMeasures } from "../../hooks/server-info.js"; -import { computeMeasureInformation } from "../../utils/computeAvailableMesaures.js"; import { isBuiltInSkipCheck } from "../../utils/measure-check.js"; /** @@ -55,7 +58,7 @@ export function Measures(): VNode { }>(); const measures = useServerMeasures(); - const { i18n } = useTranslationContext(); + const { i18n, lang } = useTranslationContext(); if (!measures) return <Loading />; if (measures instanceof TalerError) { return ( @@ -171,11 +174,14 @@ export function Measures(): VNode { }); }} measures={allMeasures} + summary={measureBody} + language={lang} newMeasures={!request.new_measures ? [] : request.new_measures} /> )} <ShowAllMeasures summary={measureBody} + language={lang} addNewMeasure={(template) => { setAddMeasure({ isNew: true, @@ -234,23 +240,32 @@ function convertToMeasureType([name, measure]: [ function ActiveMeasureForm({ measures, + summary, + language, editMeasure, newMeasures, }: { measures: MeasureType[]; + summary: AvailableMeasureSummary; + language: string; newMeasures: string[]; editMeasure: (m: Partial<MeasureDefinition>) => void; }): VNode { const { i18n } = useTranslationContext(); const [request, updateRequest] = useCurrentDecisionRequest(); + const [requireAll, setRequireAll] = useState(request.measures_and ?? false); - const design = formDesign(i18n, measures); + const design = formDesign(i18n, measures, summary, language); - const form = useForm<FormType>(design, { + const form = useForm<Pick<FormType, "measures">>(design, { measures: newMeasures, - all: request.measures_and ?? false, }); const requestCustomMeasures = { ...(request?.custom_measures ?? {}) }; + const selected = form.status.result.measures ?? []; + + useEffect(() => { + if (selected.length < 2 && requireAll) setRequireAll(false); + }, [selected.length, requireAll]); onComponentUnload(() => { const newMeasures: string[] = []; @@ -276,12 +291,11 @@ function ActiveMeasureForm({ } updateRequest("unload active measure", { new_measures: newMeasures, - measures_and: form.status.result.all ?? false, + measures_and: newMeasures.length > 1 ? requireAll : false, custom_measures: requestCustomMeasures, }); }); - const selected = form.status.result.measures ?? []; const selectedSkipMeasures = selected.filter((name) => { const measure = measures.find((candidate) => candidate.name === name); return ( @@ -290,62 +304,147 @@ function ActiveMeasureForm({ ); }); - const selectedVerifyMeasure = selected - .map((s) => measures.find((d) => d.name === s)) - .filter((d) => d !== undefined && d.type === "verify-template") - .filter((c) => requestCustomMeasures[c.name] === undefined); + const selectedMeasures = selected + .map((name) => measures.find((candidate) => candidate.name === name)) + .filter((measure): measure is MeasureType => measure !== undefined); return ( - <Fragment> + <div class="space-y-5"> <FormUI design={design} model={form.model} /> {selectedSkipMeasures.length > 1 ? ( - <Attention type="danger" title={i18n.str`Too many SKIP measures`}> + <Attention + type="danger" + title={i18n.str`Only one instant measure is allowed`} + > <i18n.Translate> - At most one immediate measure may use the SKIP check. + Remove all but one instant measure before continuing. Instant + measures run an AML program without customer interaction. </i18n.Translate> </Attention> ) : undefined} - <div> - {selectedVerifyMeasure.map((ver, ky) => { - return ( - <button - key={ky} - onClick={() => { - editMeasure({ - check: ver.measure.check_name, - context: !ver.measure.context - ? [] - : Object.entries(ver.measure.context).map( - ([key, value]) => ({ - key, - type: "json", - value: JSON.stringify(value), - }), - ), - name: ver.name, - program: ver.measure.prog_name, - }); - }} - class="mt-3 min-h-11 rounded-md bg-primary px-4 py-2 text-sm font-semibold text-onPrimary hover:bg-primary/90 dark:bg-darkPrimary dark:text-darkOnPrimary" - > - <i18n.Translate> - Configure verification measure “{ver.name}” - </i18n.Translate> - </button> - ); - })} - </div> - </Fragment> + {selected.length > 1 ? ( + <fieldset class="rounded-lg border border-gray-200 p-4 dark:border-gray-700"> + <legend class="px-1 text-sm font-semibold text-gray-950 dark:text-gray-50"> + <i18n.Translate>Completion rule</i18n.Translate> + </legend> + <div class="mt-1 grid gap-3 sm:grid-cols-2"> + <CombinatorChoice + id="measure-combinator-any" + checked={!requireAll} + onChange={() => setRequireAll(false)} + title={i18n.str`Any one selected measure is sufficient`} + description={i18n.str`The requirement is completed as soon as the customer completes any one selected measure.`} + /> + <CombinatorChoice + id="measure-combinator-all" + checked={requireAll} + onChange={() => setRequireAll(true)} + title={i18n.str`Every selected measure is required`} + description={i18n.str`The requirement is completed only after every selected measure has been completed.`} + /> + </div> + </fieldset> + ) : undefined} + + {selectedMeasures.length ? ( + <section> + <h4 class="text-sm font-semibold text-gray-950 dark:text-gray-50"> + <i18n.Translate>Selected measure details</i18n.Translate> + </h4> + <div class="mt-3 grid gap-4 lg:grid-cols-2"> + {selectedMeasures.map((selectedMeasure) => { + const definition = definitionForMeasure(selectedMeasure); + const configurableVerification = + selectedMeasure.type === "verify-template" && + requestCustomMeasures[selectedMeasure.name] === undefined; + return ( + <MeasureCard + key={selectedMeasure.name} + name={selectedMeasure.name} + measure={definition} + summary={summary} + language={language} + category={categoryForMeasure(selectedMeasure)} + action={ + configurableVerification ? ( + <button + type="button" + onClick={() => + editMeasure( + toEditableDefinition( + selectedMeasure.name, + definition, + ), + ) + } + class="min-h-10 rounded-md bg-primary px-3 py-2 text-sm font-semibold text-onPrimary hover:bg-primary/90 dark:bg-darkPrimary dark:text-darkOnPrimary" + > + <i18n.Translate>Configure</i18n.Translate> + </button> + ) : undefined + } + /> + ); + })} + </div> + </section> + ) : undefined} + </div> + ); +} + +function CombinatorChoice({ + id, + checked, + onChange, + title, + description, +}: { + id: string; + checked: boolean; + onChange: () => void; + title: string; + description: string; +}): VNode { + return ( + <label + for={id} + aria-label={title} + class={`flex cursor-pointer gap-3 rounded-lg border p-4 ${ + checked + ? "border-primary ring-2 ring-primary" + : "border-gray-300 dark:border-gray-600" + }`} + > + <input + id={id} + type="radio" + name="measure-combinator" + checked={checked} + onChange={onChange} + class="mt-1 h-4 w-4 text-primary" + /> + <span> + <span class="block text-sm font-semibold text-gray-950 dark:text-gray-50"> + {title} + </span> + <span class="mt-1 block text-xs leading-5 text-gray-600 dark:text-gray-300"> + {description} + </span> + </span> + </label> ); } function ShowAllMeasures({ summary, + language, addNewMeasure, editMeasure, }: { summary: AvailableMeasureSummary; + language: string; addNewMeasure: (m: Partial<MeasureDefinition>) => void; editMeasure: (m: Partial<MeasureDefinition>) => void; }): VNode { @@ -377,27 +476,29 @@ function ShowAllMeasures({ </i18n.Translate> </p> </div> - <div class="mt-3"> - <CurrentMeasureTable - measures={computeMeasureInformation(summary, { - measureMap: request.custom_measures, - })} - onSelect={(m) => { - editMeasure({ - check: m.type === "form" ? m.checkName : undefined, - context: !m.context - ? [] - : Object.entries(m.context).map(([key, value]) => ({ - key, - type: "json", - value: JSON.stringify(value), - })), - name: m.name, - program: m.type !== "info" ? m.programName : undefined, - }); - }} - actionLabel={i18n.str`Edit`} - /> + <div class="mt-3 grid gap-4 lg:grid-cols-2"> + {Object.entries(request.custom_measures ?? {}).map( + ([name, measure]) => ( + <MeasureCard + key={name} + name={name} + measure={measure} + summary={summary} + language={language} + action={ + <button + type="button" + onClick={() => + editMeasure(toEditableDefinition(name, measure)) + } + class="min-h-10 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-semibold text-gray-800 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-950 dark:text-gray-100 dark:hover:bg-gray-900" + > + <i18n.Translate>Edit</i18n.Translate> + </button> + } + /> + ), + )} </div> </section> )} @@ -408,29 +509,57 @@ function ShowAllMeasures({ </span> <span class="ml-2 text-sm text-gray-600 dark:text-gray-300"> <i18n.Translate> - Reusable templates configured by the exchange operator + Inspect reusable definitions configured by the exchange </i18n.Translate> </span> </summary> - <div class="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700"> - <CurrentMeasureTable - measures={computeMeasureInformation(summary)} - onSelect={(m) => { - addNewMeasure({ - check: m.type === "form" ? m.checkName : undefined, - context: !m.context - ? [] - : Object.entries(m.context).map(([key, value]) => ({ - key, - type: "json", - value: JSON.stringify(value), - })), - name: m.name, - program: m.type !== "info" ? m.programName : undefined, - }); - }} - actionLabel={i18n.str`Use as template`} - /> + <div class="mt-3 border-t border-gray-200 pt-4 dark:border-gray-700"> + <div class="mb-4 flex flex-wrap items-start justify-between gap-3"> + <p class="max-w-3xl text-sm text-gray-600 dark:text-gray-300"> + <i18n.Translate> + Select a measure above to use it unchanged. Customize a copy + when this decision needs different checks, programs, or context. + </i18n.Translate> + </p> + <a + href="#/info" + target="_blank" + rel="noreferrer" + class="text-sm font-semibold text-primary hover:underline dark:text-darkPrimary" + > + <i18n.Translate>Open complete AML configuration</i18n.Translate> + </a> + </div> + {Object.keys(summary.roots).length ? ( + <div class="grid gap-4 lg:grid-cols-2"> + {Object.entries(summary.roots).map(([name, measure]) => ( + <MeasureCard + key={name} + name={name} + measure={measure} + summary={summary} + language={language} + action={ + <button + type="button" + onClick={() => + addNewMeasure(toEditableDefinition(name, measure)) + } + class="min-h-10 rounded-md border border-primary px-3 py-2 text-sm font-semibold text-primary hover:bg-primary/10 dark:border-darkPrimary dark:text-darkPrimary" + > + <i18n.Translate>Customize a copy</i18n.Translate> + </button> + } + /> + ))} + </div> + ) : ( + <p class="rounded-lg border border-dashed border-gray-300 p-4 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300"> + <i18n.Translate> + No exchange measures are configured. + </i18n.Translate> + </p> + )} </div> </details> </div> @@ -470,12 +599,13 @@ type VerifyMeasure = { type FormType = { measures: string[]; - all: boolean; }; function formDesign( i18n: InternationalizationAPI, measureNames: MeasureType[], + summary: AvailableMeasureSummary, + language: string, ): FormDesign { return { type: "single-column", @@ -484,40 +614,78 @@ function formDesign( type: "selectMultiple", unique: true, choices: measureNames.map((me) => { - switch (me.type) { - case "normal": { - return { - label: me.name, - value: me.name, - }; - } - case "simple-check-form": { - return { - label: `CHECK: ${me.checkName}`, - value: `check-${me.checkName}`, - }; - } - case "verify-template": { - return { - label: me.name, - value: me.name, - }; - } - default: { - assertUnreachable(me); - } - } + const instant = categoryForMeasure(me) === "instant"; + return { + label: me.name, + value: me.name, + description: descriptionForMeasure(me, summary, language, i18n), + exclusiveGroup: instant ? "instant-measure" : undefined, + disabledReason: instant + ? i18n.str`Only one instant measure can be selected. Remove the selected instant measure first.` + : undefined, + }; }), id: "measures", label: i18n.str`Immediate measures`, + placeholder: i18n.str`Search available measures`, help: i18n.str`Selected measures begin immediately after the decision is accepted by the exchange.`, }, - { - type: "toggle", - id: "all", - label: i18n.str`Require every selected measure`, - help: i18n.str`When enabled, all selected measures must be completed. Otherwise, completing any one of them is sufficient.`, - }, ], }; } + +function definitionForMeasure(measure: MeasureType): MeasureInformation { + switch (measure.type) { + case "normal": + case "verify-template": + return measure.measure; + case "simple-check-form": + return { check_name: measure.checkName }; + default: + return assertUnreachable(measure); + } +} + +function categoryForMeasure(measure: MeasureType): MeasureCategory { + if (measure.type === "simple-check-form") return "standalone-check"; + if (isBuiltInSkipCheck(measure.measure.check_name)) return "instant"; + if (measure.measure.prog_name) return "customer-form"; + return "information"; +} + +function descriptionForMeasure( + measure: MeasureType, + summary: AvailableMeasureSummary, + language: string, + i18n: InternationalizationAPI, +): string { + const definition = definitionForMeasure(measure); + const category = categoryForMeasure(measure); + return descriptionForMeasureDefinition( + definition, + summary, + language, + i18n, + category, + ); +} + +function toEditableDefinition( + name: string, + measure: MeasureInformation, +): Partial<MeasureDefinition> { + return { + name, + check: isBuiltInSkipCheck(measure.check_name) + ? undefined + : measure.check_name, + program: measure.prog_name, + context: !measure.context + ? [] + : Object.entries(measure.context).map(([key, value]) => ({ + key, + type: "json" as const, + value: JSON.stringify(value), + })), + }; +}