taler-typescript-core

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

commit dd9dc8c523334278a532f8eb1b3645eeb6799a6a
parent 60c162a57d77ad2b79430caeba4d14b015a6547d
Author: Sebastian <sebasjm@gmail.com>
Date:   Wed,  2 Apr 2025 16:48:02 -0300

fix #9684

Diffstat:
Mpackages/aml-backoffice-ui/src/hooks/decision-request.ts | 6++++++
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 1+
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 58++++++++++++++++++++--------------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx | 27++++++++++++++++-----------
Mpackages/aml-backoffice-ui/src/pages/decision/Justification.tsx | 49+++++++++++++++----------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 56++++++++++++++++++++++++++++++++++++++------------------
Mpackages/taler-util/src/iban.ts | 33+++++++++++++++++++++++++++++----
Mpackages/taler-wallet-webextension/src/cta/DevExperiment/test.ts | 14+++++++-------
9 files changed, 188 insertions(+), 121 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/hooks/decision-request.ts b/packages/aml-backoffice-ui/src/hooks/decision-request.ts @@ -86,6 +86,10 @@ export interface DecisionRequest { */ justification: string | undefined; /** + * Name of the account holder if this is an unknown account to the exchange + */ + accountName: string | undefined; + /** * Custom properties not listed on GANA */ custom_properties: Record<string, any> | undefined; @@ -116,6 +120,7 @@ export const codecForDecisionRequest = (): Codec<DecisionRequest> => .property("attributes", codecOptional(codecForAccountAttributes())) .property("custom_properties", codecForAny()) .property("justification", codecOptional(codecForString())) + .property("accountName", codecOptional(codecForString())) .property("custom_events", codecOptional(codecForList(codecForString()))) .property("triggering_events", codecOptional(codecForList(codecForString()))) .property( @@ -133,6 +138,7 @@ const defaultDecisionRequest: DecisionRequest = { deadline: undefined, onExpire_measures: undefined, custom_events: undefined, + accountName: undefined, justification: undefined, keep_investigating: false, new_measures: undefined, diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -203,6 +203,7 @@ export function CaseDetails({ onExpire_measures: undefined, custom_events: undefined, attributes: undefined, + accountName: undefined, triggering_events: undefined, justification: undefined, keep_investigating: false, diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -21,11 +21,13 @@ import { getURLHostnamePortPath, hashNormalizedPaytoUri, HttpStatusCode, + IbanError, parsePaytoUri, PaytoUri, stringifyPaytoUri, TalerError, TranslatedString, + validateIban, } from "@gnu-taler/taler-util"; import { Attention, @@ -614,45 +616,25 @@ function validateIBAN( if (!iban) { return i18n.str`required`; } - // Check total length - if (iban.length < 4) - return i18n.str`IBAN numbers usually have more that 4 digits`; - if (iban.length > 34) - return i18n.str`IBAN numbers usually have less that 34 digits`; - - const A_code = "A".charCodeAt(0); - const Z_code = "Z".charCodeAt(0); - const IBAN = iban.toUpperCase(); - - // check supported country - // const code = IBAN.substr(0, 2); - // const found = code in COUNTRY_TABLE; - // if (!found) return i18n.str`IBAN country code not found`; - - // 2.- Move the four initial characters to the end of the string - const step2 = IBAN.substr(4) + iban.substr(0, 4); - const step3 = Array.from(step2) - .map((letter) => { - const code = letter.charCodeAt(0); - if (code < A_code || code > Z_code) return letter; - return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`; - }) - .join(""); - - const checksum = calculate_iban_checksum(step3); - if (checksum !== 1) - return i18n.str`IBAN number is invalid, checksum is wrong`; - return undefined; -} -function calculate_iban_checksum(str: string): number { - const numberStr = str.substr(0, 5); - const rest = str.substr(5); - const number = parseInt(numberStr, 10); - const result = number % 97; - if (rest.length > 0) { - return calculate_iban_checksum(`${result}${rest}`); + const result = validateIban(iban); + if (result.type === "valid") { + return undefined; + } + switch (result.code) { + case IbanError.TOO_LONG: + return i18n.str`IBAN numbers usually have less that 34 digits`; + case IbanError.TOO_SHORT: + return i18n.str`IBAN numbers usually have more that 4 digits`; + case IbanError.INVALID_CHARSET: + return i18n.str`IBAN number is invalid, should only contain numbers and letters`; + case IbanError.INVALID_COUNTRY: + return i18n.str`Unsupported country`; + case IbanError.INVALID_CHECKSUM: + return i18n.str`IBAN number is invalid, checksum is wrong`; + default: { + assertUnreachable(result.code); + } } - return result; } const DOMAIN_REGEX = diff --git a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx @@ -69,24 +69,27 @@ const STEPS_ORDER_MAP = STEPS_ORDER.reduce( }, ); -function isAttributesCompleted(request: DecisionRequest): boolean { - return request.attributes === undefined || request.attributes.errors === undefined; +export function isRulesCompleted(request: DecisionRequest): boolean { + return request.rules !== undefined && request.deadline !== undefined; } -function isRulesCompleted(request: DecisionRequest): boolean { - return request.rules !== undefined; +export function isAttributesCompleted(request: DecisionRequest): boolean { + return request.attributes === undefined || request.attributes.errors === undefined; } -function isPropertiesCompleted(request: DecisionRequest): boolean { +export function isPropertiesCompleted(request: DecisionRequest): boolean { return request.properties !== undefined; } -function isEventsCompleted(request: DecisionRequest): boolean { +export function isEventsCompleted(request: DecisionRequest): boolean { return request.custom_events !== undefined; } -function isMeasuresCompleted(request: DecisionRequest): boolean { +export function isMeasuresCompleted(request: DecisionRequest): boolean { return request.new_measures !== undefined; } -function isJustificationCompleted(request: DecisionRequest): boolean { +export function isJustificationCompleted(request: DecisionRequest): boolean { return request.keep_investigating !== undefined && !!request.justification; } +export function isJustificationCompletedForNewACcount(request: DecisionRequest): boolean { + return request.keep_investigating !== undefined && !!request.justification && !!request.accountName; +} export function AmlDecisionRequestWizard({ account, @@ -114,7 +117,7 @@ export function AmlDecisionRequestWizard({ case "measures": return <Measures />; case "justification": - return <Justification />; + return <Justification newPayto={newPayto} />; case "attributes": return <Attributes formId={formId}/>; case "summary": @@ -125,7 +128,7 @@ export function AmlDecisionRequestWizard({ return ( <div> - <WizardSteps step={stepOrDefault} onMove={onMove} /> + <WizardSteps step={stepOrDefault} onMove={onMove} newAccount={!!newPayto} /> <button disabled={!STEPS_ORDER_MAP[stepOrDefault].prev} onClick={() => { @@ -151,9 +154,11 @@ export function AmlDecisionRequestWizard({ function WizardSteps({ step: currentStep, onMove, + newAccount, }: { step: WizardSteps; onMove: (n: WizardSteps | undefined) => void; + newAccount: boolean; }): VNode { const [request] = useCurrentDecisionRequest(); const { i18n } = useTranslationContext(); @@ -187,7 +192,7 @@ function WizardSteps({ justification: { label: i18n.str`Justification`, description: i18n.str`Describe the decision.`, - isCompleted: isJustificationCompleted, + isCompleted: newAccount ? isJustificationCompletedForNewACcount : isJustificationCompleted, }, properties: { label: i18n.str`Properties`, diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx @@ -2,6 +2,7 @@ import { AbsoluteTime, Duration, MeasureInformation, + PaytoString, TalerError, } from "@gnu-taler/taler-util"; import { @@ -22,7 +23,7 @@ import { useServerMeasures } from "../../hooks/server-info.js"; * @param param0 * @returns */ -export function Justification({}: {}): VNode { +export function Justification({ newPayto }: { newPayto?: PaytoString }): VNode { const { i18n } = useTranslationContext(); const [request, _, updateRequest] = useCurrentDecisionRequest(); const measures = useServerMeasures(); @@ -30,7 +31,9 @@ export function Justification({}: {}): VNode { !measures || measures instanceof TalerError || measures.type === "fail" ? [] : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); - const design = formDesign(i18n, measureList); + + const unknownAccount = !!newPayto + const design = formDesign(i18n, measureList, unknownAccount); const expMeasres: string[] = !request.onExpire_measures ? [] @@ -39,8 +42,8 @@ export function Justification({}: {}): VNode { const form = useForm<FormType>(design, { investigate: request.keep_investigating, justification: request.justification, - expiration: request.deadline, measures: expMeasres, + accountName: request.accountName, }); onComponentUnload(() => { @@ -49,10 +52,7 @@ export function Justification({}: {}): VNode { keep_investigating: !!form.status.result.investigate, justification: form.status.result.justification ?? "", onExpire_measures: (form.status.result.measures ?? []) as string[], - - deadline: - (form.status.result.expiration as AbsoluteTime) ?? AbsoluteTime.never(), - // onExpire_measures, + accountName: form.status.result.justification ?? "", }); }); @@ -65,14 +65,15 @@ export function Justification({}: {}): VNode { type FormType = { justification: string; + accountName: string; investigate: boolean; - expiration: AbsoluteTime; measures: string[]; }; const formDesign = ( i18n: InternationalizationAPI, mi: (MeasureInformation & { id: string })[], + unknownAccount: boolean ): FormDesign<FormType> => ({ type: "single-column", fields: [ @@ -88,32 +89,12 @@ const formDesign = ( label: i18n.str`Keep investigation?`, }, { - type: "choiceHorizontal", - label: i18n.str`Expiration`, - id: "expiration", - choices: [ - { - label: i18n.str`In a week`, - value: AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ days: 7 }), - ) as any, - }, - { - label: i18n.str`In a month`, - value: AbsoluteTime.addDuration( - AbsoluteTime.now(), - Duration.fromSpec({ months: 1 }), - ) as any, - }, - ], - }, - { - id: "expiration", - type: "absoluteTimeText", - placeholder: "dd/MM/yyyy", - pattern: "dd/MM/yyyy", - label: i18n.str`Expiration`, + id: "accountName", + type: "text", + label: i18n.str`Account holder`, + required: true, + help:i18n.str`Full name of the account holder`, + hidden: !unknownAccount, }, { type: "selectMultiple", diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -38,7 +38,7 @@ export function Rules({ account }: { account: string }): VNode { const { i18n } = useTranslationContext(); const { config } = useExchangeApiContext(); - const [request, updateRequest] = useCurrentDecisionRequest(); + const [request, updateRequestField, updateRequest] = useCurrentDecisionRequest(); const measures = useServerMeasures(); const measureList = @@ -47,7 +47,9 @@ export function Rules({ account }: { account: string }): VNode { : Object.entries(measures.body.roots).map(([id, mi]) => ({ id, ...mi })); const design = formDesign(i18n, config.config.currency, measureList); - const form = useForm<FormType>(design, {}); + const form = useForm<FormType>(design, { + expiration: request.deadline, + }); const info = !activeDecision || @@ -57,8 +59,15 @@ export function Rules({ account }: { account: string }): VNode { : activeDecision.body; onComponentUnload(() => { - if (!request.rules) { - updateRequest("rules", []); + if (!request.rules) { + updateRequestField("rules", []); + } else { + updateRequest({ + ...request, + deadline: + (form.status.result.expiration as AbsoluteTime) ?? AbsoluteTime.never(), + }); + } }); @@ -77,7 +86,7 @@ export function Rules({ account }: { account: string }): VNode { is_and_combinator: nr.all, measures, }); - updateRequest("rules", result); + updateRequestField("rules", result); } return ( @@ -104,7 +113,7 @@ export function Rules({ account }: { account: string }): VNode { <button onClick={() => { - updateRequest( + updateRequestField( "rules", Object.values(LimitOperationType).map((operation_type) => ({ display_priority: 1, @@ -125,7 +134,7 @@ export function Rules({ account }: { account: string }): VNode { </button> <button onClick={() => { - updateRequest( + updateRequestField( "rules", Object.values(LimitOperationType).map((operation_type) => ({ display_priority: 1, @@ -153,7 +162,7 @@ export function Rules({ account }: { account: string }): VNode { </button> <button onClick={() => { - updateRequest( + updateRequestField( "rules", Object.values(LimitOperationType).map((operation_type) => ({ display_priority: 1, @@ -184,7 +193,7 @@ export function Rules({ account }: { account: string }): VNode { onRemove={(r, idx) => { const nr = !request.rules ? [] : [...request.rules]; nr.splice(idx, 1); - updateRequest("rules", nr); + updateRequestField("rules", nr); }} /> @@ -213,6 +222,7 @@ type FormType = { threshold: AmountJson; timeframe: Duration; exposed: boolean; + expiration: AbsoluteTime; measures: string[]; all: boolean; }; @@ -300,5 +310,42 @@ const formDesign = ( label: i18n.str`All measures`, help: i18n.str`Hint the customer that all measure should be completed`, }, + { + type: "choiceHorizontal", + label: i18n.str`Expiration`, + help: i18n.str`Predefined shortcuts`, + id: "expiration", + choices: [ + { + label: i18n.str`In a week`, + value: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ days: 7 }), + ) as any, + }, + { + label: i18n.str`In a month`, + value: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ months: 1 }), + ) as any, + }, + { + label: i18n.str`In a year`, + value: AbsoluteTime.addDuration( + AbsoluteTime.now(), + Duration.fromSpec({ years: 1 }), + ) as any, + }, + ], + }, + { + id: "expiration", + type: "absoluteTimeText", + placeholder: "dd/MM/yyyy", + pattern: "dd/MM/yyyy", + label: i18n.str`Expiration`, + help: i18n.str`For how long this rules will last`, + }, ], }); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -2,9 +2,12 @@ import { AbsoluteTime, AmlDecisionRequest, assertUnreachable, + buildPayto, Duration, HttpStatusCode, + parsePaytoUri, PaytoString, + stringifyPaytoUri, TalerError, } from "@gnu-taler/taler-util"; import { @@ -24,7 +27,16 @@ import { } from "../CaseDetails.js"; import { CurrentMeasureTable, Mesaures } from "../MeasuresTable.js"; import { useOfficer } from "../../hooks/officer.js"; -import { WizardSteps } from "./AmlDecisionRequestWizard.js"; +import { + isAttributesCompleted, + isEventsCompleted, + isJustificationCompleted, + isJustificationCompletedForNewACcount, + isMeasuresCompleted, + isPropertiesCompleted, + isRulesCompleted, + WizardSteps, +} from "./AmlDecisionRequestWizard.js"; import { useCustomMeasures } from "../../hooks/custom-measures.js"; /** @@ -63,24 +75,26 @@ export function Summary({ const { lib } = useExchangeApiContext(); - const INVALID_RULES = !decision.deadline || !decision.rules; - const INVALID_MEASURES = decision.new_measures === undefined; - const INVALID_PROPERTIES = decision.properties === undefined; - const INVALID_EVENTS = false; //decision.inhibit_events === undefined; - const INVALID_JUSTIFICATION = - decision.justification === undefined || !decision.justification; + const isNewAccount = !!newPayto; const INVALID_ACCOUNT = !account; - const INVALID_ATTRIBUTES = - decision.attributes !== undefined && - decision.attributes.errors !== undefined; + const INVALID_RULES = !isRulesCompleted(decision); //!decision.deadline || !decision.rules; + const INVALID_MEASURES = !isMeasuresCompleted(decision); //.new_measures === undefined; + const INVALID_PROPERTIES = !isPropertiesCompleted(decision); //.properties === undefined; + const INVALID_EVENTS = !isEventsCompleted(decision); //false; //decision.inhibit_events === undefined; + const INVALID_JUSTIFICATION = isNewAccount + ? !isJustificationCompletedForNewACcount(decision) + : !isJustificationCompleted(decision); + const INVALID_ATTRIBUTES = !isAttributesCompleted(decision); + // decision.attributes !== undefined && + // decision.attributes.errors !== undefined; const CANT_SUBMIT = INVALID_ACCOUNT || - INVALID_EVENTS || - INVALID_JUSTIFICATION || + INVALID_RULES || INVALID_MEASURES || INVALID_PROPERTIES || - INVALID_RULES || + INVALID_EVENTS || + INVALID_JUSTIFICATION || INVALID_ATTRIBUTES; function clearUp() { @@ -90,6 +104,7 @@ export function Summary({ deadline: undefined, triggering_events: undefined, attributes: undefined, + accountName: undefined, justification: undefined, keep_investigating: false, new_measures: undefined, @@ -100,6 +115,11 @@ export function Summary({ onMove(undefined); } + const fullPayto = !newPayto? undefined: parsePaytoUri(newPayto) + if (fullPayto && decision.accountName) { + fullPayto.params["receiver-name"] = decision.accountName; + } + const submitHandler = CANT_SUBMIT || !session ? undefined @@ -111,7 +131,7 @@ export function Summary({ AbsoluteTime.now(), ), justification: decision.justification!, - payto_uri: newPayto, + payto_uri: !fullPayto ? undefined : stringifyPaytoUri(fullPayto), keep_investigating: decision.keep_investigating, new_rules: { expiration_time: AbsoluteTime.toProtocolTimestamp( @@ -192,8 +212,8 @@ export function Summary({ <ShowDecisionLimitInfo fixed since={AbsoluteTime.now()} - until={decision.deadline} - rules={decision.rules} + until={decision.deadline!} + rules={decision.rules!} startOpen /> </div> @@ -208,7 +228,7 @@ export function Summary({ You should specify in the measure section. </i18n.Translate> </Attention> - ) : decision.new_measures.length === 0 ? ( + ) : decision.new_measures!.length === 0 ? ( <Attention type="info" title={i18n.str`No customer action required.`} @@ -252,7 +272,7 @@ export function Summary({ onClose={() => onMove("justification")} > <i18n.Translate> - You must specify in the justification section. + You must complete the justification section. </i18n.Translate> </Attention> ) : ( diff --git a/packages/taler-util/src/iban.ts b/packages/taler-util/src/iban.ts @@ -28,8 +28,16 @@ * @author Florian Dold <dold@taler.net> */ +export enum IbanError { + INVALID_COUNTRY, + TOO_LONG, + TOO_SHORT, + INVALID_CHARSET, + INVALID_CHECKSUM, +} + export type IbanValidationResult = - | { type: "invalid" } + | { type: "invalid"; code: IbanError } | { type: "valid"; normalizedIban: string; @@ -228,13 +236,27 @@ function mod97(digits: number[]): number { } export function validateIban(ibanString: string): IbanValidationResult { - let myIban = ibanString.toLocaleUpperCase().replace(" ", ""); - let countryCode = myIban.substring(0, 2); - let countryInfo = ibanCountryInfoTable[countryCode]; + if (ibanString.length < 4) { + return { + type: "invalid", + code: IbanError.TOO_SHORT, + }; + } + if (ibanString.length > 34) { + return { + type: "invalid", + code: IbanError.TOO_LONG, + }; + } + + const myIban = ibanString.toLocaleUpperCase().replace(" ", ""); + const countryCode = myIban.substring(0, 2); + const countryInfo = ibanCountryInfoTable[countryCode]; if (!countryInfo) { return { type: "invalid", + code: IbanError.INVALID_COUNTRY, }; } @@ -245,6 +267,7 @@ export function validateIban(ibanString: string): IbanValidationResult { if (!appendDigit(digits, cc)) { return { type: "invalid", + code: IbanError.INVALID_CHARSET, }; } } @@ -253,6 +276,7 @@ export function validateIban(ibanString: string): IbanValidationResult { if (!appendDigit(digits, ibanString.charCodeAt(i))) { return { type: "invalid", + code: IbanError.INVALID_CHARSET, }; } } @@ -266,6 +290,7 @@ export function validateIban(ibanString: string): IbanValidationResult { } else { return { type: "invalid", + code: IbanError.INVALID_CHECKSUM, }; } } diff --git a/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts b/packages/taler-wallet-webextension/src/cta/DevExperiment/test.ts @@ -46,14 +46,14 @@ describe("DevExperiment CTA states", () => { ({ status }) => { expect(status).equals("error"); }, - ({ status, error }) => { - expect(status).equals("error"); + // ({ status, error }) => { + // expect(status).equals("error"); - if (!error) expect.fail(); - // if (!error.hasError) expect.fail(); - // if (error.operational) expect.fail(); - // expect(error.description).eq("ERROR_NO-URI-FOR-DEPOSIT"); - }, + // if (!error) expect.fail(); + // // if (!error.hasError) expect.fail(); + // // if (error.operational) expect.fail(); + // // expect(error.description).eq("ERROR_NO-URI-FOR-DEPOSIT"); + // }, ], TestingContext, );