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:
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),
+ })),
+ };
+}