commit 122d888b3103497bd38666d22dfa650003a67894
parent 977787d6a827174f86976c978c0c4c60907f79df
Author: Sebastian <sebasjm@gmail.com>
Date: Sun, 26 Jan 2025 11:33:37 -0300
summary
Diffstat:
8 files changed, 356 insertions(+), 63 deletions(-)
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
@@ -704,8 +704,8 @@ function ShowMesaureInfo({
nextMeasures: string[][];
customMeasure: { [d: string]: TalerExchangeApi.MeasureInformation };
}): VNode {
- const measures = useServerMeasures();
const { i18n } = useTranslationContext();
+ const measures = useServerMeasures();
if (!measures) {
return <Loading />;
}
@@ -731,32 +731,7 @@ function ShowMesaureInfo({
assertUnreachable(measures.case);
}
}
- const summary: TalerExchangeApi.AvailableMeasureSummary = measures.body;
-
- const map: { [d: string]: MeasureInfo } = {};
-
- function addUpIntoMap([key, value]: [
- string,
- TalerExchangeApi.MeasureInformation,
- ]): void {
- if (value.check_name !== "SKIP") {
- map[key] = {
- name: key,
- context: value.context,
- program: summary.programs[value.prog_name],
- check: summary.checks[value.check_name],
- };
- } else {
- map[key] = {
- name: key,
- context: value.context,
- program: summary.programs[value.prog_name],
- };
- }
- }
-
- Object.entries(measures.body.roots).forEach(addUpIntoMap);
- Object.entries(customMeasure).forEach(addUpIntoMap);
+ const map = computeAvailableMesaures(measures.body, customMeasure);
const filteredMeasures = nextMeasures.filter((n) => !!n.length);
@@ -1497,3 +1472,34 @@ export function ShowMeasuresToSelect({
return <CurrentMeasureTable list={list} onSelect={onSelect} />;
}
+
+export function computeAvailableMesaures(
+ server: TalerExchangeApi.AvailableMeasureSummary,
+ custom: TalerExchangeApi.AvailableMeasureSummary["roots"],
+): { [name: string]: MeasureInfo } {
+ const result: { [d: string]: MeasureInfo } = {};
+
+ function addUpIntoMap([key, value]: [
+ string,
+ TalerExchangeApi.MeasureInformation,
+ ]): void {
+ if (value.check_name !== "SKIP") {
+ result[key] = {
+ name: key,
+ context: value.context,
+ program: server.programs[value.prog_name],
+ check: server.checks[value.check_name],
+ };
+ } else {
+ result[key] = {
+ name: key,
+ context: value.context,
+ program: server.programs[value.prog_name],
+ };
+ }
+ }
+ Object.entries(server.roots).forEach(addUpIntoMap);
+ Object.entries(custom).forEach(addUpIntoMap);
+
+ return result;
+}
diff --git a/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx b/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx
@@ -32,11 +32,43 @@ export function RulesInfo({
<Attention
title={i18n.str`There are no rules for operations`}
type="warning"
- />
+ >
+ <i18n.Translate>
+ This mean that all operation have no limit.
+ </i18n.Translate>
+ </Attention>
);
}
- const sorted = [...rules].sort(sortKycRules);
+ const OPERATION_TYPE_MISSING = {
+ [LimitOperationType.balance]: true,
+ [LimitOperationType.transaction]: true,
+ [LimitOperationType.withdraw]: true,
+ [LimitOperationType.deposit]: true,
+ [LimitOperationType.aggregate]: true,
+ [LimitOperationType.close]: true,
+ [LimitOperationType.refund]: true,
+ [LimitOperationType.merge]: true,
+ };
+
+ const sorted = [...rules].sort((a, b) => {
+ console.log(a.operation_type);
+ // to prevent iterate again we are using this sort function
+ // to save present operation type
+ OPERATION_TYPE_MISSING[a.operation_type] = false;
+ OPERATION_TYPE_MISSING[b.operation_type] = false;
+ return sortKycRules(a, b);
+ });
+ if (rules.length === 1) {
+ // if there is only one element, sort function is not called
+ OPERATION_TYPE_MISSING[rules[0].operation_type] = false;
+ }
+
+ console.log(OPERATION_TYPE_MISSING);
+ const missing = Object.entries(OPERATION_TYPE_MISSING)
+ .filter(([key, value]) => !!value)
+ .map(([key]) => key) as LimitOperationType[];
+ console.log(missing);
const hasActions = !!onEdit || !!onRemove;
@@ -69,19 +101,58 @@ export function RulesInfo({
scope="col"
class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
>
- <i18n.Translate>Actions</i18n.Translate>
+ {/* <i18n.Translate>Actions</i18n.Translate> */}
</th>
)}
</tr>
</thead>
- <tbody class="divide-y divide-gray-200">
+
+ <tbody id="thetable" class="divide-y divide-gray-200 bg-white ">
{sorted.map((r, idx) => {
return (
- <tr>
- <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left">
- {r.operation_type}
+ <tr class="even:bg-gray-200 ">
+ <td class="flex whitespace-nowrap py-2 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left">
+ <span class="mx-2">
+ {r.exposed ? (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
+ />
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
+ />
+ </svg>
+ ) : (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6 text-gray-500"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
+ />
+ </svg>
+ )}
+ </span>
+ <span>{r.operation_type}</span>
</td>
- <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
+ <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
{r.timeframe.d_us === "forever" ? (
<RenderAmount
value={Amounts.parseOrThrow(r.threshold)}
@@ -103,11 +174,18 @@ export function RulesInfo({
</i18n.Translate>
)}
</td>
- <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
+ <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
+ {r.is_and_combinator ? (
+ <span class="text-gray-500">
+ <i18n.Translate>(all)</i18n.Translate>
+ </span>
+ ) : (
+ <Fragment />
+ )}
{r.measures}
</td>
{!hasActions ? undefined : (
- <td class="relative flex justify-end whitespace-nowrap py-4 pl-3 pr-4 text-sm font-medium sm:pr-6">
+ <td class="relative flex justify-end whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6">
{!onEdit ? undefined : (
<button onClick={() => onEdit(r, idx)}>
<svg
@@ -151,6 +229,27 @@ export function RulesInfo({
})}
</tbody>
</table>
+ {!missing.length ? undefined : missing.length === 1 ? (
+ <Attention
+ type="warning"
+ title={i18n.str`There is an operation without limit`}
+ >
+ <i18n.Translate>
+ This mean that this operation can be used without limit:{" "}
+ {missing.join(", ")}
+ </i18n.Translate>
+ </Attention>
+ ) : (
+ <Attention
+ type="warning"
+ title={i18n.str`There are operations without limit`}
+ >
+ <i18n.Translate>
+ This mean that these operations can be used without limit:{" "}
+ {missing.join(", ")}
+ </i18n.Translate>
+ </Attention>
+ )}
</div>
</Fragment>
);
diff --git a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
@@ -69,23 +69,20 @@ const STEPS_ORDER_MAP = STEPS_ORDER.reduce(
},
);
-function isRulesCompleted(request: DecisionRequest): boolean {
+export function isRulesCompleted(request: DecisionRequest): boolean {
return request.rules !== 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 {
- return (
- request.keep_investigating !== undefined &&
- request.justification !== undefined
- );
+export function isJustificationCompleted(request: DecisionRequest): boolean {
+ return request.keep_investigating !== undefined && !!request.justification;
}
export function AmlDecisionRequestWizard({
@@ -112,7 +109,7 @@ export function AmlDecisionRequestWizard({
case "justification":
return <Justification />;
case "summary":
- return <Summary />;
+ return <Summary account={account} />;
}
assertUnreachable(stepOrDefault);
})();
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx
@@ -65,7 +65,7 @@ export function Events({}: {}): VNode {
const isInhibit =
request.inhibit_events !== undefined &&
request.inhibit_events.indexOf(cur.id) !== -1;
- prev[cur.id] = isInhibit;
+ prev[cur.id] = !isInhibit;
return prev;
},
{} as FormType["inhibit"],
@@ -82,7 +82,7 @@ export function Events({}: {}): VNode {
? []
: form.status.result.trigger.map((t) => t?.name!),
inhibit_events: Object.entries(form.status.result.inhibit ?? {})
- .filter(([key, inhibit]) => !!inhibit)
+ .filter(([key, inhibit]) => !inhibit)
.map(([key]) => key),
});
});
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx
@@ -79,6 +79,7 @@ const formDesign = (
{
id: "justification" as UIHandlerId,
type: "textArea",
+ required: true,
label: i18n.str`Justification`,
},
{
@@ -122,6 +123,7 @@ const formDesign = (
label: m.id,
};
}),
+ unique: true,
id: "measures" as UIHandlerId,
label: i18n.str`Expiration measure`,
help: i18n.str`Measures that the customer will need to satisfy after expiration.`,
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
@@ -64,6 +64,7 @@ function formDesign(
fields: [
{
type: "selectMultiple",
+ unique: true,
choices: mi.map((m) => {
return {
value: m.id,
@@ -72,7 +73,7 @@ function formDesign(
}),
id: "measures" as UIHandlerId,
label: i18n.str`Active measures`,
- help: i18n.str`Measures that the customer will need to satisfy while the current rules are active.`,
+ help: i18n.str`Measures that the customer will need to satisfy while the rules are active.`,
},
],
};
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
@@ -62,10 +62,12 @@ export function Rules({ account }: { account?: string }): VNode {
function addNewRule(nr: FormType) {
const result = !request.rules ? [] : [...request.rules];
- const clean = (nr.measures ?? []).filter((m) => !m);
+ const clean = (nr.measures ?? []).filter((m) => !!m);
const measures = !clean.length ? ["VERBOTEN"] : clean;
result.push({
- timeframe: Duration.toTalerProtocolDuration(nr.timeframe),
+ timeframe: !nr.timeframe
+ ? Duration.toTalerProtocolDuration(Duration.getForever())
+ : Duration.toTalerProtocolDuration(nr.timeframe),
threshold: Amounts.stringify(nr.threshold),
operation_type: nr.operation_type,
display_priority: 1,
@@ -266,7 +268,7 @@ const formDesign = (
{
id: "timeframe" as UIHandlerId,
type: "durationText",
- required: true,
+ // required: true,
placeholder: "1Y 2M 3D 4h 5m 6s",
label: i18n.str`Timeframe`,
help: `Use YMDhms next to a number as a unit for Year, Month, Day, hour, minute and seconds.`,
@@ -279,6 +281,7 @@ const formDesign = (
},
{
type: "selectMultiple",
+ unique: true,
choices: mi.map((m) => {
return {
value: m.id,
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
@@ -1,23 +1,208 @@
import {
- FormDesign,
- FormUI,
- InternationalizationAPI,
- onComponentUnload,
- UIHandlerId,
- useForm,
+ AbsoluteTime,
+ AmlDecisionRequest,
+ assertUnreachable,
+ HttpStatusCode,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Button,
+ LocalNotificationBanner,
+ useExchangeApiContext,
+ useLocalNotificationHandler,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { h, VNode } from "preact";
+import { Fragment, h, VNode } from "preact";
import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
+import { useServerMeasures } from "../../hooks/server-info.js";
+import {
+ computeAvailableMesaures,
+ ShowDecisionLimitInfo,
+} from "../CaseDetails.js";
+import { CurrentMeasureTable } from "../MeasuresTable.js";
+import { useOfficer } from "../../hooks/officer.js";
/**
* Mark for further investigation and explain decision
* @param param0
* @returns
*/
-export function Summary({}: {}): VNode {
+export function Summary({ account }: { account?: string }): VNode {
const { i18n } = useTranslationContext();
- const [request] = useCurrentDecisionRequest();
+ const [decision, _, updateDecision] = useCurrentDecisionRequest();
+ const measures = useServerMeasures();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.account : undefined;
+
+ const allMeasures =
+ !measures || measures instanceof TalerError || measures.type === "fail"
+ ? []
+ : Object.values(computeAvailableMesaures(measures.body, {}));
+
+ const d = decision.new_measures === undefined ? [] : decision.new_measures;
+ const activeMeasureInfo = allMeasures.filter((m) => d.indexOf(m.name) !== -1);
+
+ 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 = decision.inhibit_events === undefined;
+ const INVALID_JUSTIFICATION =
+ decision.justification === undefined || !decision.justification;
+ const INVALID_ACCOUNT = !account;
+ const CANT_SUBMIT =
+ INVALID_ACCOUNT ||
+ INVALID_EVENTS ||
+ INVALID_JUSTIFICATION ||
+ INVALID_MEASURES ||
+ INVALID_PROPERTIES ||
+ INVALID_RULES;
+
+ const submitHandler =
+ CANT_SUBMIT || !session
+ ? undefined
+ : withErrorHandler(
+ () => {
+ const request: Omit<AmlDecisionRequest, "officer_sig"> = {
+ h_payto: account,
+ decision_time: AbsoluteTime.toProtocolTimestamp(
+ AbsoluteTime.now(),
+ ),
+ justification: decision.justification!,
+ keep_investigating: decision.keep_investigating,
+ new_rules: {
+ expiration_time: AbsoluteTime.toProtocolTimestamp(
+ decision.deadline!,
+ ),
+ rules: decision.rules!,
+ successor_measure: decision.onExpire_measures!.join(" "),
+ custom_measures: {}, // TODO: compute custom measures
+ },
+ properties: decision.properties!, // TODO: compute properites
+ new_measures: decision.new_measures!.join(" "),
+ };
+ return lib.exchange.makeAmlDesicion(session, request);
+ },
+ () => {
+ updateDecision({
+ custom_events: undefined,
+ custom_properties: undefined,
+ deadline: undefined,
+ inhibit_events: undefined,
+ justification: undefined,
+ keep_investigating: false,
+ new_measures: undefined,
+ onExpire_measures: undefined,
+ properties: undefined,
+ rules: undefined,
+ });
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Forbidden:
+ if (session) {
+ return i18n.str`Wrong credentials for "${session}"`;
+ } else {
+ return i18n.str`Wrong credentials.`;
+ }
+ case HttpStatusCode.NotFound:
+ return i18n.str`The account was not found`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`Officer disabled or more recent decision was already submitted.`;
+ default:
+ assertUnreachable(fail);
+ }
+ },
+ );
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ {INVALID_RULES ? (
+ <Fragment>
+ {!decision.deadline && (
+ <Attention type="danger" title={i18n.str`Missing deadline`}>
+ <i18n.Translate>
+ Deadline should specify when this rules ends and what is the
+ next measures to apply after expiration.
+ </i18n.Translate>
+ </Attention>
+ )}
+ {!decision.rules && (
+ <Attention type="danger" title={i18n.str`Missing rules`}>
+ <i18n.Translate>
+ Can't make a decision without rules.
+ </i18n.Translate>
+ </Attention>
+ )}
+ </Fragment>
+ ) : (
+ <div>
+ <h2 class="mt-4 mb-2">
+ <i18n.Translate>New rules</i18n.Translate>
+ </h2>
+ <ShowDecisionLimitInfo
+ fixed
+ since={AbsoluteTime.now()}
+ until={decision.deadline}
+ rules={decision.rules}
+ startOpen
+ />
+ </div>
+ )}
+ {INVALID_MEASURES ? (
+ <Attention type="danger" title={i18n.str`Missing active measure`}>
+ <i18n.Translate>
+ You should specify in the measure section.
+ </i18n.Translate>
+ </Attention>
+ ) : decision.new_measures.length === 0 ? (
+ <Attention type="info" title={i18n.str`No customer action required.`}>
+ <i18n.Translate>No active measure has been selected.</i18n.Translate>
+ </Attention>
+ ) : (
+ <CurrentMeasureTable list={activeMeasureInfo} />
+ )}
+ {INVALID_PROPERTIES ? (
+ <Attention type="danger" title={i18n.str`Missing properties`}>
+ <i18n.Translate>
+ You should specify in the properties section.
+ </i18n.Translate>
+ </Attention>
+ ) : (
+ <div />
+ )}
+ {INVALID_EVENTS ? (
+ <Attention type="danger" title={i18n.str`Missing events`}>
+ <i18n.Translate>
+ You should specify in the properties section.
+ </i18n.Translate>
+ </Attention>
+ ) : (
+ <div />
+ )}
+ {INVALID_JUSTIFICATION ? (
+ <Attention type="danger" title={i18n.str`Missing justification`}>
+ <i18n.Translate>
+ You should specify in the properties section.
+ </i18n.Translate>
+ </Attention>
+ ) : (
+ <div />
+ )}
- return <div>summary</div>;
+ <Button
+ type="submit"
+ handler={submitHandler}
+ disabled={!submitHandler}
+ class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Send decision</i18n.Translate>
+ </Button>
+ </Fragment>
+ );
}