commit 7031cd8e11943ce3c6afb941edb7c8bb603a4549 parent ba68b867d072398f363d1ddc628dc947bd84371b Author: Florian Dold <dold@taler.net> Date: Thu, 27 Aug 2026 00:59:56 +0200 AML web UI: validate complete decision requests Diffstat:
9 files changed, 494 insertions(+), 95 deletions(-)
diff --git a/packages/taler-exchange-aml-webui/src/components/NewMeasure.tsx b/packages/taler-exchange-aml-webui/src/components/NewMeasure.tsx @@ -18,16 +18,20 @@ import { assertUnreachable, AvailableMeasureSummary, KycCheckInformation, + HttpStatusCode, TalerError, TranslatedString, } from "@gnu-taler/taler-util"; import { + ErrorLoading, + FailLoading, design_challenger_email, design_challenger_phone, design_challenger_postal, FormDesign, FormUI, InternationalizationAPI, + Loading, RecursivePartial, useForm, useTranslationContext, @@ -79,18 +83,46 @@ export function NewMeasure({ const measures = useServerMeasures(); const { i18n } = useTranslationContext(); - const summary = - !measures || measures instanceof TalerError || measures.type === "fail" - ? undefined - : measures.body; - - if (!summary) { + if (!measures) return <Loading />; + if (measures instanceof TalerError) { return ( - <div> - <i18n.Translate>loading...</i18n.Translate> - </div> + <ErrorLoading + title={i18n.str`Failed to load available measures.`} + error={measures} + /> ); } + if (measures.type === "fail") { + return ( + <FailLoading + operation={measures} + title={i18n.str`Failed to load available measures.`} + translate={(failure) => { + switch (failure.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate>The officer session is invalid.</i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The measures endpoint was not found. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The officer session is disabled. + </i18n.Translate> + ); + default: + return assertUnreachable(failure.case); + } + }} + /> + ); + } + const summary = measures.body; return ( <MeasureForm @@ -386,7 +418,7 @@ function VerificationMeasureForm({ custom_measures: currentMeasures, }); if (onRemoved) { - onRemoved(name!); + onRemoved(newMeasure.name); } } diff --git a/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.tsx b/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.tsx @@ -29,6 +29,7 @@ import { } from "../hooks/decision-request.js"; import { useAccountActiveDecision } from "../hooks/decisions.js"; import { OfficerReady } from "../hooks/officer.js"; +import { attributesAreComplete } from "../utils/decision-validation.js"; import { Events } from "./decision/Events.js"; import { Attributes } from "./decision/Information.js"; import { Justification } from "./decision/Justification.js"; @@ -78,9 +79,7 @@ export function isRulesCompleted(request: DecisionRequest): boolean { return request.rules !== undefined && request.deadline !== undefined; } export function isAttributesCompleted(request: DecisionRequest): boolean { - return ( - request.attributes === undefined || request.attributes.errors === undefined - ); + return attributesAreComplete(request); } export function isPropertiesCompleted(request: DecisionRequest): boolean { return ( @@ -373,7 +372,8 @@ function Header({ const isNewAccountAWallet = newPayto === undefined ? undefined - : (newPaytoParsed = Result.orUndefined(Paytos.fromString(newPayto))) === undefined + : (newPaytoParsed = Result.orUndefined(Paytos.fromString(newPayto))) === + undefined ? undefined : newPaytoParsed.targetType === PaytoType.TalerReserve || newPaytoParsed.targetType === PaytoType.TalerReserveHttp; diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Events.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Events.tsx @@ -70,13 +70,18 @@ function ShowEventForm({ events: calculatedEvents }: { events: Events }) { const form = useForm<FormType>(design, { calculated, rest, + custom: (request.custom_events ?? []).map((name) => ({ name })), }); onComponentUnload(() => { updateRequest("onload event", { - custom_events: !form.status.result.custom - ? [] - : form.status.result.custom, + custom_events: Array.from( + new Set( + (form.status.result.custom ?? []) + .map((row) => row?.name?.trim()) + .filter((name): name is string => !!name), + ), + ), triggering_events: [ ...(form.status.result.calculated ?? []), ...(form.status.result.rest ?? []), @@ -99,7 +104,7 @@ type Events = { type FormType = { calculated: string[]; rest: string[]; - custom: string[]; + custom: Array<{ name: string }>; }; const formDesign = ( diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Information.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Information.tsx @@ -13,8 +13,14 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { TalerFormAttributes } from "@gnu-taler/taler-util"; import { + AbsoluteTime, + encodeCrock, + randomBytes, + TalerFormAttributes, +} from "@gnu-taler/taler-util"; +import { + Attention, ErrorsSummary, FormDesign, FormMetadata, @@ -85,6 +91,21 @@ function FillCustomerData({ }): VNode { const [request, updateRequest] = useCurrentDecisionRequest(); const [expiration, setExpiration] = useState(request.attributes?.expiration); + const formSalt = useMemo( + () => + request.attributes?.formId === theForm.id && + request.attributes.formVersion === theForm.version && + request.attributes.formSalt + ? request.attributes.formSalt + : encodeCrock(randomBytes(32)), + [ + theForm.id, + theForm.version, + request.attributes?.formId, + request.attributes?.formVersion, + request.attributes?.formSalt, + ], + ); const expirationHandler: UIFieldHandler = { onChange: setExpiration, value: expiration, @@ -107,6 +128,7 @@ function FillCustomerData({ ...form.status.result, [TalerFormAttributes.FORM_ID]: theForm.id, [TalerFormAttributes.FORM_VERSION]: theForm.version, + [TalerFormAttributes.FORM_SALT]: formSalt, }; const errors = form.status.errors; @@ -118,11 +140,15 @@ function FillCustomerData({ expiration, formId: theForm.id, formVersion: theForm.version, + formSalt, errors, }, }); }); const { i18n } = useTranslationContext(); + const expirationInvalid = + !expiration || + (!AbsoluteTime.isNever(expiration) && AbsoluteTime.isExpired(expiration)); return ( <div> @@ -162,6 +188,13 @@ function FillCustomerData({ /> </div> <div> + {expirationInvalid ? ( + <Attention type="danger" title={i18n.str`Invalid expiration`}> + <i18n.Translate> + Attributes require an expiration date in the future. + </i18n.Translate> + </Attention> + ) : undefined} {!errors ? undefined : <ErrorsSummary errors={errors} />} <FormUI design={formDesign} model={form.model} /> </div> diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Measures.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Measures.tsx @@ -15,14 +15,20 @@ */ import { assertUnreachable, + AvailableMeasureSummary, + HttpStatusCode, KycCheckInformation, MeasureInformation, TalerError, } from "@gnu-taler/taler-util"; import { + Attention, + ErrorLoading, + FailLoading, FormDesign, FormUI, InternationalizationAPI, + Loading, onComponentUnload, useForm, useTranslationContext, @@ -48,11 +54,47 @@ export function Measures(): VNode { }>(); const measures = useServerMeasures(); - - const measureBody = - !measures || measures instanceof TalerError || measures.type === "fail" - ? undefined - : measures.body; + const { i18n } = useTranslationContext(); + if (!measures) return <Loading />; + if (measures instanceof TalerError) { + return ( + <ErrorLoading + title={i18n.str`Failed to load available measures.`} + error={measures} + /> + ); + } + if (measures.type === "fail") { + return ( + <FailLoading + operation={measures} + title={i18n.str`Failed to load available measures.`} + translate={(failure) => { + switch (failure.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate>The officer session is invalid.</i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The measures endpoint was not found. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The officer session is disabled. + </i18n.Translate> + ); + default: + return assertUnreachable(failure.case); + } + }} + /> + ); + } + const measureBody = measures.body; const measureList = ( !measureBody ? [] : Object.entries(measureBody.roots) @@ -119,6 +161,7 @@ export function Measures(): VNode { /> )} <ShowAllMeasures + summary={measureBody} addNewMeasure={(template) => { setAddMeasure({ isNew: true, @@ -171,6 +214,7 @@ function convertToMeasureType([name, measure]: [ return { type: "normal", name, + measure, }; } @@ -188,8 +232,11 @@ function ActiveMeasureForm({ const design = formDesign(i18n, measures); - const form = useForm<FormType>(design, { measures: newMeasures }); - const requestCustomMeasures = request?.custom_measures ?? {}; + const form = useForm<FormType>(design, { + measures: newMeasures, + all: request.measures_and ?? false, + }); + const requestCustomMeasures = { ...(request?.custom_measures ?? {}) }; onComponentUnload(() => { const newMeasures: string[] = []; @@ -215,11 +262,19 @@ function ActiveMeasureForm({ } updateRequest("unload active measure", { new_measures: newMeasures, + measures_and: form.status.result.all ?? false, custom_measures: requestCustomMeasures, }); }); const selected = form.status.result.measures ?? []; + const selectedSkipMeasures = selected.filter((name) => { + const measure = measures.find((candidate) => candidate.name === name); + return ( + (measure?.type === "normal" || measure?.type === "verify-template") && + measure.measure.check_name === "SKIP" + ); + }); const selectedVerifyMeasure = selected .map((s) => measures.find((d) => d.name === s)) @@ -229,6 +284,13 @@ function ActiveMeasureForm({ return ( <Fragment> <FormUI design={design} model={form.model} /> + {selectedSkipMeasures.length > 1 ? ( + <Attention type="danger" title={i18n.str`Too many SKIP measures`}> + <i18n.Translate> + At most one immediate measure may use the SKIP check. + </i18n.Translate> + </Attention> + ) : undefined} <div> {selectedVerifyMeasure.map((ver, ky) => { @@ -265,20 +327,15 @@ function ActiveMeasureForm({ } function ShowAllMeasures({ + summary, addNewMeasure, editMeasure, }: { + summary: AvailableMeasureSummary; addNewMeasure: (m: Partial<MeasureDefinition>) => void; editMeasure: (m: Partial<MeasureDefinition>) => void; }): VNode { const { i18n } = useTranslationContext(); - const measures = useServerMeasures(); - - const measureBody = - !measures || measures instanceof TalerError || measures.type === "fail" - ? undefined - : measures.body; - const [request] = useCurrentDecisionRequest(); const haveCustomMeasures = Object.keys(request?.custom_measures ?? {}).length > 0; @@ -302,7 +359,7 @@ function ShowAllMeasures({ </div> <div class="p-2"> <CurrentMeasureTable - measures={computeMeasureInformation(measureBody, { + measures={computeMeasureInformation(summary, { measureMap: request.custom_measures, })} onSelect={(m) => { @@ -331,7 +388,7 @@ function ShowAllMeasures({ </div> <div class="p-2"> <CurrentMeasureTable - measures={computeMeasureInformation(measureBody)} + measures={computeMeasureInformation(summary)} onSelect={(m) => { addNewMeasure({ check: m.type === "form" ? m.checkName : undefined, @@ -362,6 +419,7 @@ type MeasureType = NormalMeasure | SimpleCheckMeasure | VerifyMeasure; type NormalMeasure = { type: "normal"; name: string; + measure: MeasureInformation; }; /** @@ -385,6 +443,7 @@ type VerifyMeasure = { type FormType = { measures: string[]; + all: boolean; }; function formDesign( @@ -426,6 +485,12 @@ function formDesign( label: i18n.str`New measures`, help: i18n.str`Measures that are available/executed directly after the decision is confirmed.`, }, + { + type: "toggle", + id: "all", + label: i18n.str`Require all measures`, + help: i18n.str`Run all selected measures together instead of accepting any one of them.`, + }, ], }; } diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Properties.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Properties.tsx @@ -24,6 +24,7 @@ import { TOPS_AccountProperties, } from "@gnu-taler/taler-util"; import { + Attention, ErrorsSummary, FormDesign, FormUI, @@ -71,7 +72,7 @@ export function Properties(): VNode { } return prev; }, - request.properties ?? {}, + { ...(request.properties ?? {}) }, ); return ( <div> @@ -122,19 +123,33 @@ function ReloadForm({ custom: customProps, }); const errors = form.status.errors; + const customRows = form.status.result.custom ?? []; + const customNames = customRows.map((row) => row?.name?.trim()); + const customInvalid = customRows.some((row, index) => { + const name = row?.name?.trim(); + return ( + !name || + !row?.value?.trim() || + Object.prototype.hasOwnProperty.call(merged, name) || + customNames.indexOf(name) !== index + ); + }); onComponentUnload(() => { updateRequest("unload properties", { properties: (form.status.result.defined ?? {}) as Record<string, boolean>, - custom_properties: (form.status.result.custom ?? []).reduce( + custom_properties: customRows.reduce( (prev, cur) => { - if (!cur || !cur.name || !cur.value) return prev; - prev[cur.name] = cur.value; + const name = cur?.name?.trim(); + const value = cur?.value?.trim(); + if (!name || !value) return prev; + prev[name] = value; return prev; }, {} as Record<string, string>, ), - properties_errors: errors, + properties_errors: + errors ?? (customInvalid ? { custom: true } : undefined), }); }); @@ -153,6 +168,14 @@ function ReloadForm({ </button> {!errors ? undefined : <ErrorsSummary errors={errors.defined} />} + {customInvalid ? ( + <Attention type="danger" title={i18n.str`Invalid custom properties`}> + <i18n.Translate> + Custom property names and values must be non-empty and names must be + unique and different from built-in properties. + </i18n.Translate> + </Attention> + ) : undefined} <FormUI design={design} model={form.model} /> </div> ); @@ -189,11 +212,13 @@ const propertiesForm = ( type: "text", label: i18n.str`Name`, id: "name", + required: true, }, { type: "text", label: i18n.str`Value`, id: "value", + required: true, }, ], }, diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Rules.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Rules.tsx @@ -22,6 +22,7 @@ import { AvailableMeasureSummary, Duration, ExchangeVersionResponse, + HttpStatusCode, KycRule, LegitimizationRuleSet, LimitOperationType, @@ -33,9 +34,12 @@ import { } from "@gnu-taler/taler-util"; import { Attention, + ErrorLoading, + FailLoading, FormDesign, FormUI, InternationalizationAPI, + Loading, onComponentUnload, useExchangeApiContext, useForm, @@ -143,7 +147,8 @@ export function Rules({ newPayto }: { newPayto?: string }): VNode { const isNewAccountAWallet = newPayto === undefined ? undefined - : (newPaytoParsed = Result.orUndefined(Paytos.fromString(newPayto))) === undefined + : (newPaytoParsed = Result.orUndefined(Paytos.fromString(newPayto))) === + undefined ? undefined : newPaytoParsed.targetType === PaytoType.TalerReserve || newPaytoParsed.targetType === PaytoType.TalerReserveHttp; @@ -153,16 +158,47 @@ export function Rules({ newPayto }: { newPayto?: string }): VNode { const isWallet = request.original?.is_wallet ?? isNewAccountAWallet; const measures = useServerMeasures(); - const rootMeasures = - !measures || measures instanceof TalerError || measures.type === "fail" - ? undefined - : measures.body.roots; - - const defaultRules = ( - !measures || measures instanceof TalerError || measures.type === "fail" - ? [] - : measures.body.default_rules - ).filter((r) => { + if (!measures) return <Loading />; + if (measures instanceof TalerError) { + return ( + <ErrorLoading + title={i18n.str`Failed to load available measures.`} + error={measures} + /> + ); + } + if (measures.type === "fail") { + return ( + <FailLoading + operation={measures} + title={i18n.str`Failed to load available measures.`} + translate={(failure) => { + switch (failure.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate>The officer session is invalid.</i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The measures endpoint was not found. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The officer session is disabled. + </i18n.Translate> + ); + default: + return assertUnreachable(failure.case); + } + }} + /> + ); + } + const rootMeasures = measures.body.roots; + const defaultRules = measures.body.default_rules.filter((r) => { return isWallet ? WALLET_RULES.includes(r.operation_type) : BANK_RULES.includes(r.operation_type); @@ -457,7 +493,7 @@ function UpdateRulesForm({ {!showAddRuleForm ? undefined : ( <AddNewRuleForm - measureList={measureList} + measureList={allMeasure} config={config} isWallet={isWallet ?? false} onAdd={addNewRule} diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx @@ -21,7 +21,6 @@ import { OperationFail, OperationOk, opFixedSuccess, - Paytos, TalerError, TOPS_AmlEventsName, } from "@gnu-taler/taler-util"; @@ -29,6 +28,9 @@ import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { Attention, AsyncButton, + ErrorLoading, + FailLoading, + Loading, useExchangeApiContext, useNotificationContext, useNotifiedOperation, @@ -45,6 +47,7 @@ import { computeMeasureInformation, UiMeasureInformation, } from "../../utils/computeAvailableMesaures.js"; +import { buildAmlDecisionRequest } from "../../utils/decision-validation.js"; import { isAttributesCompleted, isEventsCompleted, @@ -80,11 +83,11 @@ export function Summary({ const { showError } = useNotificationContext(); const session = officer.session; - const allMeasures = computeMeasureInformation( - !measures || measures instanceof TalerError || measures.type === "fail" - ? undefined - : measures.body, - ); + const measureSummary = + measures && !(measures instanceof TalerError) && measures.type === "ok" + ? measures.body + : undefined; + const allMeasures = computeMeasureInformation(measureSummary); const d = decision.new_measures === undefined ? [] : decision.new_measures; const activeMeasureInfo: UiMeasureInformation = { @@ -116,7 +119,7 @@ export function Summary({ TOPS_AmlEventsName.MROS_REPORTED_SUSPICION_SUBSTANTIATED, ); - const CANT_SUBMIT = + const INCOMPLETE_STEPS = INVALID_ACCOUNT || INVALID_RULES || INVALID_MEASURES || @@ -130,42 +133,13 @@ export function Summary({ onMove(undefined); } - const fullPayto = !newPayto ? undefined : Paytos.fromString(newPayto); - if (fullPayto && fullPayto.tag === "ok" && decision.accountName) { - fullPayto.value.params["receiver-name"] = decision.accountName; - } - - const request: undefined | Omit<AmlDecisionRequest, "officer_sig"> = - CANT_SUBMIT - ? undefined - : { - h_payto: account!, - decision_time: AbsoluteTime.toProtocolTimestamp(AbsoluteTime.now()), - justification: decision.justification!, - payto_uri: - !fullPayto || fullPayto.tag === "error" - ? undefined - : Paytos.toFullString(fullPayto.value), - keep_investigating: decision.keep_investigating ?? false, - new_rules: { - expiration_time: AbsoluteTime.toProtocolTimestamp( - decision.deadline!, - ), - rules: decision.rules!, - successor_measure: decision.onExpire_measure, - custom_measures: decision.custom_measures ?? {}, - }, - attributes_expiration: decision.attributes?.expiration - ? AbsoluteTime.toProtocolTimestamp(decision.attributes.expiration) - : undefined, - events: decision.triggering_events, - attributes: decision.attributes?.data, - properties: decision.properties!, - new_measures: - !decision.new_measures || !decision.new_measures.length - ? undefined - : decision.new_measures.join(" "), - }; + const built = buildAmlDecisionRequest({ + account, + fullPayto: newPayto, + decision, + measures: measureSummary, + }); + const request = INCOMPLETE_STEPS ? undefined : built.request; const [submitConfirmation, setSubmitConfirmation] = useState<boolean>(false); const requiresConfirmation = MROS_REPORT_COMPLETED; @@ -210,6 +184,46 @@ export function Summary({ }, ); + if (!measures) return <Loading />; + if (measures instanceof TalerError) { + return ( + <ErrorLoading + title={i18n.str`Failed to load available measures.`} + error={measures} + /> + ); + } + if (measures.type === "fail") { + return ( + <FailLoading + operation={measures} + title={i18n.str`Failed to load available measures.`} + translate={(failure) => { + switch (failure.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate>The officer session is invalid.</i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate> + The measures endpoint was not found. + </i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The officer session is disabled. + </i18n.Translate> + ); + default: + return assertUnreachable(failure.case); + } + }} + /> + ); + } + if (submitConfirmation) { return ( <div class="flex"> @@ -247,6 +261,21 @@ export function Summary({ return ( <Fragment> + {built.errors.includes("unknown-measure") ? ( + <Attention type="danger" title={i18n.str`Unknown measure`}> + <i18n.Translate> + A rule or immediate action references a measure that is not + available. Review the rules and measures before submitting. + </i18n.Translate> + </Attention> + ) : undefined} + {built.errors.includes("multiple-skip-measures") ? ( + <Attention type="danger" title={i18n.str`Too many SKIP measures`}> + <i18n.Translate> + At most one immediate measure may use the SKIP check. + </i18n.Translate> + </Attention> + ) : undefined} {INVALID_RULES ? ( <Fragment> {!decision.deadline && ( diff --git a/packages/taler-exchange-aml-webui/src/utils/decision-validation.ts b/packages/taler-exchange-aml-webui/src/utils/decision-validation.ts @@ -0,0 +1,174 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import { + AbsoluteTime, + AmlDecisionRequest, + AvailableMeasureSummary, + Paytos, + TalerFormAttributes, +} from "@gnu-taler/taler-util"; +import { DecisionRequest } from "../hooks/decision-request.js"; + +export type DecisionValidationError = + | "missing-account" + | "missing-rules" + | "missing-deadline" + | "missing-properties" + | "invalid-properties" + | "missing-events" + | "missing-measures" + | "missing-justification" + | "missing-account-name" + | "invalid-attributes" + | "missing-measure-metadata" + | "unknown-measure" + | "multiple-skip-measures" + | "invalid-payto"; + +export interface BuiltDecisionRequest { + request?: Omit<AmlDecisionRequest, "officer_sig">; + errors: DecisionValidationError[]; +} + +export function attributesAreComplete(request: DecisionRequest): boolean { + const attributes = request.attributes; + if (!attributes) return true; + return ( + attributes.errors === undefined && + !!attributes.formId && + !!attributes.formSalt && + !!attributes.expiration && + (AbsoluteTime.isNever(attributes.expiration) || + !AbsoluteTime.isExpired(attributes.expiration)) + ); +} + +function normalizedNames(values: Array<string | undefined>): string[] { + return Array.from( + new Set(values.map((v) => v?.trim()).filter((v): v is string => !!v)), + ); +} + +export function buildAmlDecisionRequest({ + account, + fullPayto, + decision, + measures, + now = AbsoluteTime.now(), +}: { + account: string | undefined; + fullPayto: string | undefined; + decision: DecisionRequest; + measures: AvailableMeasureSummary | undefined; + now?: AbsoluteTime; +}): BuiltDecisionRequest { + const errors: DecisionValidationError[] = []; + if (!account) errors.push("missing-account"); + if (!decision.rules) errors.push("missing-rules"); + if (!decision.deadline) errors.push("missing-deadline"); + if (!decision.properties) errors.push("missing-properties"); + if (decision.properties_errors) errors.push("invalid-properties"); + if (decision.triggering_events === undefined || decision.custom_events === undefined) { + errors.push("missing-events"); + } + if (decision.new_measures === undefined) errors.push("missing-measures"); + if (!decision.justification?.trim()) errors.push("missing-justification"); + if (!decision.original && fullPayto && !decision.accountName?.trim()) { + errors.push("missing-account-name"); + } + if (!attributesAreComplete(decision)) errors.push("invalid-attributes"); + if (!measures) errors.push("missing-measure-metadata"); + + const properties = { ...(decision.properties ?? {}) }; + for (const [rawName, value] of Object.entries( + decision.custom_properties ?? {}, + )) { + const name = rawName.trim(); + if (!name || Object.prototype.hasOwnProperty.call(properties, name)) { + errors.push("invalid-properties"); + continue; + } + properties[name] = value; + } + + const knownMeasures = new Map([ + ...Object.entries(measures?.roots ?? {}), + ...Object.entries(decision.custom_measures ?? {}), + ]); + const immediateMeasures = normalizedNames(decision.new_measures ?? []); + const referencedMeasures = [ + ...immediateMeasures, + ...(decision.rules ?? []).flatMap((rule) => rule.measures), + decision.onExpire_measure, + ]; + for (const name of normalizedNames(referencedMeasures)) { + if (name !== "VERBOTEN" && !knownMeasures.has(name)) { + errors.push("unknown-measure"); + } + } + const skipCount = immediateMeasures.filter( + (name) => knownMeasures.get(name)?.check_name === "SKIP", + ).length; + if (skipCount > 1) errors.push("multiple-skip-measures"); + + let paytoUri: string | undefined; + if (fullPayto) { + const parsed = Paytos.fromString(fullPayto); + if (parsed.tag === "error") { + errors.push("invalid-payto"); + } else { + if (decision.accountName?.trim()) { + parsed.value.params["receiver-name"] = decision.accountName.trim(); + } + paytoUri = Paytos.toFullString(parsed.value); + } + } + + const attributes = decision.attributes + ? { + ...decision.attributes.data, + [TalerFormAttributes.FORM_ID]: decision.attributes.formId, + [TalerFormAttributes.FORM_VERSION]: decision.attributes.formVersion, + [TalerFormAttributes.FORM_SALT]: decision.attributes.formSalt, + } + : undefined; + const events = normalizedNames([ + ...(decision.triggering_events ?? []), + ...(decision.custom_events ?? []), + ]); + + if (errors.length) return { errors: Array.from(new Set(errors)) }; + + return { + errors: [], + request: { + h_payto: account!, + decision_time: AbsoluteTime.toProtocolTimestamp(now), + justification: decision.justification!.trim(), + payto_uri: paytoUri, + keep_investigating: decision.keep_investigating ?? false, + new_rules: { + expiration_time: AbsoluteTime.toProtocolTimestamp(decision.deadline!), + rules: decision.rules!, + successor_measure: decision.onExpire_measure || undefined, + custom_measures: decision.custom_measures ?? {}, + }, + attributes_expiration: decision.attributes + ? AbsoluteTime.toProtocolTimestamp(decision.attributes.expiration!) + : undefined, + attributes, + events: events.length ? events : undefined, + properties, + new_measures: immediateMeasures.length + ? `${decision.measures_and ? "+" : ""}${immediateMeasures.join(" ")}` + : undefined, + }, + }; +}