diff --git a/.env.example b/.env.example index 68300ecaac..6563e83838 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,8 @@ KYC_TRANSACTION_PREFIX= KYC_APP_TOKEN= KYC_SECRET_KEY= KYC_WEBHOOK_KEY= +# Keep false until all eight legacy DfxApproval Apps Script timers have been disabled. +KYC_DFX_APPROVAL_WORKFLOW_ENABLED=false MAIL_USER= MAIL_PASS= diff --git a/.env.local.example b/.env.local.example index c81eb259e0..5ff11a8d7b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -28,6 +28,7 @@ ENVIRONMENT=loc NETWORK=testnet DISABLED_PROCESSES=* REQUEST_LIMIT_CHECK=false +KYC_DFX_APPROVAL_WORKFLOW_ENABLED=false # Database (matches docker-compose.yml) SQL_HOST=localhost diff --git a/docs/cron-jobs.md b/docs/cron-jobs.md index ccb451d1ed..647e70ae83 100644 --- a/docs/cron-jobs.md +++ b/docs/cron-jobs.md @@ -1,6 +1,6 @@ # Cron jobs -Every scheduled job this service runs: **131 `@DfxCron` declarations** across 92 files and 33 areas. +Every scheduled job this service runs: **132 `@DfxCron` declarations** across 93 files and 33 areas. ## Columns @@ -13,7 +13,7 @@ Every scheduled job this service runs: **131 `@DfxCron` declarations** across 92 ## Flags -110 of the 131 jobs carry a `process` flag, 21 do not. A job with a flag can be switched off +111 of the 132 jobs carry a `process` flag, 21 do not. A job with a flag can be switched off without a deploy — `DfxCronService` skips it when the process appears in the disabled set, which `ProcessService` refreshes from the `disabledProcesses` setting and the `DISABLED_PROCESSES` environment variable every 30 seconds. @@ -45,7 +45,7 @@ New jobs should declare a flag unless there is a reason like the one above. | second | 5 | | 10 seconds | 3 | | 30 seconds | 8 | -| minute | 49 | +| minute | 50 | | 5 minutes | 17 | | 10 minutes | 15 | | hour | 16 | @@ -70,7 +70,7 @@ Jobs by area: | `subdomains/core/buy-crypto` | 6 | 4 | | `subdomains/core/sell-crypto` | 5 | 2 | | `subdomains/core/payment-link` | 4 | — | -| `subdomains/generic/kyc` | 4 | — | +| `subdomains/generic/kyc` | 5 | — | | `subdomains/supporting/bank-tx` | 4 | — | | `subdomains/supporting/bank` | 4 | — | | `subdomains/supporting/fiat-output` | 4 | — | @@ -100,7 +100,7 @@ Jobs by area: Every `@DfxCron(` occurrence in `src/**/*.ts`. Decorator arguments are read by a balanced-paren scan, so multi-line declarations are included — a line-based match misses four of them. The parsed -count is asserted against a raw text count of the decorator: **131 = 131**, no gap. Class and +count is asserted against a raw text count of the decorator: **132 = 132**, no gap. Class and method come from the enclosing `export class` (including `export abstract class`) and the identifier following the decorator. @@ -146,6 +146,7 @@ the interval while running as an independent timer with its own lock. | minute | `MONITORING` | `CheckoutObserver::fetch` | `subdomains/core/monitoring/observers/checkout.observer.ts` | | minute | `PAY_IN` | `CitreaBaseStrategy::checkPayInEntries` | `subdomains/supporting/payin/strategies/register/impl/base/citrea.strategy.ts` | | minute | `CUSTODY` | `CustodyJobService::handleOrders` | `subdomains/core/custody/services/custody-job.service.ts` | +| minute | `KYC_DFX_APPROVAL` | `DfxApprovalWorkflowService::reviewPersonalApprovals` | `subdomains/generic/kyc/services/dfx-approval-workflow.service.ts` | | minute | `FIAT_OUTPUT` | `FiatOutputJobService::fillFiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts` | | minute | `FIAT_PAY_IN` | `FiatPayInSyncService::syncCheckout` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts` | | minute | `PAY_IN` | `InternetComputerStrategy::checkPayInEntries` | `subdomains/supporting/payin/strategies/register/impl/icp.strategy.ts` | diff --git a/docs/dfx-approval-migration.md b/docs/dfx-approval-migration.md new file mode 100644 index 0000000000..c4514af8e7 --- /dev/null +++ b/docs/dfx-approval-migration.md @@ -0,0 +1,155 @@ +# Replacing the eight DfxApproval GSheets + +## Scope + +The API workflow replaces exactly the eight minute-interval personal onboarding projects: + +1. DfxApproval approval +2. DfxApproval risk flags +3. `GwGFileCover` +4. `IdentificationForm` +5. `CustomerProfile` +6. `RiskProfile` +7. `FormA` +8. `DfxNameCheck` + +`IdentReport` and `PersonalNameCheck` keep being produced by their existing KYC processes. They are +part of the approval gate, but they are not among the eight sheets being replaced. Organizations are +not part of this personal-sheet migration. + +## How the API handles a case + +`DfxApprovalWorkflowService` picks up at most 50 of the oldest personal cases with +`DfxApproval = ManualReview` and `kycLevel >= 40` every minute. The process is off by default and is +only activated with `KYC_DFX_APPROVAL_WORKFLOW_ENABLED=true`. + +Per case: + +1. A PostgreSQL advisory lock prevents parallel processing across API instances. +2. Empty personal risk fields are initialised with the previous GSheet values only after a completed + NameCheck that is at most 90 days old: `pep=false`, `highRisk=false`, `complexOrgStructure=false` + and `depositLimit=100000`. As in the approval sheet, `amlAccountType` is only set to + `natural person` for the DfxApproval case itself. Existing values are never overwritten, and every + change is recorded in `kyc_log`. +3. The six missing PDF records are written with `pdf-lib` onto copies of the productive Google Sheet + PDF templates and stored idempotently in WORM storage under a unique `generationKey`. A `kyc_file` + is marked valid only after a successful upload. The text is set in an embedded Unicode font + (Liberation Sans, metrically compatible with Arial/Helvetica) so that names, streets and employers + outside Latin-1 do not abort the document; characters without a glyph are substituted and logged + instead of discarding the document. +4. The server-side gate checks every business precondition and all eight document types. +5. Only a fully unblocked case is set to `DfxApproval = Completed`, `kycLevel = 50` and + `kycStatus = Completed` in one database transaction. Step and KYC logs are written in the same + transaction; the notification follows after the commit. + +The six documents keep their mutually independent GSheet selection rules: + +- `GwGFileCover`, `IdentificationForm` and `DfxNameCheck`: DfxApproval in `InternalReview` or + `ManualReview`; the specific checks on personal account, name, nationality and merge status apply + per document. +- `CustomerProfile`: completed FinancialData for personal accounts with `30 <= kycLevel < 50`. +- `RiskProfile` and `FormA`: DFX personal accounts with `30 <= kycLevel < 50`; RiskProfile + additionally requires `highRisk=false` and a FATF-enabled country of residence. These two documents + belong to the account, not to a KYC step: they are also generated for accounts that have neither a + `DfxApproval` nor a `FinancialData` step, which is what the productive Sheet covers. The productive + legacy exceptions live in the `dfxApprovalDocumentExclusions` setting (a JSON array of `user_data` + IDs) instead of the source tree; without that setting the exclusion list is empty. + +A document can therefore be produced even when another document or a later approval precondition is +still missing. Incomplete or invalid JSON data, missing NameCheck data and storage errors are logged +per document; the other documents of the same case continue. Empty compliance values are never +interpreted as `false`. + +## Automatic approval gate + +Automatic approval requires: + +- personal account, DfxApproval `ManualReview`, `kycLevel` of at least 40 +- `verifiedName`, `kycHash`, first name, date of birth and e-mail +- `complexOrgStructure = false`, `highRisk = false`, `pep = false` +- a permitted user and KYC status +- an enabled country without manual country review; Brazil stays excluded +- a permitted identification document type and a present document number +- a present nationality; for a disabled nationality a completed residence permit +- no open sanctioned NameCheck +- valid files for `GwGFileCover`, `IdentReport`, `IdentificationForm`, `CustomerProfile`, + `RiskProfile`, `FormA`, `DfxNameCheck` and `PersonalNameCheck` + +Whenever the gate refuses, the blocking reasons are logged as +`DfxApproval step not ready: `. + +This migration requires no change in `DFXswiss/services` and no additional manual endpoint. +Approval, document generation, locking, idempotency and auditing live entirely in the API. + +## Behaviour carried over deliberately + +Three properties of the Sheet process are reproduced as they are, because this migration replaces the +process without changing the rules it applies: + +- **Compliance defaults on approval.** `complexOrgStructure`, `highRisk`, `depositLimit` and + `amlAccountType` are set to the approval defaults, even where an account carried a different value + before. The previous values are written to `kyc_log` in the same transaction, so any earlier + decision stays reconstructible. Changing this would change the outcome of the approval, not just + its implementation. +- **RiskProfile outside a FATF-enabled country.** The document is only generated for a FATF-enabled + country of residence, while the gate requires it for every case. An account outside such a country + therefore never completes automatically and stays with Compliance - exactly as under the Sheet + process. A test pins generation condition and gate requirement together so neither side can be + changed alone. +- **Documents already produced by the Sheets are not regenerated.** A subtype that exists and is + valid is skipped, whatever produced it. + +## Residual risk + +The selection rules of the six document sheets are documented in the audit package rather than taken +from their source: Google refuses the script export of those six projects with `403`, so only the two +readable projects (approval, risk flags) have byte-exact code snapshots. The rules were reconstructed +from the workbook exports. Before the cutover, compare the candidate sets of at least one document +sheet against the productive sheet - the API-side queries are in +`DfxApprovalWorkflowService.generatePending*`. + +## Productive cutover + +The order is binding so that GSheets and API never write in parallel: + +1. Deploy the API including the database migration while `KYC_DFX_APPROVAL_WORKFLOW_ENABLED=false` + stays in place. In the same step, fill the `dfxApprovalDocumentExclusions` setting with the + productive legacy exceptions — without it the workflow also generates documents for those + accounts. +2. Verify the six PDF subtypes and the automatic approval with a test case in the disabled or + controlled staging setup. +3. Disable all eight minute-interval triggers on the operator account, but do not delete them yet, so + that a rollback stays quick. +4. Confirm for at least three minutes that none of the eight projects runs any more. +5. Set `KYC_DFX_APPROVAL_WORKFLOW_ENABLED=true` and restart the API in a controlled way. +6. Watch throughput, the oldest waiting case, new `kyc_file` subtypes, step logs and error logs over + several minutes. +7. Remove the eight old triggers for good only after a stable observation period. + +`Process.KYC_DFX_APPROVAL` also remains available as a fast kill switch through the existing +`disabledProcesses` setting. + +## Rollback + +1. Set `KYC_DFX_APPROVAL_WORKFLOW_ENABLED=false` or disable `KycDfxApproval` through + `disabledProcesses`. +2. Make sure no API execution is still running. +3. Re-enable the eight old triggers and watch their executions and the backlog. + +The schema migration is not rolled back: `generationKey` is nullable for existing files and does not +affect the old process. Documents already generated correctly by the API stay valid KYC records; as +before, the old sheets have to skip subtypes that already exist. + +## Operational monitoring + +Alerts are needed for: + +- a growing number of `DfxApproval = ManualReview` with `kycLevel = 40` +- an increasing age of the oldest waiting case +- missing or invalid document subtypes +- repeated `DfxApproval workflow failed` logs +- recurring `DfxApproval step not ready` logs with the same blocker: they name the reason a case + is not being approved +- storage, PDF, JSON or NameCheck errors + +A backlog of zero is only a snapshot. Throughput and case age are what matter. diff --git a/migration/1785537000000-AddKycFileGenerationKey.js b/migration/1785537000000-AddKycFileGenerationKey.js new file mode 100644 index 0000000000..9b50b21457 --- /dev/null +++ b/migration/1785537000000-AddKycFileGenerationKey.js @@ -0,0 +1,28 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * Adds an idempotency key for API-generated KYC documents. Existing Sheet-generated files remain + * untouched (NULL); a new workflow can create every document at most once per user and version. + * + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddKycFileGenerationKey1785537000000 { + name = 'AddKycFileGenerationKey1785537000000'; + + /** @param {QueryRunner} queryRunner */ + async up(queryRunner) { + await queryRunner.query(`SET LOCAL lock_timeout = '5s'`); + await queryRunner.query(`ALTER TABLE "kyc_file" ADD "generationKey" character varying(256)`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_840d5653c5f3bc7c76de2d156d" ON "kyc_file" ("generationKey")`); + } + + /** @param {QueryRunner} queryRunner */ + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_840d5653c5f3bc7c76de2d156d"`); + await queryRunner.query(`ALTER TABLE "kyc_file" DROP COLUMN "generationKey"`); + } +}; diff --git a/nest-cli.json b/nest-cli.json index 6ca2783774..90c9991ba6 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -4,7 +4,10 @@ "compilerOptions": { "assets": [ { "include": "subdomains/supporting/notification/templates/**/*.hbs", "outDir": "dist/src/" }, - { "include": "shared/i18n/**/*", "outDir": "dist/src/" } + { "include": "shared/i18n/**/*", "outDir": "dist/src/" }, + { "include": "subdomains/generic/kyc/assets/**/*.pdf", "outDir": "dist/src/" }, + { "include": "subdomains/generic/kyc/assets/**/*.ttf", "outDir": "dist/src/" }, + { "include": "subdomains/generic/kyc/assets/**/LICENSE", "outDir": "dist/src/" } ], "watchAssets": true } diff --git a/package-lock.json b/package-lock.json index b293fef6e1..d577e7c2a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", "@opentelemetry/sdk-node": "^0.218.0", "@opentelemetry/sdk-trace-base": "^2.7.1", + "@pdf-lib/fontkit": "^1.1.1", "@railgun-community/engine": "^9.4.0", "@scure/bip32": "^1.6.2", "@scure/bip39": "^1.5.4", @@ -102,6 +103,7 @@ "opentimestamps": "^0.4.9", "passport": "^0.6.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "pdfkit": "^0.15.2", "pg": "^8.13.3", "qrcode": "^1.5.4", @@ -12323,6 +12325,30 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/fontkit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", + "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -29104,6 +29130,22 @@ ], "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/pdfkit": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.2.tgz", diff --git a/package.json b/package.json index 74a9f61938..8978142c39 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.218.0", "@opentelemetry/sdk-node": "^0.218.0", "@opentelemetry/sdk-trace-base": "^2.7.1", + "@pdf-lib/fontkit": "^1.1.1", "@railgun-community/engine": "^9.4.0", "@scure/bip32": "^1.6.2", "@scure/bip39": "^1.5.4", @@ -121,6 +122,7 @@ "opentimestamps": "^0.4.9", "passport": "^0.6.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "pdfkit": "^0.15.2", "pg": "^8.13.3", "qrcode": "^1.5.4", diff --git a/src/config/config.ts b/src/config/config.ts index 785a112b1d..d6b2897dad 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -343,6 +343,7 @@ export class Configuration { kyc = { transactionPrefix: process.env.KYC_TRANSACTION_PREFIX, + dfxApprovalWorkflowEnabled: process.env.KYC_DFX_APPROVAL_WORKFLOW_ENABLED === 'true', identFailAfterDays: 30, reminderAfterDays: 2, appToken: process.env.KYC_APP_TOKEN, diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index 97d79a09b5..409ef88c26 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -29,6 +29,7 @@ export enum Process { KYC_NATIONALITY_REVIEW = 'KycNationalityReview', KYC_FINANCIAL_REVIEW = 'KycFinancialReview', KYC_RECOMMENDATION_REVIEW = 'KycRecommendationReview', + KYC_DFX_APPROVAL = 'KycDfxApproval', KYC_MAIL = 'KycMail', BANK_ACCOUNT = 'BankAccount', BANK_TX = 'BankTx', diff --git a/src/subdomains/generic/kyc/assets/dfx-approval/customer-profile.pdf b/src/subdomains/generic/kyc/assets/dfx-approval/customer-profile.pdf new file mode 100644 index 0000000000..d130165489 Binary files /dev/null and b/src/subdomains/generic/kyc/assets/dfx-approval/customer-profile.pdf differ diff --git a/src/subdomains/generic/kyc/assets/dfx-approval/form-a.pdf b/src/subdomains/generic/kyc/assets/dfx-approval/form-a.pdf new file mode 100644 index 0000000000..f342bb71e6 Binary files /dev/null and b/src/subdomains/generic/kyc/assets/dfx-approval/form-a.pdf differ diff --git a/src/subdomains/generic/kyc/assets/dfx-approval/gwg-file-cover.pdf b/src/subdomains/generic/kyc/assets/dfx-approval/gwg-file-cover.pdf new file mode 100644 index 0000000000..d9d2030579 Binary files /dev/null and b/src/subdomains/generic/kyc/assets/dfx-approval/gwg-file-cover.pdf differ diff --git a/src/subdomains/generic/kyc/assets/dfx-approval/identification-form.pdf b/src/subdomains/generic/kyc/assets/dfx-approval/identification-form.pdf new file mode 100644 index 0000000000..8e9a72172e Binary files /dev/null and b/src/subdomains/generic/kyc/assets/dfx-approval/identification-form.pdf differ diff --git a/src/subdomains/generic/kyc/assets/dfx-approval/name-check.pdf b/src/subdomains/generic/kyc/assets/dfx-approval/name-check.pdf new file mode 100644 index 0000000000..c38c15f9cf Binary files /dev/null and b/src/subdomains/generic/kyc/assets/dfx-approval/name-check.pdf differ diff --git a/src/subdomains/generic/kyc/assets/dfx-approval/risk-profile.pdf b/src/subdomains/generic/kyc/assets/dfx-approval/risk-profile.pdf new file mode 100644 index 0000000000..4db37f8183 Binary files /dev/null and b/src/subdomains/generic/kyc/assets/dfx-approval/risk-profile.pdf differ diff --git a/src/subdomains/generic/kyc/assets/fonts/LICENSE b/src/subdomains/generic/kyc/assets/fonts/LICENSE new file mode 100644 index 0000000000..aba73e8a40 --- /dev/null +++ b/src/subdomains/generic/kyc/assets/fonts/LICENSE @@ -0,0 +1,102 @@ +Digitized data copyright (c) 2010 Google Corporation + with Reserved Font Arimo, Tinos and Cousine. +Copyright (c) 2012 Red Hat, Inc. + with Reserved Font Name Liberation. + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE The goals of the Open Font License (OFL) are to stimulate +worldwide development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to provide +a free and open framework in which fonts may be shared and improved in +partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. +The fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + + + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. +This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components +as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting ? in part or in whole ? +any of the components of the Original Version, by changing formats or +by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer +or other person who contributed to the Font Software. + + +PERMISSION & CONDITIONS + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components,in + Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under + this license does not apply to any document created using the Font + Software. + + + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + + + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + diff --git a/src/subdomains/generic/kyc/assets/fonts/LiberationSans-Bold.ttf b/src/subdomains/generic/kyc/assets/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000000..dc5d57f15f Binary files /dev/null and b/src/subdomains/generic/kyc/assets/fonts/LiberationSans-Bold.ttf differ diff --git a/src/subdomains/generic/kyc/assets/fonts/LiberationSans-Regular.ttf b/src/subdomains/generic/kyc/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000000..e6339859d0 Binary files /dev/null and b/src/subdomains/generic/kyc/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/src/subdomains/generic/kyc/dto/dfx-approval-status.dto.ts b/src/subdomains/generic/kyc/dto/dfx-approval-status.dto.ts new file mode 100644 index 0000000000..55097d3d25 --- /dev/null +++ b/src/subdomains/generic/kyc/dto/dfx-approval-status.dto.ts @@ -0,0 +1,44 @@ +import { FileSubType } from './kyc-file.dto'; + +// Internal result of the approval gate: the workflow decides on it and logs it. It is not exposed +// through an endpoint, so it carries no Swagger contract. + +export enum DfxApprovalBlocker { + WRONG_ACCOUNT_TYPE = 'WrongAccountType', + WRONG_STEP_STATUS = 'WrongStepStatus', + WRONG_KYC_LEVEL = 'WrongKycLevel', + MISSING_VERIFIED_NAME = 'MissingVerifiedName', + MISSING_KYC_HASH = 'MissingKycHash', + MISSING_FIRST_NAME = 'MissingFirstName', + MISSING_BIRTHDAY = 'MissingBirthday', + MISSING_MAIL = 'MissingMail', + COMPLEX_ORGANIZATION = 'ComplexOrganization', + RISK_DATA_PENDING = 'RiskDataPending', + HIGH_RISK = 'HighRisk', + PEP = 'Pep', + INVALID_USER_STATUS = 'InvalidUserStatus', + INVALID_KYC_STATUS = 'InvalidKycStatus', + MISSING_COUNTRY = 'MissingCountry', + COUNTRY_DISABLED = 'CountryDisabled', + COUNTRY_REQUIRES_MANUAL_REVIEW = 'CountryRequiresManualReview', + COUNTRY_EXCLUDED = 'CountryExcluded', + MISSING_NATIONALITY = 'MissingNationality', + NATIONALITY_DISABLED = 'NationalityDisabled', + MISSING_IDENT_DOCUMENT_TYPE = 'MissingIdentDocumentType', + IDENT_DOCUMENT_TYPE_DISABLED = 'IdentDocumentTypeDisabled', + MISSING_IDENT_DOCUMENT_ID = 'MissingIdentDocumentId', + OPEN_NAME_CHECK = 'OpenNameCheck', + MISSING_DOCUMENT = 'MissingDocument', +} + +export class DfxApprovalBlockerDto { + code: DfxApprovalBlocker; + + documentSubType?: FileSubType; +} + +export class DfxApprovalStatusDto { + ready: boolean; + + blockers: DfxApprovalBlockerDto[]; +} diff --git a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts index d7e6c5a91c..8aa74a371e 100644 --- a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts +++ b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts @@ -69,6 +69,7 @@ export interface KycFileBlob extends Blob { } export class CreateKycFileDto { + generationKey?: string; name: string; type: FileType; protected: boolean; diff --git a/src/subdomains/generic/kyc/entities/kyc-file.entity.ts b/src/subdomains/generic/kyc/entities/kyc-file.entity.ts index 15bd84cb76..5ee36f64d5 100644 --- a/src/subdomains/generic/kyc/entities/kyc-file.entity.ts +++ b/src/subdomains/generic/kyc/entities/kyc-file.entity.ts @@ -8,6 +8,10 @@ import { KycStep } from './kyc-step.entity'; @Entity() export class KycFile extends IEntity { + @Index({ unique: true }) + @Column({ length: 256, nullable: true }) + generationKey?: string; + @Column({ type: 'text' }) name: string; diff --git a/src/subdomains/generic/kyc/kyc.module.ts b/src/subdomains/generic/kyc/kyc.module.ts index 8ed614b6dd..dd37736a17 100644 --- a/src/subdomains/generic/kyc/kyc.module.ts +++ b/src/subdomains/generic/kyc/kyc.module.ts @@ -26,6 +26,10 @@ import { KycStepRepository } from './repositories/kyc-step.repository'; import { NameCheckLogRepository } from './repositories/name-check-log.repository'; import { StepLogRepository } from './repositories/step-log.repository'; import { TfaLogRepository } from './repositories/tfa-log.repository'; +import { DfxApprovalCheckService } from './services/dfx-approval-check.service'; +import { DfxApprovalDocumentService } from './services/dfx-approval-document.service'; +import { DfxApprovalPdfService } from './services/dfx-approval-pdf.service'; +import { DfxApprovalWorkflowService } from './services/dfx-approval-workflow.service'; import { DilisenseService } from './services/integration/dilisense.service'; import { FinancialService } from './services/integration/financial.service'; import { KycDocumentService } from './services/integration/kyc-document.service'; @@ -74,6 +78,10 @@ import { TfaService } from './services/tfa.service'; StepLogRepository, TfaLogRepository, DilisenseService, + DfxApprovalCheckService, + DfxApprovalDocumentService, + DfxApprovalPdfService, + DfxApprovalWorkflowService, FinancialService, KycFileService, KycLogRepository, diff --git a/src/subdomains/generic/kyc/services/__tests__/dfx-approval-check.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-check.service.spec.ts new file mode 100644 index 0000000000..6e2a5d8df5 --- /dev/null +++ b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-check.service.spec.ts @@ -0,0 +1,141 @@ +import { createCustomCountry } from 'src/shared/models/country/__mocks__/country.entity.mock'; +import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; +import { AccountType } from '../../../user/models/user-data/account-type.enum'; +import { KycLevel, KycStatus, UserDataStatus } from '../../../user/models/user-data/user-data.enum'; +import { DfxApprovalBlocker } from '../../dto/dfx-approval-status.dto'; +import { FileSubType, FileType } from '../../dto/kyc-file.dto'; +import { KycFile } from '../../entities/kyc-file.entity'; +import { KycStep } from '../../entities/kyc-step.entity'; +import { KycStepName } from '../../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../../enums/review-status.enum'; +import { DFX_APPROVAL_REQUIRED_DOCUMENTS, DfxApprovalCheckService } from '../dfx-approval-check.service'; + +describe('DfxApprovalCheckService', () => { + const service = new DfxApprovalCheckService(); + + function eligibleInput(): { userData: ReturnType; step: KycStep; files: KycFile[] } { + const country = createCustomCountry({ + symbol: 'CH', + dfxEnable: true, + manualReviewRequired: false, + nationalityEnable: true, + enabledKycDocuments: 'PASSPORT;ID_CARD', + }); + const step = Object.assign(new KycStep(), { + id: 11, + name: KycStepName.DFX_APPROVAL, + status: ReviewStatus.MANUAL_REVIEW, + sequenceNumber: 1, + }); + const userData = createCustomUserData({ + id: 7, + accountType: AccountType.PERSONAL, + status: UserDataStatus.ACTIVE, + kycStatus: KycStatus.NA, + kycLevel: KycLevel.LEVEL_40, + verifiedName: 'Test User', + firstname: 'Test', + surname: 'User', + birthday: new Date('1990-01-01'), + mail: 'test@example.com', + kycHash: 'HASH', + complexOrgStructure: false, + highRisk: false, + pep: false, + depositLimit: 100000, + amlAccountType: 'natural person', + country, + nationality: country, + identDocumentId: 'DOC-1', + identDocumentType: 'PASSPORT', + kycSteps: [step], + }); + step.userData = userData; + const identStep = Object.assign(new KycStep(), { + name: KycStepName.IDENT, + status: ReviewStatus.COMPLETED, + sequenceNumber: 1, + }); + const files = DFX_APPROVAL_REQUIRED_DOCUMENTS.map((subType, index) => + Object.assign(new KycFile(), { + id: index + 1, + type: subType === FileSubType.PERSONAL_NAME_CHECK ? FileType.NAME_CHECK : FileType.USER_NOTES, + subType, + valid: true, + kycStep: subType === FileSubType.IDENT_REPORT ? identStep : undefined, + }), + ); + return { userData, step, files }; + } + + it('accepts a personal onboarding only when every prerequisite and document is present', () => { + const { userData, step, files } = eligibleInput(); + + expect(service.evaluatePersonal(userData, step, files, false)).toEqual({ ready: true, blockers: [] }); + }); + + it('fails closed for unset risk data and missing documents', () => { + const { userData, step, files } = eligibleInput(); + userData.pep = undefined; + userData.highRisk = undefined; + const withoutRiskProfile = files.filter((file) => file.subType !== FileSubType.RISK_PROFILE); + + const result = service.evaluatePersonal(userData, step, withoutRiskProfile, false); + + expect(result.ready).toBe(false); + expect(result.blockers).toEqual( + expect.arrayContaining([ + { code: DfxApprovalBlocker.RISK_DATA_PENDING }, + { code: DfxApprovalBlocker.MISSING_DOCUMENT, documentSubType: FileSubType.RISK_PROFILE }, + ]), + ); + }); + + it('never auto-approves a manual country, an open name check or a high-risk user', () => { + const { userData, step, files } = eligibleInput(); + userData.country.manualReviewRequired = true; + userData.highRisk = true; + + const result = service.evaluatePersonal(userData, step, files, true); + + expect(result.ready).toBe(false); + expect(result.blockers.map((blocker) => blocker.code)).toEqual( + expect.arrayContaining([ + DfxApprovalBlocker.COUNTRY_REQUIRES_MANUAL_REVIEW, + DfxApprovalBlocker.HIGH_RISK, + DfxApprovalBlocker.OPEN_NAME_CHECK, + ]), + ); + }); + + it('does not require a residence permit when the nationality itself is enabled', () => { + const { userData, step, files } = eligibleInput(); + userData.kycSteps.push( + Object.assign(new KycStep(), { + name: KycStepName.RESIDENCE_PERMIT, + status: ReviewStatus.MANUAL_REVIEW, + sequenceNumber: 1, + }), + ); + + const result = service.evaluatePersonal(userData, step, files, false); + + expect(result.blockers).not.toContainEqual({ code: DfxApprovalBlocker.NATIONALITY_DISABLED }); + }); + + it('accepts a disabled nationality only after its required residence permit was completed', () => { + const { userData, step, files } = eligibleInput(); + userData.nationality.nationalityEnable = false; + userData.kycSteps.push( + Object.assign(new KycStep(), { + name: KycStepName.RESIDENCE_PERMIT, + status: ReviewStatus.COMPLETED, + sequenceNumber: 1, + }), + ); + + const result = service.evaluatePersonal(userData, step, files, false); + + expect(result.blockers).not.toContainEqual({ code: DfxApprovalBlocker.NATIONALITY_DISABLED }); + }); +}); diff --git a/src/subdomains/generic/kyc/services/__tests__/dfx-approval-document.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-document.service.spec.ts new file mode 100644 index 0000000000..82b45f18b5 --- /dev/null +++ b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-document.service.spec.ts @@ -0,0 +1,124 @@ +import { createMock } from '@golevelup/ts-jest'; +import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; +import { AccountType } from '../../../user/models/user-data/account-type.enum'; +import { FileSubType } from '../../dto/kyc-file.dto'; +import { KycStep } from '../../entities/kyc-step.entity'; +import { NameCheckLogRepository } from '../../repositories/name-check-log.repository'; +import { DfxApprovalDocumentService } from '../dfx-approval-document.service'; +import { DfxApprovalPdfService } from '../dfx-approval-pdf.service'; +import { KycDocumentService } from '../integration/kyc-document.service'; + +describe('DfxApprovalDocumentService', () => { + let service: DfxApprovalDocumentService; + let nameCheckRepo: jest.Mocked; + let pdfService: jest.Mocked; + let documentService: jest.Mocked; + const userData = createCustomUserData({ id: 42, accountType: AccountType.PERSONAL, kycFiles: [], kycSteps: [] }); + const step = Object.assign(new KycStep(), { id: 11, userData }); + + beforeEach(() => { + nameCheckRepo = createMock(); + pdfService = createMock(); + documentService = createMock(); + documentService.findGeneratedUserFile.mockResolvedValue(null); + nameCheckRepo.findOne.mockResolvedValue(null); + pdfService.generate.mockResolvedValue(Buffer.from('pdf')); + pdfService.fileName.mockReturnValue('document.pdf'); + service = new DfxApprovalDocumentService(nameCheckRepo, pdfService, documentService); + }); + + it('generates non-NameCheck documents without NameCheck evidence', async () => { + await service.generateMissingPersonalDocuments(userData, step, [], [FileSubType.FORM_A]); + + expect(documentService.ensureGeneratedUserFile).toHaveBeenCalledWith( + `dfx-approval:${userData.id}:${FileSubType.FORM_A}:v1`, + userData, + expect.any(String), + FileSubType.FORM_A, + 'document.pdf', + Buffer.from('pdf'), + { workflow: 'DfxApproval', version: 'v1', stepId: String(step.id) }, + ); + }); + + it('continues with independent documents after one renderer fails', async () => { + pdfService.generate.mockImplementation(async (subType) => { + if (subType === FileSubType.CUSTOMER_PROFILE) throw new Error('invalid FinancialData'); + return Buffer.from('pdf'); + }); + + await expect( + service.generateMissingPersonalDocuments(userData, step, [], [FileSubType.CUSTOMER_PROFILE, FileSubType.FORM_A]), + ).rejects.toThrow('CustomerProfile: invalid FinancialData'); + + expect(documentService.ensureGeneratedUserFile).toHaveBeenCalledTimes(1); + expect(documentService.ensureGeneratedUserFile).toHaveBeenCalledWith( + expect.stringContaining(FileSubType.FORM_A), + userData, + expect.any(String), + FileSubType.FORM_A, + expect.any(String), + expect.any(Buffer), + expect.any(Object), + ); + }); + + it('requires NameCheck evidence only for the DfxNameCheck document', async () => { + await expect( + service.generateMissingPersonalDocuments(userData, step, [], [FileSubType.DFX_NAME_CHECK]), + ).rejects.toThrow(`NameCheck evidence is missing for userData ${userData.id}`); + + expect(pdfService.generate).not.toHaveBeenCalled(); + }); + + it('keeps the other documents of the same case when NameCheck evidence is missing', async () => { + await expect( + service.generateMissingPersonalDocuments( + userData, + step, + [], + [FileSubType.DFX_NAME_CHECK, FileSubType.GWG_FILE_COVER, FileSubType.FORM_A], + ), + ).rejects.toThrow('NameCheck evidence is missing'); + + expect(documentService.ensureGeneratedUserFile).toHaveBeenCalledTimes(2); + expect(documentService.ensureGeneratedUserFile).toHaveBeenCalledWith( + expect.stringContaining(FileSubType.GWG_FILE_COVER), + ...Array(6).fill(expect.anything()), + ); + }); + + it('generates account-bound documents without a KYC step', async () => { + await service.generateMissingPersonalDocuments(userData, undefined, [], [FileSubType.FORM_A]); + + expect(documentService.ensureGeneratedUserFile).toHaveBeenCalledWith( + expect.any(String), + userData, + expect.any(String), + FileSubType.FORM_A, + expect.any(String), + expect.any(Buffer), + { workflow: 'DfxApproval', version: 'v1' }, + ); + }); + + it('reuses the registered file name when a document is generated again', async () => { + documentService.findGeneratedUserFile.mockResolvedValue({ name: 'first-attempt.pdf' } as never); + + await service.generateMissingPersonalDocuments(userData, step, [], [FileSubType.FORM_A]); + + expect(pdfService.generate).toHaveBeenCalledWith( + FileSubType.FORM_A, + expect.objectContaining({ documentName: 'first-attempt.pdf' }), + ); + expect(documentService.ensureGeneratedUserFile).toHaveBeenCalledWith( + expect.any(String), + userData, + expect.any(String), + FileSubType.FORM_A, + 'first-attempt.pdf', + expect.any(Buffer), + expect.any(Object), + ); + }); +}); diff --git a/src/subdomains/generic/kyc/services/__tests__/dfx-approval-pdf.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-pdf.service.spec.ts new file mode 100644 index 0000000000..bd58b2637c --- /dev/null +++ b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-pdf.service.spec.ts @@ -0,0 +1,164 @@ +import { createHash } from 'crypto'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { createCustomCountry } from 'src/shared/models/country/__mocks__/country.entity.mock'; +import { createDefaultLanguage } from 'src/shared/models/language/__mocks__/language.entity.mock'; +import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; +import { AccountType } from '../../../user/models/user-data/account-type.enum'; +import { FileSubType } from '../../dto/kyc-file.dto'; +import { KycStep } from '../../entities/kyc-step.entity'; +import { NameCheckLog, NameCheckRiskStatus } from '../../entities/name-check-log.entity'; +import { KycStepName } from '../../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../../enums/review-status.enum'; +import { DfxApprovalPdfContext, DfxApprovalPdfService } from '../dfx-approval-pdf.service'; + +describe('DfxApprovalPdfService', () => { + const service = new DfxApprovalPdfService(); + const generatedAt = new Date('2026-07-31T20:00:00.000Z'); + + function context(): DfxApprovalPdfContext { + const country = createCustomCountry({ name: 'Switzerland', symbol: 'CH' }); + const financialStep = Object.assign(new KycStep(), { + name: KycStepName.FINANCIAL_DATA, + status: ReviewStatus.COMPLETED, + sequenceNumber: 1, + result: JSON.stringify([ + { key: 'occupation', value: 'employed' }, + { key: 'occupation_description', value: 'Software development' }, + { key: 'sector', value: 'it' }, + { key: 'income', value: '50k_100k' }, + { key: 'assets', value: '100k_500k' }, + { key: 'source_of_funds', value: 'employment_income' }, + { key: 'risky_business', value: 'no_risky_business' }, + ]), + }); + const userData = createCustomUserData({ + id: 42, + accountType: AccountType.PERSONAL, + firstname: 'Test', + surname: 'Person', + verifiedName: 'Test Person', + birthday: new Date('1990-01-01'), + street: 'Example Street', + houseNumber: '1', + zip: '8000', + location: 'Zurich', + country, + verifiedCountry: country, + nationality: country, + language: createDefaultLanguage(), + phone: '+41000000000', + identDocumentType: 'PASSPORT', + identDocumentId: 'TEST-DOC', + identificationType: 'Online' as never, + pep: false, + highRisk: false, + complexOrgStructure: false, + kycSteps: [financialStep], + }); + return { + userData, + steps: [financialStep], + generatedAt, + nameCheck: Object.assign(new NameCheckLog(), { + created: new Date('2026-07-31T19:00:00.000Z'), + riskStatus: NameCheckRiskStatus.NOT_SANCTIONED, + result: JSON.stringify({ total_hits: 0, found_records: [] }), + }), + }; + } + + it.each([ + [FileSubType.GWG_FILE_COVER, 1], + [FileSubType.DFX_NAME_CHECK, 1], + [FileSubType.FORM_A, 2], + [FileSubType.IDENTIFICATION_FORM, 4], + [FileSubType.CUSTOMER_PROFILE, 2], + [FileSubType.RISK_PROFILE, 3], + ])('creates a valid %s PDF with %i page(s)', async (subType, expectedPages) => { + const pdf = await service.generate(subType, context()); + const source = pdf.toString('latin1'); + + expect(source.startsWith('%PDF-')).toBe(true); + expect(source.match(/\/Type\s*\/Page\b/g)).toHaveLength(expectedPages); + }); + + it.each([ + ['gwg-file-cover.pdf', '8b5e6636a0c1c4c64a1fbe94b77ab12aa5ca8cab072b8ac93190d0ffd6889ace'], + ['identification-form.pdf', '7e49ae0ffa25962e8cb5319d309733d2581b618a8270183590677ef6f2fcf5c1'], + ['customer-profile.pdf', 'bbed3c41fbc940b94470d92c58a3d43046547a2b9785d27f935204b0bd017732'], + ['risk-profile.pdf', '184085521abd00161c5f570b5bfdbc36d400a257fd4650c6f3558baf7eab8e71'], + ['form-a.pdf', '656bcb34ae66a1479095dd6ff7feda6128d1dc62329606e712e685d765fe3047'], + ['name-check.pdf', '20a6e3388801c283840d46d79c06f773b40f9a02ad8f9e6b23fe414559d683a8'], + ])('keeps the productive %s template unchanged', async (fileName, expectedHash) => { + const template = await readFile(join(__dirname, '../../assets/dfx-approval', fileName)); + expect(createHash('sha256').update(template).digest('hex')).toBe(expectedHash); + }); + + it('uses the productive GSheet file names and date sources', () => { + const data = context(); + expect(service.fileName(FileSubType.GWG_FILE_COVER, data)).toBe('20260731-GwGFileDeckblatt-0-42-220000.pdf'); + expect(service.fileName(FileSubType.IDENTIFICATION_FORM, data)).toBe( + '20260731-Identifizierungsformular-0-42-220000.pdf', + ); + expect(service.fileName(FileSubType.CUSTOMER_PROFILE, data)).toBe('20260731-Kundenprofil-0-42-220000.pdf'); + expect(service.fileName(FileSubType.RISK_PROFILE, data)).toBe('20260731-Risikoprofil-0-42-220000.pdf'); + expect(service.fileName(FileSubType.FORM_A, data)).toBe('20260731-FormularA-0-42-220000.pdf'); + expect(service.fileName(FileSubType.DFX_NAME_CHECK, data)).toBe('20260731-NameCheck-0-42-220000.pdf'); + }); + + it.each([ + ['Polish', 'Łukasz Kowalczyk', 'Łódź'], + ['Turkish', 'Gülşen Şahin', 'Şişli'], + ['Cyrillic', 'Кузнецов Алексей', 'Москва'], + ['Baltic', 'Kalniņš Jānis', 'Liepāja'], + ])('renders %s names, which a WinAnsi standard font cannot encode', async (_language, name, location) => { + const data = context(); + data.userData.verifiedName = name; + data.userData.surname = name; + data.userData.location = location; + + for (const subType of [FileSubType.GWG_FILE_COVER, FileSubType.FORM_A, FileSubType.IDENTIFICATION_FORM]) { + const pdf = await service.generate(subType, data); + expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + } + }); + + it('substitutes characters the font has no glyph for instead of failing the document', async () => { + const data = context(); + data.userData.verifiedName = '田中太郎'; + + const pdf = await service.generate(FileSubType.GWG_FILE_COVER, data); + + expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-'); + }); + + it('keeps the registered file name as document number when a document is regenerated', () => { + const data = { ...context(), documentName: '20260731-FormularA-0-42-100000.pdf' }; + + expect((service as any).documentNumber(FileSubType.FORM_A, data)).toBe('20260731-FormularA-0-42-100000'); + }); + + it('replaces cached template values for every dynamic checkbox', async () => { + const data = context(); + data.userData.sellVolume = 0; + data.userData.cryptoVolume = 0; + const uncheck = jest.spyOn(service as any, 'uncheck'); + const check = jest.spyOn(service as any, 'check'); + + await service.generate(FileSubType.IDENTIFICATION_FORM, data); + await service.generate(FileSubType.CUSTOMER_PROFILE, data); + await service.generate(FileSubType.RISK_PROFILE, data); + + expect(uncheck).toHaveBeenCalledWith(expect.anything(), 239.3, 202.0); + expect(uncheck).toHaveBeenCalledWith(expect.anything(), 239.3, 219.0); + expect(uncheck).toHaveBeenCalledWith(expect.anything(), 282.2, 167.5); + expect(uncheck).toHaveBeenCalledWith(expect.anything(), 282.2, 197.1); + expect(uncheck).toHaveBeenCalledWith(expect.anything(), 259.4, 150.0); + expect(uncheck).toHaveBeenCalledWith(expect.anything(), 259.4, 166.7); + expect(check).not.toHaveBeenCalledWith(expect.anything(), 239.3, 202.0); + expect(check).not.toHaveBeenCalledWith(expect.anything(), 239.3, 219.0); + expect(check).not.toHaveBeenCalledWith(expect.anything(), 282.2, 167.5); + expect(check).not.toHaveBeenCalledWith(expect.anything(), 282.2, 197.1); + }); +}); diff --git a/src/subdomains/generic/kyc/services/__tests__/dfx-approval-workflow.service.spec.ts b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-workflow.service.spec.ts new file mode 100644 index 0000000000..2564fe7eb0 --- /dev/null +++ b/src/subdomains/generic/kyc/services/__tests__/dfx-approval-workflow.service.spec.ts @@ -0,0 +1,379 @@ +import { createMock } from '@golevelup/ts-jest'; +import { Config, ConfigService } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { EntityManager } from 'typeorm'; +import { createCustomUserData } from '../../../user/models/user-data/__mocks__/user-data.entity.mock'; +import { AccountType } from '../../../user/models/user-data/account-type.enum'; +import { KycLevel, KycType, UserDataStatus } from '../../../user/models/user-data/user-data.enum'; +import { DfxApprovalBlocker } from '../../dto/dfx-approval-status.dto'; +import { FileSubType } from '../../dto/kyc-file.dto'; +import { KycStep } from '../../entities/kyc-step.entity'; +import { KycStepName } from '../../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../../enums/review-status.enum'; +import { KycStepRepository } from '../../repositories/kyc-step.repository'; +import { DFX_APPROVAL_REQUIRED_DOCUMENTS, DfxApprovalCheckService } from '../dfx-approval-check.service'; +import { DfxApprovalDocumentService } from '../dfx-approval-document.service'; +import { DfxApprovalWorkflowService } from '../dfx-approval-workflow.service'; +import { KycLogService } from '../kyc-log.service'; +import { KycNotificationService } from '../kyc-notification.service'; +import { NameCheckService } from '../name-check.service'; + +describe('DfxApprovalWorkflowService', () => { + let service: DfxApprovalWorkflowService; + let stepRepo: jest.Mocked; + let documentService: jest.Mocked; + let notificationService: jest.Mocked; + let checkService: jest.Mocked; + let settingService: jest.Mocked; + + // Chainable query-builder double: the production code locks through the builder, so the double + // has to return itself from every chained call. + function queryBuilder(result: unknown): Record { + const builder: Record = {}; + for (const method of ['leftJoinAndSelect', 'where', 'setLock']) + builder[method] = jest.fn().mockReturnValue(builder); + builder.getOne = jest.fn().mockResolvedValue(result); + + return builder; + } + + // TypeORM emits `FOR UPDATE OF ` unquoted; Postgres folds it to lower case and then fails + // to match a camelCase alias in the FROM clause. + function expectLowerCaseLockAlias(builder: Record): void { + const tables = builder.setLock.mock.calls[0][2] as string[]; + expect(tables).toEqual(tables.map((table) => table.toLowerCase())); + } + + function pendingStep(): KycStep { + const userData = createCustomUserData({ + id: 42, + accountType: AccountType.PERSONAL, + kycLevel: KycLevel.LEVEL_40, + kycFiles: [], + kycSteps: [], + }); + const step = Object.assign(new KycStep(), { + id: 11, + name: KycStepName.DFX_APPROVAL, + status: ReviewStatus.MANUAL_REVIEW, + sequenceNumber: 1, + userData, + }); + userData.kycSteps = [step]; + return step; + } + + beforeEach(() => { + if (!Config) new ConfigService(); + Config.kyc.dfxApprovalWorkflowEnabled = true; + stepRepo = createMock(); + Object.defineProperty(stepRepo, 'manager', { value: createMock() }); + documentService = createMock(); + notificationService = createMock(); + checkService = createMock(); + settingService = createMock(); + settingService.getObjCached.mockResolvedValue([]); + checkService.evaluatePersonal.mockReturnValue({ + ready: false, + blockers: [{ code: DfxApprovalBlocker.MISSING_DOCUMENT, documentSubType: FileSubType.RISK_PROFILE }], + }); + service = new DfxApprovalWorkflowService( + stepRepo, + checkService, + documentService, + createMock(), + notificationService, + settingService, + createMock(), + ); + (stepRepo.manager.find as jest.Mock).mockResolvedValue([]); + }); + + afterEach(() => { + Config.kyc.dfxApprovalWorkflowEnabled = false; + }); + + it('stays inactive until the explicit cutover flag is enabled', async () => { + Config.kyc.dfxApprovalWorkflowEnabled = false; + + await service.reviewPersonalApprovals(); + + expect(stepRepo.find).not.toHaveBeenCalled(); + }); + + it('serializes every pending candidate through the cross-instance step lock', async () => { + const first = pendingStep(); + const second = pendingStep(); + second.id = 12; + stepRepo.find.mockResolvedValueOnce([first, second]).mockResolvedValue([]); + const lock = jest.spyOn(service as any, 'withStepLock').mockResolvedValue(undefined); + + await service.reviewPersonalApprovals(); + + expect(lock).toHaveBeenCalledTimes(2); + expect(lock).toHaveBeenNthCalledWith(1, 11, expect.any(Function)); + expect(lock).toHaveBeenNthCalledWith(2, 12, expect.any(Function)); + }); + + it('initializes risk data, creates documents and only then completes and notifies', async () => { + const step = pendingStep(); + jest.spyOn(service as any, 'loadStep').mockResolvedValue(step); + const initialize = jest.spyOn(service as any, 'initializePersonalRiskData').mockResolvedValue(undefined); + const completedUser = createCustomUserData({ ...step.userData, kycLevel: KycLevel.LEVEL_50 }); + const complete = jest.spyOn(service as any, 'completeIfReady').mockResolvedValue(completedUser); + (service as any).nameCheckService.hasOpenNameChecks.mockResolvedValue(false); + + await (service as any).processPersonalApproval(step.id); + + expect(initialize.mock.invocationCallOrder[0]).toBeLessThan( + (documentService.generateMissingPersonalDocuments as jest.Mock).mock.invocationCallOrder[0], + ); + expect((documentService.generateMissingPersonalDocuments as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + complete.mock.invocationCallOrder[0], + ); + expect(notificationService.kycChanged).toHaveBeenCalledWith(completedUser, KycLevel.LEVEL_50); + }); + + it('does not touch documents after the step has already advanced', async () => { + const step = pendingStep(); + step.status = ReviewStatus.COMPLETED; + jest.spyOn(service as any, 'loadStep').mockResolvedValue(step); + + await (service as any).processPersonalApproval(step.id); + + expect(documentService.generateMissingPersonalDocuments).not.toHaveBeenCalled(); + expect(notificationService.kycChanged).not.toHaveBeenCalled(); + }); + + it('generates independent documents even while a non-document prerequisite is blocked', async () => { + const step = pendingStep(); + jest.spyOn(service as any, 'loadStep').mockResolvedValue(step); + jest.spyOn(service as any, 'initializePersonalRiskData').mockResolvedValue(undefined); + jest.spyOn(service as any, 'completeIfReady').mockResolvedValue(undefined); + (service as any).nameCheckService.hasOpenNameChecks.mockResolvedValue(false); + checkService.evaluatePersonal.mockReturnValue({ + ready: false, + blockers: [ + { code: DfxApprovalBlocker.MISSING_BIRTHDAY }, + { code: DfxApprovalBlocker.MISSING_DOCUMENT, documentSubType: FileSubType.RISK_PROFILE }, + ], + }); + + await (service as any).processPersonalApproval(step.id); + + expect(documentService.generateMissingPersonalDocuments).toHaveBeenCalledWith( + step.userData, + step, + step.userData.kycFiles, + expect.any(Array), + ); + expect(notificationService.kycChanged).not.toHaveBeenCalled(); + }); + + it('gives every document exactly one generation path, so no two lock keys write the same file', () => { + const step = pendingStep(); + const financialStep = Object.assign(new KycStep(), { + id: 12, + name: KycStepName.FINANCIAL_DATA, + status: ReviewStatus.COMPLETED, + sequenceNumber: 1, + }); + Object.assign(step.userData, { + status: UserDataStatus.ACTIVE, + kycType: KycType.DFX, + verifiedName: 'Test User', + nationality: { name: 'Switzerland' }, + country: { fatfEnable: true }, + highRisk: false, + kycSteps: [step, financialStep], + }); + + // Step lock: the three documents that hang off the DfxApproval step. + expect((service as any).eligibleApprovalStepDocuments(step.userData)).toEqual([ + FileSubType.GWG_FILE_COVER, + FileSubType.IDENTIFICATION_FORM, + FileSubType.DFX_NAME_CHECK, + ]); + // User-data lock: the two documents that hang off the account. + expect((service as any).eligibleRiskAndFormADocuments(step.userData, [])).toEqual([ + FileSubType.FORM_A, + FileSubType.RISK_PROFILE, + ]); + // CustomerProfile is generated under the lock of the FinancialData step and appears in neither. + expect((service as any).eligibleApprovalStepDocuments(step.userData)).not.toContain(FileSubType.CUSTOMER_PROFILE); + expect((service as any).eligibleRiskAndFormADocuments(step.userData, [])).not.toContain( + FileSubType.CUSTOMER_PROFILE, + ); + }); + + it('keeps RiskProfile generation and the approval gate on the same country condition', () => { + const userData = createCustomUserData({ + id: 42, + accountType: AccountType.PERSONAL, + status: UserDataStatus.ACTIVE, + kycType: KycType.DFX, + kycLevel: KycLevel.LEVEL_40, + verifiedName: 'Test User', + highRisk: false, + country: { fatfEnable: false } as never, + }); + + // Outside a FATF-enabled country no RiskProfile is generated - exactly as in the productive + // Sheet. The gate still requires the document, so such a case stays with Compliance instead of + // being approved automatically. Both sides must be changed together. + expect((service as any).eligibleRiskAndFormADocuments(userData, [])).toEqual([FileSubType.FORM_A]); + expect(DFX_APPROVAL_REQUIRED_DOCUMENTS).toContain(FileSubType.RISK_PROFILE); + }); + + it('does not initialise risk data while a sanctioned name check is open', async () => { + const userData = createCustomUserData({ id: 42, highRisk: null, lastNameCheckDate: new Date() }); + (stepRepo.manager.find as jest.Mock).mockResolvedValueOnce([userData]); + const nameCheckService = (service as any).nameCheckService as jest.Mocked; + nameCheckService.hasOpenNameChecks.mockResolvedValue(true); + const initialize = jest.spyOn(service as any, 'initializePersonalRiskData'); + + await (service as any).initializePendingPersonalRiskData(); + + expect(nameCheckService.hasOpenNameChecks).toHaveBeenCalledWith(userData); + expect(initialize).toHaveBeenCalledWith(userData, true); + expect(stepRepo.manager.transaction).not.toHaveBeenCalled(); + }); + + it('applies the productive approval defaults atomically when completing a ready case', async () => { + const step = pendingStep(); + step.userData.depositLimit = 250000; + step.userData.amlAccountType = 'legacy'; + const manager = stepRepo.manager as unknown as jest.Mocked; + (manager.transaction as jest.Mock).mockImplementation((action) => action(manager)); + const stepBuilder = queryBuilder(step); + (manager.createQueryBuilder as jest.Mock).mockReturnValue(stepBuilder); + (manager.findOne as jest.Mock).mockResolvedValue(step.userData); + (manager.exists as jest.Mock).mockResolvedValue(false); + checkService.evaluatePersonal.mockReturnValue({ ready: true, blockers: [] }); + + await (service as any).completeIfReady(step.id); + + // The step is loaded together with its user data: a ManyToOne without eager loading is not + // populated otherwise, and the completion needs the user data ID. + expect(stepBuilder.leftJoinAndSelect).toHaveBeenCalledWith('step.userData', 'joinedUserData'); + // Postgres rejects FOR UPDATE on the nullable side of an outer join, so the lock names the step. + expect(stepBuilder.setLock).toHaveBeenCalledWith('pessimistic_write', undefined, ['step']); + expectLowerCaseLockAlias(stepBuilder); + + expect(manager.update).toHaveBeenCalledWith( + expect.any(Function), + step.userData.id, + expect.objectContaining({ + kycLevel: KycLevel.LEVEL_50, + complexOrgStructure: false, + highRisk: false, + depositLimit: 100000, + amlAccountType: 'natural person', + }), + ); + }); + + it('records the previous value of every overwritten compliance column before the update', async () => { + const step = pendingStep(); + Object.assign(step.userData, { + depositLimit: 250000, + amlAccountType: 'legacy', + highRisk: false, + complexOrgStructure: false, + }); + const manager = stepRepo.manager as unknown as jest.Mocked; + (manager.transaction as jest.Mock).mockImplementation((action) => action(manager)); + (manager.createQueryBuilder as jest.Mock).mockReturnValue(queryBuilder(step)); + (manager.findOne as jest.Mock).mockResolvedValue(step.userData); + (manager.exists as jest.Mock).mockResolvedValue(false); + checkService.evaluatePersonal.mockReturnValue({ ready: true, blockers: [] }); + const logService = (service as any).logService as jest.Mocked; + + await (service as any).completeIfReady(step.id); + + const approvalLog = logService.createLogInternal.mock.calls + .map((call) => call[2]) + .map((result) => { + try { + return JSON.parse(result as string); + } catch { + return undefined; + } + }) + .find((entry) => entry?.action === 'AutomaticApproval'); + + expect(approvalLog.before).toEqual( + expect.objectContaining({ + depositLimit: 250000, + amlAccountType: 'legacy', + highRisk: false, + complexOrgStructure: false, + }), + ); + // The audit entry is written before the columns change. + expect(logService.createLogInternal.mock.invocationCallOrder[0]).toBeLessThan( + (manager.update as jest.Mock).mock.invocationCallOrder[0], + ); + }); + + it('locks user data without joining its eager relations', async () => { + const userData = createCustomUserData({ id: 42, pep: null, highRisk: null, lastNameCheckDate: new Date() }); + const manager = stepRepo.manager as unknown as jest.Mocked; + (manager.transaction as jest.Mock).mockImplementation((action) => action(manager)); + const userDataBuilder = queryBuilder(userData); + (manager.createQueryBuilder as jest.Mock).mockReturnValue(userDataBuilder); + + await (service as any).initializePersonalRiskData(userData, false); + + expect(manager.findOne).not.toHaveBeenCalled(); + expect(userDataBuilder.leftJoinAndSelect).not.toHaveBeenCalled(); + expect(userDataBuilder.setLock).toHaveBeenCalledWith('pessimistic_write', undefined, ['user_data']); + expectLowerCaseLockAlias(userDataBuilder); + }); + + it('generates RiskProfile and FormA for accounts without any approval or financial step', async () => { + const userData = createCustomUserData({ + id: 42, + accountType: AccountType.PERSONAL, + status: UserDataStatus.ACTIVE, + kycType: KycType.DFX, + kycLevel: KycLevel.LEVEL_30, + verifiedName: 'Test User', + highRisk: false, + country: { fatfEnable: true } as never, + kycSteps: [], + kycFiles: [], + }); + (stepRepo.manager.find as jest.Mock).mockResolvedValueOnce([userData]); + jest.spyOn(service as any, 'withUserDataLock').mockImplementation((_id, action: any) => action()); + + await (service as any).generatePendingRiskAndFormADocuments([]); + + expect(documentService.generateMissingPersonalDocuments).toHaveBeenCalledWith( + userData, + undefined, + userData.kycFiles, + [FileSubType.FORM_A, FileSubType.RISK_PROFILE], + ); + }); + + it('takes the document exclusions from configuration, never from the source tree', async () => { + settingService.getObjCached.mockResolvedValue([42]); + const userData = createCustomUserData({ + id: 42, + accountType: AccountType.PERSONAL, + status: UserDataStatus.ACTIVE, + kycType: KycType.DFX, + kycLevel: KycLevel.LEVEL_30, + highRisk: false, + country: { fatfEnable: true } as never, + }); + + expect((service as any).eligibleRiskAndFormADocuments(userData, [42])).toEqual([]); + expect((service as any).eligibleRiskAndFormADocuments(userData, [])).toContain(FileSubType.FORM_A); + + await service.reviewPersonalApprovals(); + + expect(settingService.getObjCached).toHaveBeenCalledWith('dfxApprovalDocumentExclusions', []); + }); +}); diff --git a/src/subdomains/generic/kyc/services/dfx-approval-check.service.ts b/src/subdomains/generic/kyc/services/dfx-approval-check.service.ts new file mode 100644 index 0000000000..c1876eed4b --- /dev/null +++ b/src/subdomains/generic/kyc/services/dfx-approval-check.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; +import { AccountType } from '../../user/models/user-data/account-type.enum'; +import { UserData } from '../../user/models/user-data/user-data.entity'; +import { KycLevel, KycStatus, UserDataStatus } from '../../user/models/user-data/user-data.enum'; +import { DfxApprovalBlocker, DfxApprovalBlockerDto, DfxApprovalStatusDto } from '../dto/dfx-approval-status.dto'; +import { IdentDocumentType } from '../dto/ident-result-data.dto'; +import { FileSubType, FileType } from '../dto/kyc-file.dto'; +import { KycFile } from '../entities/kyc-file.entity'; +import { KycStep } from '../entities/kyc-step.entity'; +import { KycStepName } from '../enums/kyc-step-name.enum'; +import { ReviewStatus } from '../enums/review-status.enum'; + +export const DFX_APPROVAL_REQUIRED_DOCUMENTS = [ + FileSubType.GWG_FILE_COVER, + FileSubType.IDENT_REPORT, + FileSubType.IDENTIFICATION_FORM, + FileSubType.CUSTOMER_PROFILE, + FileSubType.RISK_PROFILE, + FileSubType.FORM_A, + FileSubType.DFX_NAME_CHECK, + FileSubType.PERSONAL_NAME_CHECK, +] as const; + +@Injectable() +export class DfxApprovalCheckService { + evaluatePersonal( + userData: UserData, + step: KycStep, + files: KycFile[], + hasOpenNameChecks: boolean, + ): DfxApprovalStatusDto { + const blockers: DfxApprovalBlockerDto[] = []; + const add = (code: DfxApprovalBlocker, documentSubType?: FileSubType): void => { + blockers.push({ code, documentSubType }); + }; + + if (userData.accountType !== AccountType.PERSONAL) add(DfxApprovalBlocker.WRONG_ACCOUNT_TYPE); + if (step.name !== KycStepName.DFX_APPROVAL || step.status !== ReviewStatus.MANUAL_REVIEW) + add(DfxApprovalBlocker.WRONG_STEP_STATUS); + if (userData.kycLevel < KycLevel.LEVEL_40) add(DfxApprovalBlocker.WRONG_KYC_LEVEL); + + if (!userData.verifiedName) add(DfxApprovalBlocker.MISSING_VERIFIED_NAME); + if (!userData.kycHash) add(DfxApprovalBlocker.MISSING_KYC_HASH); + if (!userData.firstname) add(DfxApprovalBlocker.MISSING_FIRST_NAME); + if (!userData.birthday) add(DfxApprovalBlocker.MISSING_BIRTHDAY); + if (!userData.mail) add(DfxApprovalBlocker.MISSING_MAIL); + if (userData.complexOrgStructure !== false) add(DfxApprovalBlocker.COMPLEX_ORGANIZATION); + + if (userData.highRisk == null || userData.pep == null) add(DfxApprovalBlocker.RISK_DATA_PENDING); + if (userData.highRisk === true) add(DfxApprovalBlocker.HIGH_RISK); + if (userData.pep === true) add(DfxApprovalBlocker.PEP); + if (![UserDataStatus.NA, UserDataStatus.ACTIVE, UserDataStatus.KYC_ONLY].includes(userData.status)) + add(DfxApprovalBlocker.INVALID_USER_STATUS); + if (![KycStatus.NA, KycStatus.COMPLETED].includes(userData.kycStatus)) add(DfxApprovalBlocker.INVALID_KYC_STATUS); + + // The productive Sheet gate uses the account's residence country, not verifiedCountry. + const country = userData.country; + if (!country) { + add(DfxApprovalBlocker.MISSING_COUNTRY); + } else { + if (!country.dfxEnable) add(DfxApprovalBlocker.COUNTRY_DISABLED); + if (country.manualReviewRequired) add(DfxApprovalBlocker.COUNTRY_REQUIRES_MANUAL_REVIEW); + if (country.symbol === 'BR') add(DfxApprovalBlocker.COUNTRY_EXCLUDED); + if (!userData.identDocumentType) { + add(DfxApprovalBlocker.MISSING_IDENT_DOCUMENT_TYPE); + } else if (!country.isKycDocEnabled(userData.identDocumentType as IdentDocumentType)) { + add(DfxApprovalBlocker.IDENT_DOCUMENT_TYPE_DISABLED); + } + } + + const residencePermitSteps = userData.getStepsWith(KycStepName.RESIDENCE_PERMIT); + const hasCompletedResidencePermit = residencePermitSteps.some((residencePermit) => residencePermit.isCompleted); + if (!userData.nationality) { + add(DfxApprovalBlocker.MISSING_NATIONALITY); + } else if (!userData.nationality.nationalityEnable && !hasCompletedResidencePermit) { + add(DfxApprovalBlocker.NATIONALITY_DISABLED); + } + + if (!userData.identDocumentId) add(DfxApprovalBlocker.MISSING_IDENT_DOCUMENT_ID); + if (hasOpenNameChecks) add(DfxApprovalBlocker.OPEN_NAME_CHECK); + + for (const documentSubType of DFX_APPROVAL_REQUIRED_DOCUMENTS) { + const matches = files.some((file) => { + if (!file.valid || file.subType !== documentSubType) return false; + if (documentSubType === FileSubType.IDENT_REPORT) + return file.kycStep?.name === KycStepName.IDENT && file.kycStep.isCompleted; + if (documentSubType === FileSubType.DFX_NAME_CHECK) return file.type === FileType.USER_NOTES; + if (documentSubType === FileSubType.PERSONAL_NAME_CHECK) return file.type === FileType.NAME_CHECK; + return true; + }); + if (!matches) add(DfxApprovalBlocker.MISSING_DOCUMENT, documentSubType); + } + + return { ready: blockers.length === 0, blockers }; + } +} diff --git a/src/subdomains/generic/kyc/services/dfx-approval-document.service.ts b/src/subdomains/generic/kyc/services/dfx-approval-document.service.ts new file mode 100644 index 0000000000..feb91a6141 --- /dev/null +++ b/src/subdomains/generic/kyc/services/dfx-approval-document.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { UserData } from '../../user/models/user-data/user-data.entity'; +import { FileSubType, FileType } from '../dto/kyc-file.dto'; +import { KycFile } from '../entities/kyc-file.entity'; +import { KycStep } from '../entities/kyc-step.entity'; +import { NameCheckLogRepository } from '../repositories/name-check-log.repository'; +import { DfxApprovalPdfService } from './dfx-approval-pdf.service'; +import { KycDocumentService } from './integration/kyc-document.service'; + +export const DFX_APPROVAL_GENERATED_DOCUMENTS = [ + FileSubType.GWG_FILE_COVER, + FileSubType.IDENTIFICATION_FORM, + FileSubType.CUSTOMER_PROFILE, + FileSubType.RISK_PROFILE, + FileSubType.FORM_A, + FileSubType.DFX_NAME_CHECK, +] as const; + +@Injectable() +export class DfxApprovalDocumentService { + constructor( + private readonly nameCheckLogRepo: NameCheckLogRepository, + private readonly pdfService: DfxApprovalPdfService, + private readonly documentService: KycDocumentService, + ) {} + + // `step` is optional: RiskProfile and FormA follow the account, not a KYC step, and the productive + // Sheet selects them from user data alone. It is only recorded in the document metadata. + async generateMissingPersonalDocuments( + userData: UserData, + step: KycStep | undefined, + files: KycFile[], + requestedDocuments: readonly FileSubType[] = DFX_APPROVAL_GENERATED_DOCUMENTS, + ): Promise { + const missing = requestedDocuments.filter( + (subType) => !files.some((file) => file.subType === subType && file.valid), + ); + if (!missing.length) return; + + const nameCheck = await this.nameCheckLogRepo.findOne({ + where: { userData: { id: userData.id } }, + order: { created: 'DESC' }, + }); + + const generatedAt = new Date(); + const failures: string[] = []; + for (const subType of missing) { + try { + // Only the NameCheck document depends on this evidence; failing it here keeps every other + // document of the same case unblocked. + if (subType === FileSubType.DFX_NAME_CHECK && !nameCheck) + throw new Error(`NameCheck evidence is missing for userData ${userData.id}`); + + const version = 'v1'; + const generationKey = `dfx-approval:${userData.id}:${subType}:${version}`; + // A retry keeps the name of the first attempt; the same string is printed into the document + // as its document number, so name and content must not drift apart. + const registered = await this.documentService.findGeneratedUserFile(generationKey); + const context = { userData, steps: userData.kycSteps, nameCheck, generatedAt, documentName: registered?.name }; + const data = await this.pdfService.generate(subType, context); + await this.documentService.ensureGeneratedUserFile( + generationKey, + userData, + FileType.USER_NOTES, + subType, + registered?.name ?? this.pdfService.fileName(subType, context), + data, + { workflow: 'DfxApproval', version, ...(step ? { stepId: String(step.id) } : {}) }, + ); + } catch (error) { + failures.push(`${subType}: ${(error as Error).message}`); + } + } + if (failures.length) throw new Error(`DfxApproval document generation failed (${failures.join('; ')})`); + } +} diff --git a/src/subdomains/generic/kyc/services/dfx-approval-pdf.service.ts b/src/subdomains/generic/kyc/services/dfx-approval-pdf.service.ts new file mode 100644 index 0000000000..715e3b0aa9 --- /dev/null +++ b/src/subdomains/generic/kyc/services/dfx-approval-pdf.service.ts @@ -0,0 +1,551 @@ +import { Injectable } from '@nestjs/common'; +import fontkit from '@pdf-lib/fontkit'; +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { KycIdentificationType } from '../../user/models/user-data/kyc-identification-type.enum'; +import { UserData } from '../../user/models/user-data/user-data.entity'; +import { KycFinancialResponse } from '../dto/input/kyc-financial-in.dto'; +import { FileSubType } from '../dto/kyc-file.dto'; +import { KycStep } from '../entities/kyc-step.entity'; +import { NameCheckLog, NameCheckRiskStatus } from '../entities/name-check-log.entity'; +import { KycStepName } from '../enums/kyc-step-name.enum'; + +type FinancialData = Record; + +// The overlay text is drawn with an embedded Unicode font instead of a PDF standard font: standard +// fonts are limited to WinAnsi and throw on every name, street or employer outside Latin-1 +// (Polish, Turkish, Baltic, Cyrillic, ...), which would leave those cases without a document. +// Liberation Sans is metrically compatible with Arial/Helvetica, so the layout is unchanged. +const FONT_DIRECTORY = '../assets/fonts'; +const REGULAR_FONT_FILE = 'LiberationSans-Regular.ttf'; +const BOLD_FONT_FILE = 'LiberationSans-Bold.ttf'; +const REPLACEMENT_CHARACTER = '?'; + +// Income and asset bands as answered in the FinancialData step, mapped to the wording of the +// productive Sheet template. +const FINANCIAL_BANDS: Record = { + '1m': " > 1'000'000 CHF", + '500k_1m': "zwischen 500'000 und 1'000'000 CHF", + '100k_500k': "zwischen 100'000 und 500'000 CHF", + '50k_100k': "zwischen 0 und 100'000 CHF", + '50k': "zwischen 0 und 100'000 CHF", +}; + +interface PdfFonts { + regular: PDFFont; + bold: PDFFont; + encodable: Set; +} + +export interface DfxApprovalPdfContext { + userData: UserData; + steps: KycStep[]; + nameCheck?: NameCheckLog; + generatedAt: Date; + // Set when the document was already registered under this name; keeps a regenerated document + // consistent with the stored file name. + documentName?: string; +} + +interface TextOptions { + x: number; + top: number; + width?: number; + height?: number; + size?: number; + color?: ReturnType; + bold?: boolean; +} + +const BLUE = rgb(0, 0, 1); +const BLACK = rgb(0, 0, 0); +const WHITE = rgb(1, 1, 1); + +const TEMPLATE_FILES: Partial> = { + [FileSubType.GWG_FILE_COVER]: 'gwg-file-cover.pdf', + [FileSubType.IDENTIFICATION_FORM]: 'identification-form.pdf', + [FileSubType.CUSTOMER_PROFILE]: 'customer-profile.pdf', + [FileSubType.RISK_PROFILE]: 'risk-profile.pdf', + [FileSubType.FORM_A]: 'form-a.pdf', + [FileSubType.DFX_NAME_CHECK]: 'name-check.pdf', +}; + +@Injectable() +export class DfxApprovalPdfService { + private readonly logger = new DfxLogger(DfxApprovalPdfService); + + private fontFiles?: Promise<[Buffer, Buffer]>; + + async generate(subType: FileSubType, context: DfxApprovalPdfContext): Promise { + const templateFile = TEMPLATE_FILES[subType]; + if (!templateFile) throw new Error(`Unsupported DfxApproval document subtype ${subType}`); + + const template = await readFile(join(__dirname, '../assets/dfx-approval', templateFile)); + const pdf = await PDFDocument.load(template); + const fonts = await this.embedFonts(pdf); + + switch (subType) { + case FileSubType.GWG_FILE_COVER: + this.renderCover(pdf, fonts, context); + break; + case FileSubType.IDENTIFICATION_FORM: + this.renderIdentificationForm(pdf, fonts, context); + break; + case FileSubType.CUSTOMER_PROFILE: + this.renderCustomerProfile(pdf, fonts, context); + break; + case FileSubType.RISK_PROFILE: + this.renderRiskProfile(pdf, fonts, context); + break; + case FileSubType.FORM_A: + this.renderFormA(pdf, fonts, context); + break; + case FileSubType.DFX_NAME_CHECK: + this.renderNameCheck(pdf, fonts, context); + break; + } + + return Buffer.from(await pdf.save({ useObjectStreams: false })); + } + + fileName(subType: FileSubType, context: DfxApprovalPdfContext): string { + const label = { + [FileSubType.GWG_FILE_COVER]: 'GwGFileDeckblatt', + [FileSubType.IDENTIFICATION_FORM]: 'Identifizierungsformular', + [FileSubType.CUSTOMER_PROFILE]: 'Kundenprofil', + [FileSubType.RISK_PROFILE]: 'Risikoprofil', + [FileSubType.FORM_A]: 'FormularA', + [FileSubType.DFX_NAME_CHECK]: 'NameCheck', + }[subType]; + if (!label) throw new Error(`Unsupported DfxApproval document subtype ${subType}`); + const documentDate = + subType === FileSubType.DFX_NAME_CHECK ? this.requiredNameCheck(context).created : context.generatedAt; + return `${this.compactDate(documentDate)}-${label}-0-${context.userData.id}-${this.compactTime(context.generatedAt)}.pdf`; + } + + private renderCover(pdf: PDFDocument, fonts: PdfFonts, context: DfxApprovalPdfContext): void { + const page = pdf.getPage(0); + page.drawRectangle({ x: 292, y: page.getHeight() - 116, width: 120, height: 14, color: WHITE }); + this.text(page, fonts, String(context.userData.id), { x: 296.4, top: 70.2 }); + this.text(page, fonts, this.documentNumber(FileSubType.GWG_FILE_COVER, context), { + x: 296.4, + top: 86.7, + width: 245, + }); + this.text(page, fonts, this.timestamp(context.generatedAt), { x: 296.4, top: 103.2, width: 160 }); + this.text(page, fonts, 'Privatperson', { x: 296.4, top: 119.7 }); + this.text(page, fonts, context.userData.verifiedName, { x: 296.4, top: 136.2, width: 245 }); + this.text(page, fonts, context.userData.naturalPersonName, { x: 296.4, top: 152.7, width: 245 }); + } + + private renderNameCheck(pdf: PDFDocument, fonts: PdfFonts, context: DfxApprovalPdfContext): void { + const nameCheck = this.requiredNameCheck(context); + const page = pdf.getPage(0); + this.text(page, fonts, String(context.userData.id), { x: 198.6, top: 70.1 }); + this.text(page, fonts, this.documentNumber(FileSubType.DFX_NAME_CHECK, context), { + x: 198.6, + top: 86.6, + width: 245, + }); + this.text(page, fonts, this.timestamp(nameCheck.created), { x: 198.6, top: 103.1 }); + this.text(page, fonts, 'Privatperson', { x: 198.6, top: 119.5 }); + this.text(page, fonts, context.userData.verifiedName, { x: 198.6, top: 136.0, width: 245 }); + this.text(page, fonts, this.timestamp(nameCheck.created), { x: 198.6, top: 200.5 }); + this.text(page, fonts, this.nameCheckResult(nameCheck), { x: 198.6, top: 217.0, width: 245 }); + this.text(page, fonts, nameCheck.result, { x: 198.6, top: 233.5, width: 360, height: 12 }); + } + + private renderFormA(pdf: PDFDocument, fonts: PdfFonts, context: DfxApprovalPdfContext): void { + const page = pdf.getPage(0); + const value = (text: unknown, top: number, height = 16): void => + this.text(page, fonts, text, { x: 202, top, width: 365, height, color: BLUE, bold: true, size: 9 }); + value(context.userData.id, 82.3); + value(context.userData.verifiedName, 231.9); + value(context.userData.surname, 349.6); + value(context.userData.firstname, 371.5); + value(this.date(context.userData.birthday), 393.4); + value(context.userData.nationality?.name, 415.4); + value(this.address(context.userData), 437.3, 30); + this.text(page, fonts, this.timestamp(context.userData.created), { + x: 16.5, + top: 549.5, + width: 180, + color: BLUE, + bold: true, + size: 9, + }); + } + + private renderIdentificationForm(pdf: PDFDocument, fonts: PdfFonts, context: DfxApprovalPdfContext): void { + const first = pdf.getPage(0); + const value = (text: unknown, top: number, height = 18): void => + this.text(first, fonts, text, { x: 257.1, top, width: 305, height, color: BLUE, bold: true, size: 9 }); + this.text(first, fonts, context.userData.id, { + x: 420.3, + top: 46.3, + width: 135, + color: BLUE, + bold: true, + size: 9, + }); + this.text(first, fonts, this.timestamp(context.generatedAt), { + x: 158.4, + top: 211.8, + width: 395, + color: BLUE, + bold: true, + size: 9, + }); + value(context.userData.naturalPersonName, 294.5); + value(this.address(context.userData), 318.2); + value(context.userData.phone, 342.0); + value(context.userData.mail, 365.7); + value(this.date(context.userData.birthday), 389.5); + value(context.userData.nationality?.name, 413.2); + value(context.userData.identDocumentType, 437.0); + + const second = pdf.getPage(1); + this.text(second, fonts, this.timestamp(context.userData.created), { + x: 257.1, + top: 402.1, + width: 305, + color: BLUE, + bold: true, + size: 9, + }); + const identificationType = context.userData.identificationType; + [424.5, 446.0, 465.0].forEach((top) => this.uncheck(second, 239.3, top)); + if (identificationType === KycIdentificationType.VIDEO_ID) this.check(second, 239.3, 424.5); + else if (identificationType === KycIdentificationType.MANUAL) this.check(second, 239.3, 465.0); + else this.check(second, 239.3, 446.0); + + const language = context.userData.language?.name; + [522.5, 546.3].forEach((top) => this.uncheck(second, 239.3, top)); + [522.5, 546.3].forEach((top) => this.uncheck(second, 402.5, top)); + if (language === 'German' || language === 'Portuguese') this.check(second, 239.3, 522.5); + else if (language === 'Italian') this.check(second, 239.3, 546.3); + else if (language === 'French') this.check(second, 402.5, 546.3); + else this.check(second, 402.5, 522.5); + + const third = pdf.getPage(2); + this.uncheck(third, 233.5, 118.5); + this.check(third, 233.5, 118.5); + + const fourth = pdf.getPage(3); + this.uncheck(fourth, 239.3, 202.0); + this.uncheck(fourth, 239.3, 219.0); + if (context.userData.sellVolume > 0) this.check(fourth, 239.3, 202.0); + if (context.userData.cryptoVolume > 0) this.check(fourth, 239.3, 219.0); + } + + private renderCustomerProfile(pdf: PDFDocument, fonts: PdfFonts, context: DfxApprovalPdfContext): void { + const financial = this.financialData(context.steps); + const first = pdf.getPage(0); + const value = (text: unknown, top: number, height = 20): void => + this.text(first, fonts, text, { x: 249, top, width: 322, height, color: BLUE, bold: true, size: 8.7 }); + this.text(first, fonts, context.userData.id, { + x: 440.1, + top: 83, + width: 125, + color: BLUE, + bold: true, + size: 9, + }); + this.text(first, fonts, context.userData.verifiedName, { + x: 159, + top: 270.9, + width: 410, + color: BLUE, + bold: true, + size: 9, + }); + this.text(first, fonts, this.timestamp(context.generatedAt), { + x: 159, + top: 368.1, + width: 410, + color: BLUE, + bold: true, + size: 9, + }); + value(this.employment(financial), 440.4, 56); + value(this.financialBand(financial.income), 518.7); + value(this.financialBand(financial.assets), 552.4); + value(this.volumeBand(context.userData.totalVolumeChfAuditPeriod ?? 0), 609.5); + + const source = financial.source_of_funds; + [633.2, 647.3, 661.4, 689.6, 703.8].forEach((top) => this.uncheck(first, 267.2, top)); + if (source === 'business' || financial.occupation === 'self_employed' || financial.occupation === 'inhaber') + this.check(first, 267.2, 633.2); + if (source === 'business') this.check(first, 267.2, 647.3); + if (source === 'real_estate_sale') this.check(first, 267.2, 661.4); + if (source === 'OTHER') this.check(first, 267.2, 703.8); + if ((context.userData.totalVolumeChfAuditPeriod ?? 0) > 100000) + value("Siehe eigene Aktennotiz bezüglich Freigabe Handelsvolumen > 100'000 CHF", 722.0, 36); + + const second = pdf.getPage(1); + this.uncheck(second, 282.2, 167.5); + this.uncheck(second, 282.2, 197.1); + if (context.userData.sellVolume > 0) this.check(second, 282.2, 167.5); + if (context.userData.cryptoVolume > 0) this.check(second, 282.2, 197.1); + } + + private renderRiskProfile(pdf: PDFDocument, fonts: PdfFonts, context: DfxApprovalPdfContext): void { + const first = pdf.getPage(0); + this.text(first, fonts, context.userData.id, { + x: 430.9, + top: 79.6, + width: 125, + color: BLUE, + bold: true, + size: 9, + }); + this.text(first, fonts, context.userData.verifiedName, { + x: 135.9, + top: 265.7, + width: 420, + color: BLUE, + bold: true, + size: 9, + }); + this.text(first, fonts, this.timestamp(context.userData.amlListAddedDate), { + x: 135.9, + top: 347.9, + width: 420, + color: BLUE, + bold: true, + size: 9, + }); + this.booleanChecks(first, !context.userData.pep, 514.4, 529.2); + this.booleanChecks(first, !context.userData.pep, 592.8, 606.3); + + const second = pdf.getPage(1); + this.booleanChecks(second, context.userData.highRisk === false, 150.0, 166.7); + this.booleanChecks(second, context.userData.complexOrgStructure === false, 301.0, 316.5); + const normalCountry = context.userData.country?.fatfEnable === true; + this.booleanChecks(second, normalCountry, 455.6, 471.7); + this.booleanChecks(second, normalCountry, 503.8, 519.9); + this.booleanChecks(second, normalCountry, 568.1, 584.2); + this.booleanChecks(second, context.userData.highRisk === false, 677.4, 693.5); + + const third = pdf.getPage(2); + this.booleanChecks(third, normalCountry, 134.6, 166.7); + this.booleanChecks(third, context.userData.highRisk === false, 313.0, 329.1); + } + + private booleanChecks(page: PDFPage, positive: boolean, positiveTop: number, negativeTop: number): void { + this.uncheck(page, 259.4, positiveTop); + this.uncheck(page, 259.4, negativeTop); + this.check(page, 259.4, positive ? positiveTop : negativeTop); + } + + private text(page: PDFPage, fonts: PdfFonts, raw: unknown, options: TextOptions): void { + if (raw == null || raw === '') return; + const value = String(raw) + .split(/\r?\n/) + .map((line) => line.replace(/[\t ]+/g, ' ').trim()) + .join('\n') + .trim(); + if (!value) return; + const font = options.bold ? fonts.bold : fonts.regular; + const size = options.size ?? 8.5; + const width = options.width ?? 260; + const lineHeight = size * 1.12; + const maxLines = Math.max(1, Math.floor((options.height ?? lineHeight) / lineHeight)); + const lines = value + .split('\n') + .flatMap((line) => this.wrap(line, font, size, width, maxLines)) + .slice(0, maxLines); + + // A value that does not fit its field is cut off; saying so turns a silently incomplete GwG + // document into a visible one. + const collapse = (text: string): string => text.replace(/\s+/g, ' ').trim(); + if (collapse(lines.join(' ')) !== collapse(value)) + this.logger.warn(`DfxApproval document: a value did not fit its field and was truncated`); + lines.forEach((line, index) => { + page.drawText(this.encodable(line, fonts), { + x: options.x, + y: page.getHeight() - options.top - size - index * lineHeight + 1.5, + size, + font, + color: options.color ?? BLACK, + }); + }); + } + + private wrap(value: string, font: PDFFont, size: number, width: number, maxLines: number): string[] { + const words = value.split(' '); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (font.widthOfTextAtSize(candidate, size) <= width) { + current = candidate; + } else { + if (current) lines.push(current); + current = word; + if (lines.length === maxLines) break; + } + } + if (current && lines.length < maxLines) lines.push(this.fit(current, font, size, width)); + return lines.slice(0, maxLines); + } + + private fit(value: string, font: PDFFont, size: number, width: number): string { + let result = value; + while (result && font.widthOfTextAtSize(result, size) > width) result = result.slice(0, -1); + return result; + } + + // The font files are read once per process; each document embeds only the glyphs it uses. + private async embedFonts(pdf: PDFDocument): Promise { + this.fontFiles ??= Promise.all([ + readFile(join(__dirname, FONT_DIRECTORY, REGULAR_FONT_FILE)), + readFile(join(__dirname, FONT_DIRECTORY, BOLD_FONT_FILE)), + ]); + const [regularFile, boldFile] = await this.fontFiles; + + pdf.registerFontkit(fontkit); + const regular = await pdf.embedFont(regularFile, { subset: true }); + const bold = await pdf.embedFont(boldFile, { subset: true }); + + return { regular, bold, encodable: new Set(regular.getCharacterSet()) }; + } + + // Characters the font has no glyph for (e.g. CJK) are replaced instead of failing the document: + // a document with one substituted character is worth more than no document at all. + private encodable(value: string, fonts: PdfFonts): string { + return Array.from(value) + .map((character) => (fonts.encodable.has(character.codePointAt(0) ?? 0) ? character : REPLACEMENT_CHARACTER)) + .join(''); + } + + private check(page: PDFPage, x: number, top: number): void { + const size = 12; + const y = page.getHeight() - top - size; + page.drawRectangle({ x, y, width: size, height: size, color: BLUE, borderColor: BLUE, borderWidth: 0.8 }); + page.drawLine({ start: { x: x + 2.2, y: y + 6.2 }, end: { x: x + 5, y: y + 3.2 }, color: WHITE, thickness: 1.6 }); + page.drawLine({ start: { x: x + 4.8, y: y + 3.2 }, end: { x: x + 10, y: y + 9.4 }, color: WHITE, thickness: 1.6 }); + } + + private uncheck(page: PDFPage, x: number, top: number): void { + const size = 12; + const y = page.getHeight() - top - size; + page.drawRectangle({ x, y, width: size, height: size, color: WHITE, borderColor: BLUE, borderWidth: 0.8 }); + } + + private financialData(steps: KycStep[]): FinancialData { + const financialStep = steps + .filter((step) => step.name === KycStepName.FINANCIAL_DATA && step.isCompleted) + .sort((a, b) => b.sequenceNumber - a.sequenceNumber)[0]; + if (!financialStep?.result) throw new Error('Completed FinancialData result is missing'); + + let responses: KycFinancialResponse[]; + try { + responses = JSON.parse(financialStep.result) as KycFinancialResponse[]; + } catch (error) { + throw new Error(`FinancialData result is invalid JSON: ${(error as Error).message}`); + } + if (!Array.isArray(responses) || responses.some((response) => !response?.key || !response?.value)) + throw new Error('FinancialData result has an invalid structure'); + return Object.fromEntries(responses.map((response) => [response.key, response.value])); + } + + private employment(financial: FinancialData): string { + const occupation = financial.occupation === 'self_employed' ? 'Selbständig' : financial.occupation; + return [ + occupation && `Beruf: ${occupation}`, + financial.occupation_description && `Arbeitgeber: ${financial.occupation_description}`, + financial.sector && `Branche: ${financial.sector}`, + ] + .filter(Boolean) + .join('\n') + .replace(/n\.a\./g, ''); + } + + private financialBand(value?: string): string { + if (value && !(value in FINANCIAL_BANDS)) + this.logger.warn(`DfxApproval CustomerProfile: unknown FinancialData band '${value}', left empty`); + + return FINANCIAL_BANDS[value ?? ''] ?? ''; + } + + private volumeBand(volume: number): string { + if (volume <= 100000) return "zwischen 0 und 100'000 CHF"; + if (volume <= 500000) return "zwischen 100'000 und 500'000 CHF"; + if (volume <= 1000000) return "zwischen 500'000 und 1'000'000 CHF"; + if (volume <= 5000000) return "zwischen 1'000'000 und 5'000'000 CHF"; + if (volume <= 10000000) return "zwischen 5'000'000 und 10'000'000 CHF"; + if (volume <= 15000000) return "zwischen 10'000'000 und 15'000'000 CHF"; + return "grösser 15'000'000 CHF"; + } + + private nameCheckResult(nameCheck: NameCheckLog): string { + try { + const result = JSON.parse(nameCheck.result) as { total_hits?: number }; + if (result.total_hits === 0) return 'keine Treffer gefunden'; + } catch { + // The productive Sheet leaves the result field empty when the raw result cannot be parsed. + } + return nameCheck.riskStatus === NameCheckRiskStatus.NOT_SANCTIONED ? 'keine Treffer gefunden' : ''; + } + + private requiredNameCheck(context: DfxApprovalPdfContext): NameCheckLog { + if (!context.nameCheck) throw new Error('NameCheck evidence is missing'); + return context.nameCheck; + } + + private documentNumber(subType: FileSubType, context: DfxApprovalPdfContext): string { + return (context.documentName ?? this.fileName(subType, context)).replace(/\.pdf$/, ''); + } + + private address(userData: UserData): string { + return [ + [userData.street, userData.houseNumber].filter(Boolean).join(' '), + [userData.zip, userData.location].filter(Boolean).join(' '), + userData.country?.name, + ] + .filter(Boolean) + .join(', '); + } + + private date(value?: Date): string { + if (!value) return ''; + return new Intl.DateTimeFormat('de-CH', { timeZone: 'Europe/Zurich' }).format(value); + } + + private timestamp(value?: Date): string { + if (!value) return ''; + const parts = this.zurichParts(value); + return `${parts.day}.${parts.month}.${parts.year} ${parts.hour}:${parts.minute}:${parts.second}`; + } + + private compactDate(value?: Date): string { + if (!value) throw new Error('Document date is missing'); + const parts = this.zurichParts(value); + return `${parts.year}${parts.month}${parts.day}`; + } + + private compactTime(value: Date): string { + const parts = this.zurichParts(value); + return `${parts.hour}${parts.minute}${parts.second}`; + } + + private zurichParts(value: Date): Record<'year' | 'month' | 'day' | 'hour' | 'minute' | 'second', string> { + const parts = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Europe/Zurich', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(value); + return Object.fromEntries(parts.map((part) => [part.type, part.value])) as Record< + 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second', + string + >; + } +} diff --git a/src/subdomains/generic/kyc/services/dfx-approval-workflow.service.ts b/src/subdomains/generic/kyc/services/dfx-approval-workflow.service.ts new file mode 100644 index 0000000000..83de7c3651 --- /dev/null +++ b/src/subdomains/generic/kyc/services/dfx-approval-workflow.service.ts @@ -0,0 +1,499 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { Config } from 'src/config/config'; +import { SettingService } from 'src/shared/models/setting/setting.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { Between, EntityManager, In, IsNull, MoreThanOrEqual, Not } from 'typeorm'; +import { AccountType } from '../../user/models/user-data/account-type.enum'; +import { UserData } from '../../user/models/user-data/user-data.entity'; +import { KycLevel, KycStatus, KycType, UserDataStatus } from '../../user/models/user-data/user-data.enum'; +import { FileSubType } from '../dto/kyc-file.dto'; +import { KycStep } from '../entities/kyc-step.entity'; +import { NameCheckLog, NameCheckRiskStatus } from '../entities/name-check-log.entity'; +import { StepLog } from '../entities/step-log.entity'; +import { KycStepName } from '../enums/kyc-step-name.enum'; +import { KycLogType } from '../enums/kyc.enum'; +import { ReviewStatus } from '../enums/review-status.enum'; +import { KycStepRepository } from '../repositories/kyc-step.repository'; +import { DfxApprovalCheckService } from './dfx-approval-check.service'; +import { DfxApprovalDocumentService } from './dfx-approval-document.service'; +import { KycLogService } from './kyc-log.service'; +import { KycNotificationService } from './kyc-notification.service'; +import { NameCheckService } from './name-check.service'; + +// Separate advisory-lock namespaces: step IDs and user data IDs are independent sequences and must +// not collide in the same lock space. +const STEP_ADVISORY_LOCK_NAMESPACE = 1145466968; +const USER_DATA_ADVISORY_LOCK_NAMESPACE = 1145466969; + +// Accounts excluded from RiskProfile and FormA generation, kept out of the source tree because they +// are productive account IDs. Empty unless the setting is present. +export const DOCUMENT_EXCLUSION_SETTING_KEY = 'dfxApprovalDocumentExclusions'; + +// Lock aliases must be lower case: TypeORM emits `FOR UPDATE OF ` unquoted and Postgres +// folds unquoted identifiers to lower case, so a camelCase alias is not found in the FROM clause. +const STEP_LOCK_ALIAS = 'step'; +const USER_DATA_LOCK_ALIAS = 'user_data'; + +@Injectable() +export class DfxApprovalWorkflowService { + private readonly logger = new DfxLogger(DfxApprovalWorkflowService); + + constructor( + private readonly kycStepRepo: KycStepRepository, + private readonly checkService: DfxApprovalCheckService, + private readonly documentService: DfxApprovalDocumentService, + private readonly nameCheckService: NameCheckService, + private readonly notificationService: KycNotificationService, + private readonly settingService: SettingService, + private readonly logService: KycLogService, + ) {} + + @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.KYC_DFX_APPROVAL, timeout: 900 }) + async reviewPersonalApprovals(): Promise { + if (!Config.kyc.dfxApprovalWorkflowEnabled) return; + + const exclusions = await this.documentExclusions(); + + await this.initializePendingPersonalRiskData(); + + const steps = await this.kycStepRepo.find({ + where: { + name: KycStepName.DFX_APPROVAL, + status: ReviewStatus.MANUAL_REVIEW, + userData: { accountType: AccountType.PERSONAL, kycLevel: MoreThanOrEqual(KycLevel.LEVEL_40) }, + }, + order: { created: 'ASC' }, + take: 50, + }); + + for (const step of steps) { + try { + await this.withStepLock(step.id, () => this.processPersonalApproval(step.id)); + } catch (error) { + this.logger.error(`DfxApproval workflow failed for step ${step.id}:`, error); + } + } + + await this.generatePendingPersonalDocuments(exclusions); + } + + // Productive account IDs stay in configuration, never in the source tree. + private async documentExclusions(): Promise { + return (await this.settingService.getObjCached(DOCUMENT_EXCLUSION_SETTING_KEY, [])) ?? []; + } + + private async processPersonalApproval(stepId: number): Promise { + let step = await this.loadStep(stepId); + if (step.status !== ReviewStatus.MANUAL_REVIEW || step.userData.kycLevel < KycLevel.LEVEL_40) return; + + const hasOpenNameChecks = await this.nameCheckService.hasOpenNameChecks(step.userData); + await this.initializePersonalRiskData(step.userData, hasOpenNameChecks); + + step = await this.loadStep(stepId); + // Only the documents that belong to this step. CustomerProfile is generated under the lock of + // the FinancialData step, RiskProfile and FormA under the user-data lock: a document written + // from two different lock keys can be rendered and uploaded twice at the same time. + await this.documentService.generateMissingPersonalDocuments( + step.userData, + step, + step.userData.kycFiles, + this.eligibleApprovalStepDocuments(step.userData), + ); + + const completedUser = await this.completeIfReady(stepId); + if (completedUser) await this.notificationService.kycChanged(completedUser, completedUser.kycLevel); + } + + private async initializePersonalRiskData(userData: UserData, hasOpenNameChecks: boolean): Promise { + if (!userData.hasValidNameCheckDate || hasOpenNameChecks) return; + + const update: Partial = {}; + if (userData.pep == null) update.pep = false; + if (userData.highRisk == null) update.highRisk = false; + if (userData.complexOrgStructure == null) update.complexOrgStructure = false; + if (userData.depositLimit == null) update.depositLimit = 100000; + if (!Object.keys(update).length) return; + + await this.kycStepRepo.manager.transaction(async (manager) => { + const current = await this.lockUserData(manager, userData.id); + if (!current) throw new NotFoundException('UserData not found'); + + const effectiveUpdate = Object.fromEntries( + Object.entries(update).filter(([key]) => current[key as keyof UserData] == null), + ) as Partial; + if (!Object.keys(effectiveUpdate).length) return; + + await this.logService.createLogInternal( + current, + KycLogType.KYC, + JSON.stringify({ + workflow: 'DfxApproval', + action: 'InitializePersonalRiskData', + before: Object.fromEntries(Object.keys(effectiveUpdate).map((key) => [key, current[key as keyof UserData]])), + after: effectiveUpdate, + }), + manager, + ); + await manager.update(UserData, current.id, effectiveUpdate); + }); + } + + private async initializePendingPersonalRiskData(): Promise { + const users = await this.kycStepRepo.manager.find(UserData, { + where: { + accountType: AccountType.PERSONAL, + highRisk: IsNull(), + lastNameCheckDate: Not(IsNull()), + kycLevel: MoreThanOrEqual(KycLevel.LEVEL_30), + }, + order: { id: 'ASC' }, + take: 50, + }); + + for (const userData of users) { + try { + // A new sanctioned hit does not refresh `lastNameCheckDate` (that only happens on a clean + // result), so a still-valid date is no proof that no check is open. Ask for it. + const hasOpenNameChecks = await this.nameCheckService.hasOpenNameChecks(userData); + await this.initializePersonalRiskData(userData, hasOpenNameChecks); + } catch (error) { + this.logger.error(`DfxApproval risk initialization failed for userData ${userData.id}:`, error); + } + } + } + + private async generatePendingPersonalDocuments(exclusions: number[]): Promise { + await this.generatePendingApprovalStepDocuments(); + await this.generatePendingCustomerProfiles(); + await this.generatePendingRiskAndFormADocuments(exclusions); + } + + private async generatePendingApprovalStepDocuments(): Promise { + const steps = await this.kycStepRepo.find({ + where: { + name: KycStepName.DFX_APPROVAL, + status: In([ReviewStatus.INTERNAL_REVIEW, ReviewStatus.MANUAL_REVIEW]), + userData: { accountType: AccountType.PERSONAL, verifiedName: Not(IsNull()) }, + }, + relations: { userData: true }, + order: { created: 'DESC' }, + take: 500, + }); + + for (const candidate of steps) { + try { + await this.withStepLock(candidate.id, async () => { + const step = await this.loadStep(candidate.id); + const requested = this.eligibleApprovalStepDocuments(step.userData); + await this.documentService.generateMissingPersonalDocuments( + step.userData, + step, + step.userData.kycFiles, + requested, + ); + }); + } catch (error) { + this.logger.error(`DfxApproval step document generation failed for step ${candidate.id}:`, error); + } + } + } + + private async generatePendingCustomerProfiles(): Promise { + const steps = await this.kycStepRepo.find({ + where: { + name: KycStepName.FINANCIAL_DATA, + status: ReviewStatus.COMPLETED, + userData: { + accountType: AccountType.PERSONAL, + kycLevel: Between(KycLevel.LEVEL_30, KycLevel.LEVEL_40), + verifiedName: Not(IsNull()), + }, + }, + relations: { userData: true }, + order: { created: 'DESC' }, + take: 500, + }); + + for (const step of steps) { + try { + await this.withStepLock(step.id, async () => { + const userData = await this.loadUserData(this.kycStepRepo.manager, step.userData.id); + await this.documentService.generateMissingPersonalDocuments(userData, step, userData.kycFiles, [ + FileSubType.CUSTOMER_PROFILE, + ]); + }); + } catch (error) { + this.logger.error(`DfxApproval customer profile generation failed for step ${step.id}:`, error); + } + } + } + + private async generatePendingRiskAndFormADocuments(exclusions: number[]): Promise { + const users = await this.kycStepRepo.manager.find(UserData, { + where: { + ...(exclusions.length ? { id: Not(In(exclusions)) } : {}), + accountType: AccountType.PERSONAL, + status: Not(UserDataStatus.MERGED), + kycType: KycType.DFX, + kycLevel: Between(KycLevel.LEVEL_30, KycLevel.LEVEL_40), + }, + relations: { + country: true, + verifiedCountry: true, + nationality: true, + language: true, + wallet: true, + kycSteps: true, + kycFiles: { kycStep: true }, + }, + order: { created: 'DESC' }, + take: 500, + }); + + for (const userData of users) { + const requested = this.eligibleRiskAndFormADocuments(userData, exclusions); + + try { + // These two documents follow the account, not a KYC step. Locking on the user data keeps + // accounts without a DfxApproval or FinancialData step in scope, which the productive Sheet + // covers as well. + await this.withUserDataLock(userData.id, () => + this.documentService.generateMissingPersonalDocuments( + userData, + this.documentAnchorStep(userData), + userData.kycFiles, + requested, + ), + ); + } catch (error) { + this.logger.error(`DfxApproval risk/FormA generation failed for userData ${userData.id}:`, error); + } + } + } + + // Optional: only used to reference the triggering step in the document metadata. + private documentAnchorStep(userData: UserData): KycStep | undefined { + return [...(userData.kycSteps ?? [])] + .filter((step) => [KycStepName.DFX_APPROVAL, KycStepName.FINANCIAL_DATA].includes(step.name)) + .sort((a, b) => b.created.getTime() - a.created.getTime())[0]; + } + + private eligibleApprovalStepDocuments(userData: UserData): FileSubType[] { + const documents: FileSubType[] = []; + if (userData.verifiedName) documents.push(FileSubType.GWG_FILE_COVER); + if (userData.status !== UserDataStatus.MERGED && userData.nationality) + documents.push(FileSubType.IDENTIFICATION_FORM); + if (userData.status !== UserDataStatus.MERGED && userData.verifiedName) documents.push(FileSubType.DFX_NAME_CHECK); + return documents; + } + + private eligibleRiskAndFormADocuments(userData: UserData, exclusions: number[]): FileSubType[] { + const eligible = + userData.accountType === AccountType.PERSONAL && + userData.status !== UserDataStatus.MERGED && + userData.kycType === KycType.DFX && + userData.kycLevel >= KycLevel.LEVEL_30 && + userData.kycLevel < KycLevel.LEVEL_50 && + !exclusions.includes(userData.id); + if (!eligible) return []; + + const documents = [FileSubType.FORM_A]; + if (userData.highRisk === false && userData.country?.fatfEnable === true) documents.push(FileSubType.RISK_PROFILE); + return documents; + } + + private async completeIfReady(stepId: number): Promise { + return this.kycStepRepo.manager.transaction(async (manager) => { + const step = await this.lockStep(manager, stepId); + if (!step || step.status !== ReviewStatus.MANUAL_REVIEW) return undefined; + + const userData = await this.loadUserData(manager, step.userData.id); + step.userData = userData; + const hasOpenNameChecks = await manager.exists(NameCheckLog, { + where: { + userData: { id: userData.id }, + riskEvaluation: IsNull(), + riskStatus: NameCheckRiskStatus.SANCTIONED, + }, + }); + const status = this.checkService.evaluatePersonal(userData, step, userData.kycFiles, hasOpenNameChecks); + if (!status.ready) { + // The gate decision is the operational signal of this workflow: without the blockers, a case + // waiting forever is indistinguishable from a case nobody looked at. + this.logger.verbose( + `DfxApproval step ${step.id} not ready: ${status.blockers + .map((blocker) => blocker.code + (blocker.documentSubType ? `(${blocker.documentSubType})` : '')) + .join(', ')}`, + ); + return undefined; + } + + const result = JSON.stringify({ + workflow: 'DfxApproval', + mode: 'Automatic', + version: 'v1', + completedAt: new Date().toISOString(), + complianceReview: { + complexOrgStructure: 'Nein', + highRisk: 'Nein', + depositLimit: '100000', + amlAccountType: 'natural person', + processedBy: 'DFX API', + finalDecision: 'Akzeptiert', + }, + }); + const finalKycLevel = Math.max(userData.kycLevel, KycLevel.LEVEL_50); + + // Every column this transaction overwrites is recorded with its previous value first, so the + // prior state stays reconstructible from the database alone. + await this.logService.createLogInternal( + userData, + KycLogType.KYC, + JSON.stringify({ + workflow: 'DfxApproval', + action: 'AutomaticApproval', + before: { + stepStatus: step.status, + kycLevel: userData.kycLevel, + kycStatus: userData.kycStatus, + complexOrgStructure: userData.complexOrgStructure, + highRisk: userData.highRisk, + depositLimit: userData.depositLimit, + amlAccountType: userData.amlAccountType, + }, + after: { + stepStatus: ReviewStatus.COMPLETED, + kycLevel: finalKycLevel, + kycStatus: KycStatus.COMPLETED, + complexOrgStructure: false, + highRisk: false, + depositLimit: 100000, + amlAccountType: 'natural person', + }, + }), + manager, + ); + + const [stepId_, stepUpdate] = step.complete(result); + await manager.update(KycStep, stepId_, { ...stepUpdate, comment: null }); + await manager.save( + manager.create(StepLog, { + type: KycLogType.STEP, + userData: { id: userData.id }, + kycStep: { id: step.id }, + status: ReviewStatus.COMPLETED, + result, + }), + ); + const userStatusUpdate = { + ...(userData.kycLevel < KycLevel.LEVEL_50 ? { kycLevel: KycLevel.LEVEL_50 } : {}), + kycStatus: KycStatus.COMPLETED, + complexOrgStructure: false, + highRisk: false, + depositLimit: 100000, + amlAccountType: 'natural person', + }; + await manager.update(UserData, userData.id, userStatusUpdate); + // Same wording as the manual approval path (KycService.createKycLevelLog), so existing log + // evaluations keep matching. + await this.logService.createLogInternal( + userData, + KycLogType.KYC, + `KycLevel changed to ${finalKycLevel}`, + manager, + ); + + Object.assign(userData, userStatusUpdate); + return userData; + }); + } + + // Row lock for the approval transaction. The step is joined to its user data because a + // `ManyToOne` without `eager` is not populated by a plain load, and the completion needs the + // user data ID. `FOR UPDATE OF` names the step alone: Postgres rejects `FOR UPDATE` on the + // nullable side of an outer join, and the user data is only read here, never written. + private async lockStep(manager: EntityManager, stepId: number): Promise { + return manager + .createQueryBuilder(KycStep, STEP_LOCK_ALIAS) + .leftJoinAndSelect(`${STEP_LOCK_ALIAS}.userData`, 'joinedUserData') + .where(`${STEP_LOCK_ALIAS}.id = :stepId`, { stepId }) + .setLock('pessimistic_write', undefined, [STEP_LOCK_ALIAS]) + .getOne() + .then((step) => step ?? undefined); + } + + // Row lock on user data only. A find with relations would join the eager, nullable relations of + // `UserData` (country, verifiedCountry, organization), which Postgres refuses to lock. + private async lockUserData(manager: EntityManager, userDataId: number): Promise { + return manager + .createQueryBuilder(UserData, USER_DATA_LOCK_ALIAS) + .where(`${USER_DATA_LOCK_ALIAS}.id = :userDataId`, { userDataId }) + .setLock('pessimistic_write', undefined, [USER_DATA_LOCK_ALIAS]) + .getOne() + .then((userData) => userData ?? undefined); + } + + private async loadStep(stepId: number): Promise { + const step = await this.kycStepRepo.findOne({ + where: { id: stepId }, + relations: { + userData: { + country: true, + verifiedCountry: true, + nationality: true, + language: true, + wallet: true, + kycSteps: true, + kycFiles: { kycStep: true }, + }, + }, + }); + if (!step) throw new NotFoundException('DfxApproval step not found'); + return step; + } + + private async loadUserData(manager: EntityManager, userDataId: number): Promise { + const userData = await manager.findOne(UserData, { + where: { id: userDataId }, + relations: { + country: true, + verifiedCountry: true, + nationality: true, + language: true, + wallet: true, + kycSteps: true, + kycFiles: { kycStep: true }, + }, + }); + if (!userData) throw new NotFoundException('UserData not found'); + return userData; + } + + private async withStepLock(stepId: number, action: () => Promise): Promise { + return this.withAdvisoryLock(STEP_ADVISORY_LOCK_NAMESPACE, stepId, action); + } + + private async withUserDataLock(userDataId: number, action: () => Promise): Promise { + return this.withAdvisoryLock(USER_DATA_ADVISORY_LOCK_NAMESPACE, userDataId, action); + } + + private async withAdvisoryLock(namespace: number, key: number, action: () => Promise): Promise { + const runner = this.kycStepRepo.manager.connection.createQueryRunner(); + await runner.connect(); + let acquired = false; + try { + const rows = (await runner.query('SELECT pg_try_advisory_lock($1, $2) AS acquired', [namespace, key])) as { + acquired: boolean; + }[]; + acquired = rows[0]?.acquired === true; + + if (acquired) await action(); + } finally { + if (acquired) await runner.query('SELECT pg_advisory_unlock($1, $2)', [namespace, key]); + await runner.release(); + } + } +} diff --git a/src/subdomains/generic/kyc/services/integration/__tests__/kyc-document.service.spec.ts b/src/subdomains/generic/kyc/services/integration/__tests__/kyc-document.service.spec.ts index 77f8b25e0e..04fa4b6372 100644 --- a/src/subdomains/generic/kyc/services/integration/__tests__/kyc-document.service.spec.ts +++ b/src/subdomains/generic/kyc/services/integration/__tests__/kyc-document.service.spec.ts @@ -2,21 +2,24 @@ // spy and no real S3/Azure/mock storage is touched. const uploadWormBlobMock = jest.fn(); const copyBlobsMock = jest.fn(); +const listKeysMock = jest.fn(); jest.mock('src/integration/infrastructure/storage/storage.factory', () => ({ createStorageService: jest.fn(() => ({ uploadWormBlob: (...args: any[]) => uploadWormBlobMock(...args), copyBlobs: (...args: any[]) => copyBlobsMock(...args), + listKeys: (...args: any[]) => listKeysMock(...args), + blobUrl: (name: string) => `https://storage/${name}`, })), })); import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; -import { FileType } from '../../../dto/kyc-file.dto'; +import { FileSubType, FileType } from '../../../dto/kyc-file.dto'; import { KycFile } from '../../../entities/kyc-file.entity'; import { ContentType } from '../../../enums/content-type.enum'; import { KycFileService } from '../../kyc-file.service'; -import { KycDocumentService } from '../kyc-document.service'; +import { GENERATION_KEY_INDEX, KycDocumentService } from '../kyc-document.service'; describe('KycDocumentService - storage', () => { let service: KycDocumentService; @@ -65,6 +68,103 @@ describe('KycDocumentService - storage', () => { expect(uploadWormBlobMock).not.toHaveBeenCalled(); }); + describe('ensureGeneratedUserFile', () => { + it('stores an invalid row and marks it valid only after the WORM upload succeeds', async () => { + const generated = Object.assign(new KycFile(), { id: 17, name: 'generated.pdf', valid: false }); + (kycFileService.getByGenerationKey as jest.Mock).mockResolvedValue(null); + (kycFileService.createKycFile as jest.Mock).mockResolvedValue(generated); + + await service.ensureGeneratedUserFile( + 'dfx-approval:11:RiskProfile:v1', + userData, + FileType.USER_NOTES, + FileSubType.RISK_PROFILE, + generated.name, + Buffer.from('pdf'), + ); + + expect(kycFileService.createKycFile).toHaveBeenCalledWith( + expect.objectContaining({ generationKey: 'dfx-approval:11:RiskProfile:v1', valid: false }), + ); + expect(uploadWormBlobMock).toHaveBeenCalled(); + expect(kycFileService.markValid).toHaveBeenCalledWith(generated); + }); + + it('verifies and reuses an already valid generated document', async () => { + const generated = Object.assign(new KycFile(), { id: 17, name: 'generated.pdf', valid: true }); + (kycFileService.getByGenerationKey as jest.Mock).mockResolvedValue(generated); + listKeysMock.mockImplementation((prefix: string) => Promise.resolve([prefix])); + + await service.ensureGeneratedUserFile( + 'dfx-approval:11:RiskProfile:v1', + userData, + FileType.USER_NOTES, + FileSubType.RISK_PROFILE, + generated.name, + Buffer.from('pdf'), + ); + + expect(listKeysMock).toHaveBeenCalled(); + expect(uploadWormBlobMock).not.toHaveBeenCalled(); + expect(kycFileService.markValid).not.toHaveBeenCalled(); + }); + + it('continues with the winning row when two workers create the same generation key', async () => { + const generated = Object.assign(new KycFile(), { id: 18, name: 'generated.pdf', valid: false }); + (kycFileService.getByGenerationKey as jest.Mock).mockResolvedValueOnce(null).mockResolvedValueOnce(generated); + (kycFileService.createKycFile as jest.Mock).mockRejectedValue({ + driverError: { code: '23505', constraint: GENERATION_KEY_INDEX }, + }); + + await service.ensureGeneratedUserFile( + 'dfx-approval:11:RiskProfile:v1', + userData, + FileType.USER_NOTES, + FileSubType.RISK_PROFILE, + generated.name, + Buffer.from('pdf'), + ); + + expect(kycFileService.getByGenerationKey).toHaveBeenCalledTimes(2); + expect(uploadWormBlobMock).toHaveBeenCalled(); + expect(kycFileService.markValid).toHaveBeenCalledWith(generated); + }); + + it('fails loudly when a document marked valid is gone from storage', async () => { + const generated = Object.assign(new KycFile(), { id: 17, name: 'generated.pdf', valid: true }); + (kycFileService.getByGenerationKey as jest.Mock).mockResolvedValue(generated); + listKeysMock.mockResolvedValue([]); + + await expect( + service.ensureGeneratedUserFile( + 'dfx-approval:11:RiskProfile:v1', + userData, + FileType.USER_NOTES, + FileSubType.RISK_PROFILE, + generated.name, + Buffer.from('pdf'), + ), + ).rejects.toThrow('missing in storage'); + }); + + it('does not hide unrelated database errors', async () => { + const error = { driverError: { code: '23505', constraint: 'some_other_constraint' } }; + (kycFileService.getByGenerationKey as jest.Mock).mockResolvedValue(null); + (kycFileService.createKycFile as jest.Mock).mockRejectedValue(error); + + await expect( + service.ensureGeneratedUserFile( + 'dfx-approval:11:RiskProfile:v1', + userData, + FileType.USER_NOTES, + FileSubType.RISK_PROFILE, + 'generated.pdf', + Buffer.from('pdf'), + ), + ).rejects.toBe(error); + }); + }); + describe('copyFiles', () => { it('copies each target key from all three prefixes', async () => { const targetKey = 'user/99/Identification/passport.pdf'; diff --git a/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts b/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts index 8a18cbb4f7..071f431007 100644 --- a/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts +++ b/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts @@ -13,6 +13,10 @@ import { KycFileService } from '../kyc-file.service'; const KYC_CONTAINER = 'kyc'; +// TypeORM's generated name for the unique index on `kyc_file.generationKey`; the repository does not +// use custom index names. +export const GENERATION_KEY_INDEX = 'IDX_840d5653c5f3bc7c76de2d156d'; + @Injectable() export class KycDocumentService { private readonly storageService: StorageService; @@ -118,6 +122,57 @@ export class KycDocumentService { return { file, url }; } + async ensureGeneratedUserFile( + generationKey: string, + userData: UserData, + type: FileType, + subType: FileSubType, + name: string, + data: Buffer, + metadata?: Record, + ): Promise<{ file: KycFile; url: string }> { + let file = await this.kycFileService.getByGenerationKey(generationKey); + if (file?.valid) { + const blobName = this.toFileId(FileCategory.USER, userData.id, type, file.name); + // Existence check only - listing the key avoids transferring the whole document. + const storedKeys = await this.storageService.listKeys(blobName); + if (!storedKeys.includes(blobName)) + throw new Error(`Generated document ${generationKey} is marked valid but missing in storage`); + + return { file, url: this.storageService.blobUrl(blobName) }; + } + + if (!file) { + try { + file = await this.kycFileService.createKycFile({ + generationKey, + name, + type, + subType, + protected: true, + valid: false, + userData, + }); + } catch (error) { + if (!this.isGenerationKeyConflict(error)) throw error; + file = await this.kycFileService.getByGenerationKey(generationKey); + if (!file) throw error; + } + } + + const blobName = this.toFileId(FileCategory.USER, userData.id, type, file.name); + const url = await this.storageService.uploadWormBlob(blobName, data, ContentType.PDF, metadata); + await this.kycFileService.markValid(file); + + return { file, url }; + } + + // Lets a caller reuse the name of a document that was already registered, so a retry after a + // failed upload keeps the document number that is printed into the PDF. + async findGeneratedUserFile(generationKey: string): Promise { + return this.kycFileService.getByGenerationKey(generationKey); + } + async downloadFile(category: FileCategory, userDataId: number, type: FileType, name: string): Promise { return this.storageService.getBlob(this.toFileId(category, userDataId, type, name)); } @@ -144,4 +199,11 @@ export class KycDocumentService { private isPermittedFileType(fileType: ContentType): boolean { return [ContentType.PNG, ContentType.JPEG, ContentType.JPG, ContentType.PDF].includes(fileType); } + + private isGenerationKeyConflict(error: unknown): boolean { + const candidate = error as { code?: string; driverError?: { code?: string; constraint?: string } }; + const code = candidate.driverError?.code ?? candidate.code; + const constraint = candidate.driverError?.constraint; + return code === '23505' && (!constraint || constraint === GENERATION_KEY_INDEX); + } } diff --git a/src/subdomains/generic/kyc/services/kyc-file.service.ts b/src/subdomains/generic/kyc/services/kyc-file.service.ts index 8baa3ff0f4..cb50a72e27 100644 --- a/src/subdomains/generic/kyc/services/kyc-file.service.ts +++ b/src/subdomains/generic/kyc/services/kyc-file.service.ts @@ -30,6 +30,16 @@ export class KycFileService { }); } + async getByGenerationKey(generationKey: string): Promise { + return this.kycFileRepository.findOne({ where: { generationKey } }); + } + + async markValid(file: KycFile): Promise { + await this.kycFileRepository.update(file.id, { valid: true }); + file.valid = true; + this.kycFileRepository.invalidateCache(); + } + async getUserDataKycFiles(userDataId: number, relations: FindOptionsRelations = {}): Promise { return this.kycFileRepository.findCached(`userData-${userDataId}-${JSON.stringify(relations)}`, { where: { userData: { id: userDataId } },