2023-05-25 23:08:20 +02:00
|
|
|
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";
|
2023-07-21 20:50:53 +02:00
|
|
|
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";
|
2023-05-25 23:08:20 +02:00
|
|
|
|
|
|
|
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;
|
2023-05-25 23:08:20 +02:00
|
|
|
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[],
|
2023-05-25 23:08:20 +02:00
|
|
|
): 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),
|
2023-05-25 23:08:20 +02:00
|
|
|
values: !k.attributes ? {} : k.attributes,
|
|
|
|
provider: k.provider_section,
|
|
|
|
});
|
|
|
|
prev.push({
|
|
|
|
type: "kyc-expiration",
|
2023-07-21 20:50:53 +02:00
|
|
|
title: "expiration" as TranslatedString,
|
2023-05-26 15:09:56 +02:00
|
|
|
when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time),
|
2023-05-25 23:08:20 +02:00
|
|
|
fields: !k.attributes ? [] : Object.keys(k.attributes),
|
|
|
|
});
|
|
|
|
return prev;
|
|
|
|
}, [] as AmlEvent[]);
|
|
|
|
return ae.concat(ke).sort(selectSooner);
|
|
|
|
}
|
|
|
|
|
2023-07-21 20:50:53 +02:00
|
|
|
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);
|
|
|
|
|
2023-05-25 23:08:20 +02:00
|
|
|
return (
|
|
|
|
<div>
|
|
|
|
<a
|
2023-07-21 20:50:53 +02:00
|
|
|
href={Pages.newFormEntry.url({ account: paytoHash })}
|
2023-05-25 23:08:20 +02:00
|
|
|
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: {
|
2023-05-25 23:08:20 +02:00
|
|
|
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: {
|
2023-05-25 23:08:20 +02:00
|
|
|
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: {
|
2023-05-25 23:08:20 +02:00
|
|
|
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) => {
|
2023-07-21 20:50:53 +02:00
|
|
|
return {
|
|
|
|
aml: {
|
|
|
|
threshold: {
|
|
|
|
hidden: !form.aml
|
|
|
|
},
|
|
|
|
since: {
|
|
|
|
hidden: !form.aml
|
|
|
|
},
|
|
|
|
state: {
|
|
|
|
hidden: !form.aml
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
2023-05-25 23:08:20 +02:00
|
|
|
},
|
|
|
|
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,
|
2023-05-25 23:08:20 +02:00
|
|
|
},
|
|
|
|
{
|
|
|
|
label: "Pending" as TranslatedString,
|
2023-07-20 22:01:35 +02:00
|
|
|
value: AmlExchangeBackend.AmlState.pending,
|
2023-05-25 23:08:20 +02:00
|
|
|
},
|
|
|
|
{
|
|
|
|
label: "Normal" as TranslatedString,
|
2023-07-20 22:01:35 +02:00
|
|
|
value: AmlExchangeBackend.AmlState.normal,
|
2023-05-25 23:08:20 +02:00
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
|
|
|
},
|
|
|
|
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: {
|
2023-07-21 20:50:53 +02:00
|
|
|
state: AmlExchangeBackend.AmlState;
|
|
|
|
threshold: AmountJson;
|
2023-05-25 23:08:20 +02:00
|
|
|
since: AbsoluteTime;
|
|
|
|
};
|
|
|
|
kyc: {
|
|
|
|
[field: string]: {
|
|
|
|
value: any;
|
|
|
|
provider: string;
|
|
|
|
since: AbsoluteTime;
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
function getConsolidated(
|
|
|
|
history: AmlEvent[],
|
|
|
|
when: AbsoluteTime,
|
|
|
|
): Consolidated {
|
|
|
|
const initial: Consolidated = {
|
|
|
|
aml: {
|
2023-07-21 20:50:53 +02:00
|
|
|
state: AmlExchangeBackend.AmlState.normal,
|
|
|
|
threshold: {
|
|
|
|
currency: "ARS",
|
|
|
|
value: 1000,
|
|
|
|
fraction: 0,
|
|
|
|
},
|
|
|
|
since: AbsoluteTime.never()
|
2023-05-25 23:08:20 +02:00
|
|
|
},
|
|
|
|
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": {
|
2023-07-21 20:50:53 +02:00
|
|
|
prev.aml = {
|
|
|
|
since: cur.when,
|
|
|
|
state: cur.state,
|
|
|
|
threshold: cur.threshold
|
|
|
|
}
|
2023-05-25 23:08:20 +02:00
|
|
|
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 {
|
2023-05-25 23:08:20 +02:00
|
|
|
if (s === undefined) return "";
|
|
|
|
switch (s) {
|
2023-07-20 22:01:35 +02:00
|
|
|
case AmlExchangeBackend.AmlState.normal:
|
2023-05-25 23:08:20 +02:00
|
|
|
return "normal";
|
2023-07-20 22:01:35 +02:00
|
|
|
case AmlExchangeBackend.AmlState.pending:
|
2023-05-25 23:08:20 +02:00
|
|
|
return "pending";
|
2023-07-20 22:01:35 +02:00
|
|
|
case AmlExchangeBackend.AmlState.frozen:
|
2023-05-25 23:08:20 +02:00
|
|
|
return "frozen";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-20 22:01:35 +02:00
|
|
|
function parseAmlState(s: string | undefined): AmlExchangeBackend.AmlState {
|
2023-05-25 23:08:20 +02:00
|
|
|
switch (s) {
|
|
|
|
case "normal":
|
2023-07-20 22:01:35 +02:00
|
|
|
return AmlExchangeBackend.AmlState.normal;
|
2023-05-25 23:08:20 +02:00
|
|
|
case "pending":
|
2023-07-20 22:01:35 +02:00
|
|
|
return AmlExchangeBackend.AmlState.pending;
|
2023-05-25 23:08:20 +02:00
|
|
|
case "frozen":
|
2023-07-20 22:01:35 +02:00
|
|
|
return AmlExchangeBackend.AmlState.frozen;
|
2023-05-25 23:08:20 +02:00
|
|
|
default:
|
|
|
|
throw Error(`unknown AML state: ${s}`);
|
|
|
|
}
|
|
|
|
}
|