diff options
-rw-r--r-- | packages/aml-backoffice-ui/src/account.ts | 19 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/handlers/FormProvider.tsx | 2 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/hooks/useBackend.ts | 8 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts | 162 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/hooks/useCases.ts | 53 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/pages.ts | 2 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 131 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/pages/Cases.tsx | 53 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx | 40 | ||||
-rw-r--r-- | packages/aml-backoffice-ui/src/types.ts | 36 |
10 files changed, 362 insertions, 144 deletions
diff --git a/packages/aml-backoffice-ui/src/account.ts b/packages/aml-backoffice-ui/src/account.ts index 1c8cd7f53..2225bf2ff 100644 --- a/packages/aml-backoffice-ui/src/account.ts +++ b/packages/aml-backoffice-ui/src/account.ts @@ -1,6 +1,7 @@ import { - PaytoUri, + Amounts, TalerSignaturePurpose, + amountToBuffer, bufferForUint32, buildSigPS, createEddsaKeyPair, @@ -11,8 +12,10 @@ import { encodeCrock, encryptWithDerivedKey, getRandomBytesF, + hash, + hashTruncate32, stringToBytes, - stringifyPaytoUri, + timestampRoundedToBuffer } from "@gnu-taler/taler-util"; import { AmlExchangeBackend } from "./types.js"; @@ -60,12 +63,16 @@ export function buildQuerySignature(key: SigningKey): string { } export function buildDecisionSignature( key: SigningKey, - payto: PaytoUri, - state: AmlExchangeBackend.AmlState, + decision: AmlExchangeBackend.AmlDecision, ): string { + const sigBlob = buildSigPS(TalerSignaturePurpose.TALER_SIGNATURE_AML_DECISION) - .put(decodeCrock(stringifyPaytoUri(payto))) - .put(bufferForUint32(state)) + .put(hash(stringToBytes(decision.justification))) + // .put(timestampRoundedToBuffer(decision.decision_time)) + .put(amountToBuffer(decision.new_threshold)) + .put(decodeCrock(decision.h_payto)) + // .put(hash(stringToBytes(decision.kyc_requirements))) + .put(bufferForUint32(decision.new_state)) .build(); return encodeCrock(eddsaSign(sigBlob, key)); diff --git a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx index a195c2051..3da2a4f07 100644 --- a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx +++ b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx @@ -29,7 +29,7 @@ export type FormState<T> = { ? Partial<InputFieldState> : T[field] extends Array<infer P> ? Partial<InputArrayFieldState<P>> - : T[field] extends object + : T[field] extends (object | undefined) ? FormState<T[field]> : Partial<InputFieldState>; }; diff --git a/packages/aml-backoffice-ui/src/hooks/useBackend.ts b/packages/aml-backoffice-ui/src/hooks/useBackend.ts index e68efb2e3..b9d66fca6 100644 --- a/packages/aml-backoffice-ui/src/hooks/useBackend.ts +++ b/packages/aml-backoffice-ui/src/hooks/useBackend.ts @@ -14,7 +14,7 @@ interface useBackendType { path: string, options?: RequestOptions, ) => Promise<HttpResponseOk<T>>; - fetcher: <T>(endpoint: string) => Promise<HttpResponseOk<T>>; + fetcher: <T>(args: [string, string]) => Promise<HttpResponseOk<T>>; paginatedFetcher: <T>( args: [string, number, number, string], ) => Promise<HttpResponseOk<T>>; @@ -35,8 +35,10 @@ export function usePublicBackend(): useBackendType { ); const fetcher = useCallback( - function fetcherImpl<T>(endpoint: string): Promise<HttpResponseOk<T>> { - return requestHandler<T>(baseUrl, endpoint); + function fetcherImpl<T>([endpoint, talerAmlOfficerSignature]: [string,string]): Promise<HttpResponseOk<T>> { + return requestHandler<T>(baseUrl, endpoint, { + talerAmlOfficerSignature + }); }, [baseUrl], ); diff --git a/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts new file mode 100644 index 000000000..980a35f21 --- /dev/null +++ b/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts @@ -0,0 +1,162 @@ + +import { + HttpResponse, + HttpResponseOk, + RequestError +} from "@gnu-taler/web-util/browser"; +import { AmlExchangeBackend } from "../types.js"; +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 +import _useSWR, { SWRHook, useSWRConfig } from "swr"; +import { AccountId } from "../account.js"; +import { usePublicBackend } from "./useBackend.js"; +const useSWR = _useSWR as unknown as SWRHook; + +export function useCaseDetails( + account: AccountId, + paytoHash: string, + signature: string | undefined, +): HttpResponse< + AmlExchangeBackend.AmlDecisionDetails, + AmlExchangeBackend.AmlError +> { + const { fetcher } = usePublicBackend(); + + const { data, error } = useSWR< + HttpResponseOk<AmlExchangeBackend.AmlDecisionDetails>, + RequestError<AmlExchangeBackend.AmlError> +>( [ + `aml/${account}/decision/${(paytoHash)}`, + signature, +], +fetcher, { + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); + + if (data) return data; + if (error) return error.cause; + return { loading: true }; +} + +const example1: AmlExchangeBackend.AmlDecisionDetails = { + aml_history: [ + { + justification: "Lack of documentation", + decider_pub: "ASDASDASD", + decision_time: { + t_s: Date.now() / 1000, + }, + new_state: 2, + new_threshold: "USD:0", + }, + { + justification: "Doing a transfer of high amount", + decider_pub: "ASDASDASD", + decision_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6, + }, + new_state: 1, + new_threshold: "USD:2000", + }, + { + justification: "Account is known to the system", + decider_pub: "ASDASDASD", + decision_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9, + }, + new_state: 0, + new_threshold: "USD:100", + }, + ], + kyc_attributes: [ + { + collection_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8, + }, + expiration_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4, + }, + provider_section: "asdasd", + attributes: { + name: "Sebastian", + }, + }, + { + collection_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5, + }, + expiration_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2, + }, + provider_section: "asdasd", + attributes: { + creditCard: "12312312312", + }, + }, + ], +}; + +export const exampleResponse: HttpResponse<AmlExchangeBackend.AmlDecisionDetails,AmlExchangeBackend.AmlError> = { + ok: true, + data: example1, +} + + +export function useAmlCasesAPI(): AmlCaseAPI { + const { request } = usePublicBackend(); + const mutateAll = useMatchMutate(); + + const updateDecision = async ( + officer: AccountId, + data: AmlExchangeBackend.AmlDecision, + ): Promise<HttpResponseOk<void>> => { + const res = await request<void>(`aml/${officer}/decision`, { + method: "POST", + data, + contentType: "json", + }); + await mutateAll(/.*aml.*/); + return res; + }; + + return { + updateDecision, + }; +} + +export interface AmlCaseAPI { + updateDecision: ( + officer: AccountId, + data: AmlExchangeBackend.AmlDecision, + ) => Promise<HttpResponseOk<void>>; +} + + +function useMatchMutate(): ( + re: RegExp, + value?: unknown, +) => Promise<any> { + const { cache, mutate } = useSWRConfig(); + + if (!(cache instanceof Map)) { + throw new Error( + "matchMutate requires the cache provider to be a Map instance", + ); + } + + return function matchRegexMutate(re: RegExp, value?: unknown) { + const allKeys = Array.from(cache.keys()); + const keys = allKeys.filter((key) => re.test(key)); + const mutations = keys.map((key) => { + return mutate(key, value, true); + }); + return Promise.all(mutations); + }; +} diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/useCases.ts index 04b7c383b..c5a0fc489 100644 --- a/packages/aml-backoffice-ui/src/hooks/useCases.ts +++ b/packages/aml-backoffice-ui/src/hooks/useCases.ts @@ -92,3 +92,56 @@ export function useCases( } return { loading: true }; } + +const example1: AmlExchangeBackend.AmlRecords = { + records: [ + { + current_state: 0, + h_payto: "QWEQWEQWEQWEWQE", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 1, + h_payto: "ASDASDASD", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 2, + h_payto: "ZXCZXCZXCXZC", + rowid: 1, + threshold: "USD 1000", + }, + { + current_state: 0, + h_payto: "QWEQWEQWEQWEWQE", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 1, + h_payto: "ASDASDASD", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 2, + h_payto: "ZXCZXCZXCXZC", + rowid: 1, + threshold: "USD 1000", + }, + ].map((e, idx) => { + e.rowid = idx; + e.threshold = `${e.threshold}${idx}`; + return e; + }), +}; + +export const exampleResponse: HttpResponsePaginated<AmlExchangeBackend.AmlRecords,AmlExchangeBackend.AmlError> = { + ok: true, + data: example1, + loadMore: () => {}, + loadMorePrev: () => {}, +} + diff --git a/packages/aml-backoffice-ui/src/pages.ts b/packages/aml-backoffice-ui/src/pages.ts index 18fb7a158..e4e16f05f 100644 --- a/packages/aml-backoffice-ui/src/pages.ts +++ b/packages/aml-backoffice-ui/src/pages.ts @@ -16,7 +16,7 @@ const cases: PageEntry = { url: "#/cases", view: Cases, }; -const account: PageEntry<{ account?: string }> = { +const account: PageEntry<{ account: string }> = { url: pageDefinition("#/account/:account"), view: CaseDetails, }; diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx index d02d8b395..ce820d612 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -13,64 +13,13 @@ import { FlexibleForm } from "../forms/index.js"; import { UIFormField } from "../handlers/forms.js"; import { Pages } from "../pages.js"; 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"; -const response: AmlExchangeBackend.AmlDecisionDetails = { - aml_history: [ - { - justification: "Lack of documentation", - decider_pub: "ASDASDASD", - decision_time: { - t_s: Date.now() / 1000, - }, - new_state: 2, - new_threshold: "USD:0", - }, - { - justification: "Doing a transfer of high amount", - decider_pub: "ASDASDASD", - decision_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6, - }, - new_state: 1, - new_threshold: "USD:2000", - }, - { - justification: "Account is known to the system", - decider_pub: "ASDASDASD", - decision_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9, - }, - new_state: 0, - new_threshold: "USD:100", - }, - ], - kyc_attributes: [ - { - collection_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8, - }, - expiration_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4, - }, - provider_section: "asdasd", - attributes: { - name: "Sebastian", - }, - }, - { - collection_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5, - }, - expiration_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2, - }, - provider_section: "asdasd", - attributes: { - creditCard: "12312312312", - }, - }, - ], -}; type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent; type AmlFormEvent = { type: "aml-form"; @@ -127,7 +76,7 @@ function getEventsFromAmlHistory( }); prev.push({ type: "kyc-expiration", - title: "expired" as TranslatedString, + title: "expiration" as TranslatedString, when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time), fields: !k.attributes ? [] : Object.keys(k.attributes), }); @@ -136,19 +85,30 @@ function getEventsFromAmlHistory( return ae.concat(ke).sort(selectSooner); } -export function CaseDetails({ account }: { account?: string }) { - const events = getEventsFromAmlHistory( - response.aml_history, - response.kyc_attributes, - ); - console.log("DETAILS", events, events[events.length - 1 - 2]); - const [selected, setSelected] = useState<AmlEvent>( - events[events.length - 1 - 2], - ); +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 })} + 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 @@ -271,13 +231,24 @@ function ShowConsolidated({ history: AmlEvent[]; until: AmlEvent; }): VNode { - console.log("UNTIL", until); const cons = getConsolidated(history, until.when); const form: FlexibleForm<Consolidated> = { versionId: "1", behavior: (form) => { - return {}; + return { + aml: { + threshold: { + hidden: !form.aml + }, + since: { + hidden: !form.aml + }, + state: { + hidden: !form.aml + } + } + }; }, design: [ { @@ -356,8 +327,8 @@ function ShowConsolidated({ interface Consolidated { aml: { - state?: AmlExchangeBackend.AmlState; - threshold?: AmountJson; + state: AmlExchangeBackend.AmlState; + threshold: AmountJson; since: AbsoluteTime; }; kyc: { @@ -375,7 +346,13 @@ function getConsolidated( ): Consolidated { const initial: Consolidated = { aml: { - since: AbsoluteTime.never(), + state: AmlExchangeBackend.AmlState.normal, + threshold: { + currency: "ARS", + value: 1000, + fraction: 0, + }, + since: AbsoluteTime.never() }, kyc: {}, }; @@ -391,9 +368,11 @@ function getConsolidated( break; } case "aml-form": { - prev.aml.threshold = cur.threshold; - prev.aml.state = cur.state; - prev.aml.since = cur.when; + prev.aml = { + since: cur.when, + state: cur.state, + threshold: cur.threshold + } break; } case "kyc-collection": { diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx index 8b115ed7e..990c0d2d4 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -12,59 +12,6 @@ import { buildQuerySignature } from "../account.js"; import { handleNotOkResult } from "../utils/errors.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -const response: AmlExchangeBackend.AmlRecords = { - records: [ - { - current_state: 0, - h_payto: "QWEQWEQWEQWEWQE", - rowid: 1, - threshold: "USD 100", - }, - { - current_state: 1, - h_payto: "ASDASDASD", - rowid: 1, - threshold: "USD 100", - }, - { - current_state: 2, - h_payto: "ZXCZXCZXCXZC", - rowid: 1, - threshold: "USD 1000", - }, - { - current_state: 0, - h_payto: "QWEQWEQWEQWEWQE", - rowid: 1, - threshold: "USD 100", - }, - { - current_state: 1, - h_payto: "ASDASDASD", - rowid: 1, - threshold: "USD 100", - }, - { - current_state: 2, - h_payto: "ZXCZXCZXCXZC", - rowid: 1, - threshold: "USD 1000", - }, - ].map((e, idx) => { - e.rowid = idx; - e.threshold = `${e.threshold}${idx}`; - return e; - }), -}; - -function doFilter( - list: typeof response.records, - filter: AmlExchangeBackend.AmlState | undefined, -): typeof response.records { - if (filter === undefined) return list; - return list.filter((r) => r.current_state === filter); -} - export function Cases() { const officer = useOfficer(); const { i18n } = useTranslationContext(); diff --git a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx index bbd04daee..13e78b169 100644 --- a/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx +++ b/packages/aml-backoffice-ui/src/pages/NewFormEntry.tsx @@ -2,8 +2,12 @@ import { VNode, h } from "preact"; import { allForms } from "./AntiMoneyLaunderingForm.js"; import { Pages } from "../pages.js"; import { NiceForm } from "../NiceForm.js"; -import { AbsoluteTime, Amounts } from "@gnu-taler/taler-util"; +import { AbsoluteTime, Amounts, TalerProtocolTimestamp } from "@gnu-taler/taler-util"; import { AmlExchangeBackend } from "../types.js"; +import { useAmlCasesAPI } from "../hooks/useCaseDetails.js"; +import { useOfficer } from "../hooks/useOfficer.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +import { buildDecisionSignature, buildQuerySignature } from "../account.js"; export function NewFormEntry({ account, @@ -12,30 +16,58 @@ export function NewFormEntry({ account?: string; type?: string; }): VNode { + const officer = useOfficer(); + if (!account) { return <div>no account</div>; } if (!type) { return <SelectForm account={account} />; } + if (officer.state !== "ready") { + return <HandleAccountNotReady officer={officer} />; + } const selectedForm = Number.parseInt(type ?? "0", 10); if (Number.isNaN(selectedForm)) { return <div>WHAT! {type}</div>; } const showingFrom = allForms[selectedForm].impl; + const formName = allForms[selectedForm].name const initial = { fullName: "loggedIn_user_fullname", when: AbsoluteTime.now(), state: AmlExchangeBackend.AmlState.pending, - threshold: Amounts.parseOrThrow("USD:10"), + threshold: Amounts.parseOrThrow("ARS:1000"), }; + const api = useAmlCasesAPI() + return ( <NiceForm initial={initial} form={showingFrom(initial)} - onSubmit={(v) => { - alert(JSON.stringify(v)); + onSubmit={(formValue) => { + if (formValue.state === undefined || formValue.threshold === undefined) return; + + const justification = { + index: selectedForm, + name: formName, + value: formValue + } + const decision: AmlExchangeBackend.AmlDecision = { + justification: JSON.stringify(justification), + decision_time: TalerProtocolTimestamp.now(), + h_payto: account, + new_state: formValue.state, + new_threshold: Amounts.stringify(formValue.threshold), + officer_sig: "", + kyc_requirements: undefined + } + const signature = buildDecisionSignature(officer.account.signingKey, decision); + decision.officer_sig = signature + api.updateDecision(officer.account.accountId, decision); + + // alert(JSON.stringify(formValue)); }} > <div class="mt-6 flex items-center justify-end gap-x-6"> diff --git a/packages/aml-backoffice-ui/src/types.ts b/packages/aml-backoffice-ui/src/types.ts index 104d938b3..429b538e7 100644 --- a/packages/aml-backoffice-ui/src/types.ts +++ b/packages/aml-backoffice-ui/src/types.ts @@ -59,6 +59,9 @@ export namespace AmlExchangeBackend { type PaytoHash = string; type Integer = number; type Amount = string; + // EdDSA signatures are transmitted as 64-bytes base32 + // binary-encoded objects with just the R and S values (base32_ binary-only). + type EddsaSignature = string; export interface AmlRecords { // Array of AML records matching the query. @@ -85,4 +88,37 @@ export namespace AmlExchangeBackend { pending = 1, frozen = 2, } + + + export interface AmlDecision { + + // Human-readable justification for the decision. + justification: string; + + // At what monthly transaction volume should the + // decision be automatically reviewed? + new_threshold: Amount; + + // Which payto-address is the decision about? + // Identifies a GNU Taler wallet or an affected bank account. + h_payto: PaytoHash; + + // What is the new AML state (e.g. frozen, unfrozen, etc.) + // Numerical values are defined in AmlDecisionState. + new_state: Integer; + + // Signature by the AML officer over a + // TALER_MasterAmlOfficerStatusPS. + // Must have purpose TALER_SIGNATURE_MASTER_AML_KEY. + officer_sig: EddsaSignature; + + // When was the decision made? + decision_time: Timestamp; + + // Optional argument to impose new KYC requirements + // that the customer has to satisfy to unblock transactions. + kyc_requirements?: string[]; + } + + } |