wallet-core/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx

422 lines
13 KiB
TypeScript
Raw Normal View History

import { Fragment, VNode, h } from "preact";
import {
AbsoluteTime,
AmountJson,
Amounts,
TranslatedString,
} from "@gnu-taler/taler-util";
import { format } from "date-fns";
import { ArrowDownCircleIcon, ClockIcon } from "@heroicons/react/20/solid";
import { useState } from "preact/hooks";
import { NiceForm } from "../NiceForm.js";
import { FlexibleForm } from "../forms/index.js";
import { UIFormField } from "../handlers/forms.js";
import { Pages } from "../pages.js";
2023-07-20 22:01:35 +02:00
import { AmlExchangeBackend } from "../types.js";
import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { useOfficer } from "../hooks/useOfficer.js";
import { buildQuerySignature } from "../account.js";
import { useCaseDetails } from "../hooks/useCaseDetails.js";
import { handleNotOkResult } from "../utils/errors.js";
type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent;
type AmlFormEvent = {
type: "aml-form";
when: AbsoluteTime;
title: TranslatedString;
2023-07-20 22:01:35 +02:00
state: AmlExchangeBackend.AmlState;
threshold: AmountJson;
};
type KycCollectionEvent = {
type: "kyc-collection";
when: AbsoluteTime;
title: TranslatedString;
values: object;
provider: string;
};
type KycExpirationEvent = {
type: "kyc-expiration";
when: AbsoluteTime;
title: TranslatedString;
fields: string[];
};
type WithTime = { when: AbsoluteTime };
function selectSooner(a: WithTime, b: WithTime) {
return AbsoluteTime.cmp(a.when, b.when);
}
function getEventsFromAmlHistory(
2023-07-20 22:01:35 +02:00
aml: AmlExchangeBackend.AmlDecisionDetail[],
kyc: AmlExchangeBackend.KycDetail[],
): AmlEvent[] {
const ae: AmlEvent[] = aml.map((a) => {
return {
type: "aml-form",
state: a.new_state,
threshold: Amounts.parseOrThrow(a.new_threshold),
title: a.justification as TranslatedString,
when: {
t_ms:
a.decision_time.t_s === "never"
? "never"
: a.decision_time.t_s * 1000,
},
} as AmlEvent;
});
const ke = kyc.reduce((prev, k) => {
prev.push({
type: "kyc-collection",
title: "collection" as TranslatedString,
2023-05-26 15:09:56 +02:00
when: AbsoluteTime.fromProtocolTimestamp(k.collection_time),
values: !k.attributes ? {} : k.attributes,
provider: k.provider_section,
});
prev.push({
type: "kyc-expiration",
title: "expiration" as TranslatedString,
2023-05-26 15:09:56 +02:00
when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time),
fields: !k.attributes ? [] : Object.keys(k.attributes),
});
return prev;
}, [] as AmlEvent[]);
return ae.concat(ke).sort(selectSooner);
}
export function CaseDetails({ account: paytoHash }: { account: string }) {
const [selected, setSelected] = useState<AmlEvent | undefined>(undefined);
const officer = useOfficer();
const { i18n } = useTranslationContext();
if (officer.state !== "ready") {
return <HandleAccountNotReady officer={officer} />;
}
const signature =
officer.state === "ready"
? buildQuerySignature(officer.account.signingKey)
: undefined;
const details = useCaseDetails(officer.account.accountId, paytoHash, signature)
if (!details.ok && !details.loading) {
return handleNotOkResult(i18n)(details);
}
const aml_history = details.loading ? [] : details.data.aml_history
const kyc_attributes = details.loading ? [] : details.data.kyc_attributes
const events = getEventsFromAmlHistory(aml_history,kyc_attributes);
return (
<div>
<a
href={Pages.newFormEntry.url({ account: paytoHash })}
class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
>
New AML form
</a>
<header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
<h1 class="text-base font-semibold leading-7 text-black">
Case history
</h1>
</header>
<div class="flow-root">
<ul role="list">
{events.map((e, idx) => {
const isLast = events.length - 1 === idx;
return (
<li
class="hover:bg-gray-200 p-2 rounded cursor-pointer"
onClick={() => {
setSelected(e);
}}
>
<div class="relative pb-6">
{!isLast ? (
<span
class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200"
aria-hidden="true"
></span>
) : undefined}
<div class="relative flex space-x-3">
{(() => {
switch (e.type) {
case "aml-form": {
switch (e.state) {
2023-07-20 22:01:35 +02:00
case AmlExchangeBackend.AmlState.normal: {
return (
<div>
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
Normal
</span>
<span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
{e.threshold.currency}{" "}
{Amounts.stringifyValue(e.threshold)}
</span>
</div>
);
}
2023-07-20 22:01:35 +02:00
case AmlExchangeBackend.AmlState.pending: {
return (
<div>
<span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
Pending
</span>
<span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
{e.threshold.currency}{" "}
{Amounts.stringifyValue(e.threshold)}
</span>
</div>
);
}
2023-07-20 22:01:35 +02:00
case AmlExchangeBackend.AmlState.frozen: {
return (
<div>
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
Frozen
</span>
<span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
{e.threshold.currency}{" "}
{Amounts.stringifyValue(e.threshold)}
</span>
</div>
);
}
}
}
case "kyc-collection": {
return (
<ArrowDownCircleIcon class="h-8 w-8 text-green-700" />
);
}
case "kyc-expiration": {
return <ClockIcon class="h-8 w-8 text-gray-700" />;
}
}
})()}
<div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
<div>
<p class="text-sm text-gray-900">{e.title}</p>
</div>
<div class="whitespace-nowrap text-right text-sm text-gray-500">
{e.when.t_ms === "never" ? (
"never"
) : (
<time dateTime={format(e.when.t_ms, "dd MMM yyyy")}>
{format(e.when.t_ms, "dd MMM yyyy")}
</time>
)}
</div>
</div>
</div>
</div>
</li>
);
})}
</ul>
</div>
{selected && <ShowEventDetails event={selected} />}
{selected && <ShowConsolidated history={events} until={selected} />}
</div>
);
}
function ShowEventDetails({ event }: { event: AmlEvent }): VNode {
return <div>type {event.type}</div>;
}
function ShowConsolidated({
history,
until,
}: {
history: AmlEvent[];
until: AmlEvent;
}): VNode {
const cons = getConsolidated(history, until.when);
const form: FlexibleForm<Consolidated> = {
versionId: "1",
behavior: (form) => {
return {
aml: {
threshold: {
hidden: !form.aml
},
since: {
hidden: !form.aml
},
state: {
hidden: !form.aml
}
}
};
},
design: [
{
title: "AML" as TranslatedString,
fields: [
{
type: "amount",
props: {
label: "Threshold" as TranslatedString,
name: "aml.threshold",
},
},
{
type: "choiceHorizontal",
props: {
label: "State" as TranslatedString,
name: "aml.state",
converter: amlStateConverter,
choices: [
{
label: "Frozen" as TranslatedString,
2023-07-20 22:01:35 +02:00
value: AmlExchangeBackend.AmlState.frozen,
},
{
label: "Pending" as TranslatedString,
2023-07-20 22:01:35 +02:00
value: AmlExchangeBackend.AmlState.pending,
},
{
label: "Normal" as TranslatedString,
2023-07-20 22:01:35 +02:00
value: AmlExchangeBackend.AmlState.normal,
},
],
},
},
],
},
Object.entries(cons.kyc).length > 0
? {
title: "KYC" as TranslatedString,
fields: Object.entries(cons.kyc).map(([key, field]) => {
const result: UIFormField = {
type: "text",
props: {
label: key as TranslatedString,
name: `kyc.${key}.value`,
help: `${field.provider} since ${
field.since.t_ms === "never"
? "never"
: format(field.since.t_ms, "dd/MM/yyyy")
}` as TranslatedString,
},
};
return result;
}),
}
: undefined,
],
};
return (
<Fragment>
<h1 class="text-base font-semibold leading-7 text-black">
Consolidated information after{" "}
{until.when.t_ms === "never"
? "never"
: format(until.when.t_ms, "dd MMMM yyyy")}
</h1>
<NiceForm
key={`${String(Date.now())}`}
form={form}
initial={cons}
onUpdate={() => {}}
/>
</Fragment>
);
}
interface Consolidated {
aml: {
state: AmlExchangeBackend.AmlState;
threshold: AmountJson;
since: AbsoluteTime;
};
kyc: {
[field: string]: {
value: any;
provider: string;
since: AbsoluteTime;
};
};
}
function getConsolidated(
history: AmlEvent[],
when: AbsoluteTime,
): Consolidated {
const initial: Consolidated = {
aml: {
state: AmlExchangeBackend.AmlState.normal,
threshold: {
currency: "ARS",
value: 1000,
fraction: 0,
},
since: AbsoluteTime.never()
},
kyc: {},
};
return history.reduce((prev, cur) => {
if (AbsoluteTime.cmp(when, cur.when) < 0) {
return prev;
}
switch (cur.type) {
case "kyc-expiration": {
cur.fields.forEach((field) => {
delete prev.kyc[field];
});
break;
}
case "aml-form": {
prev.aml = {
since: cur.when,
state: cur.state,
threshold: cur.threshold
}
break;
}
case "kyc-collection": {
Object.keys(cur.values).forEach((field) => {
prev.kyc[field] = {
value: (cur.values as any)[field],
provider: cur.provider,
since: cur.when,
};
});
break;
}
}
return prev;
}, initial);
}
export const amlStateConverter = {
toStringUI: stringifyAmlState,
fromStringUI: parseAmlState,
};
2023-07-20 22:01:35 +02:00
function stringifyAmlState(s: AmlExchangeBackend.AmlState | undefined): string {
if (s === undefined) return "";
switch (s) {
2023-07-20 22:01:35 +02:00
case AmlExchangeBackend.AmlState.normal:
return "normal";
2023-07-20 22:01:35 +02:00
case AmlExchangeBackend.AmlState.pending:
return "pending";
2023-07-20 22:01:35 +02:00
case AmlExchangeBackend.AmlState.frozen:
return "frozen";
}
}
2023-07-20 22:01:35 +02:00
function parseAmlState(s: string | undefined): AmlExchangeBackend.AmlState {
switch (s) {
case "normal":
2023-07-20 22:01:35 +02:00
return AmlExchangeBackend.AmlState.normal;
case "pending":
2023-07-20 22:01:35 +02:00
return AmlExchangeBackend.AmlState.pending;
case "frozen":
2023-07-20 22:01:35 +02:00
return AmlExchangeBackend.AmlState.frozen;
default:
throw Error(`unknown AML state: ${s}`);
}
}