Перейти к содержанию

KYC Identity Verification

Overview

The KYC (Know Your Customer) flow verifies the identity of a new OneWallet user as a mandatory part of the registration process. The user photographs their ID document and takes a selfie; the KYC Service uses Google Gemini AI to perform OCR on the document and match the face against the selfie. After AI processing, the user reviews and confirms the extracted data. A human operator then performs a final approval before the user gains full access to payments. KYC cannot be skipped — the app blocks access to all payment features until fully_verified status is reached.

Participants

Service Role in This Flow
Flutter App Captures photos; uploads to S3; drives the 4-step wizard; polls for OCR result
Auth Center (Serverpod) Owns the kyc_verifications and user_profiles tables; exposes KYC RPC endpoints; fires-and-forgets to KYC Service
Garage S3 (object storage) Stores encrypted KYC document and selfie files (minimum 5-year retention)
KYC Service (Node.js + Express) Receives job via HTTP, enqueues to BullMQ, runs Gemini OCR and face match, writes result to PostgreSQL
BullMQ / Redis Job queue for KYC processing (kyc-jobs queue)
Google Gemini API AI service for OCR extraction and face matching
PostgreSQL (shared) kyc_verifications table (Serverpod schema); user_profiles table
Admin Panel (SvelteKit) Operator reviews OCR result; approves or rejects; triggers Wallet Registration in IPPS

Diagram

Flow Diagram

Prerequisites

  • User has completed email OTP verification and has a valid registration_token
  • User record exists in public.users with status = 'registration_in_progress'
  • S3 bucket kyc-data is accessible with SSE encryption enabled
  • KYC_SERVICE_SECRET environment variable is set in both Serverpod and KYC Service
  • Google Gemini API key is configured in KYC Service

Steps

Step 1: User Photographs Documents (Flutter Wizard)

Service: Flutter App (/kyc/upload screen)
Action: The app guides the user through a 3-step photo sequence using the device camera: 1. Passport / ID document — back camera (CameraLensDirection.back) 2. Selfie holding the document — front camera (CameraLensDirection.front) 3. Visa stamp — back camera (optional)

Data: Three in-memory image files ready for upload.

Step 2: Upload Files to S3

Service: Flutter App → Garage S3
Action: Flutter requests presigned PUT URLs from Auth Center. Uploads each photo directly to S3 under keys like kyc/{userId}/{uuid}/passport.jpg, kyc/{userId}/{uuid}/selfie.jpg.
Data: S3 object keys stored locally in the app for the next step.

Step 3: Initialize KYC Record

Service: Flutter App → Auth Center (initializeKyc)
Action: Calls KycEndpoint.initializeKyc(documentFrontUrl, documentBackUrl, selfieUrl). Serverpod creates (or updates) a kyc_verifications row with the S3 keys and status = 'pending'.
Data:

INSERT INTO kyc_verifications (userId, documentFrontUrl, selfieUrl, status)
VALUES (<userId>, 'kyc/.../passport.jpg', 'kyc/.../selfie.jpg', 'pending')
ON CONFLICT (userId) DO UPDATE SET documentFrontUrl=..., selfieUrl=..., status='pending';

Step 4: Submit for Review

Service: Flutter App → Auth Center (submitForReview)
Action: Calls KycEndpoint.submitForReview(). Serverpod updates kyc_verifications.status = 'in_review' and fires-and-forgets an HTTP POST to the KYC Service.
Data:

POST http://kyc-service:3003/api/v1/process
X-Service-Token: <kycServiceSecret>

{
  "userId":       <userId>,
  "kycId":        <kycVerificationId>,
  "passportKey":  "kyc/.../passport.jpg",
  "selfieKey":    "kyc/.../selfie.jpg",
  "stampKey":     "kyc/.../stamp.jpg"  (optional)
}

Flutter navigates to /kyc/processing screen. Serverpod does not await the KYC Service response.

Step 5: KYC Job Enqueued

Service: KYC Service (Node.js/Express)
Action: Receives the HTTP POST, validates X-Service-Token, and adds a job to BullMQ queue kyc-jobs with: - attempts: 5 - Exponential backoff: 15s → 30s → 60s → 120s → 240s

Returns HTTP 202 to Serverpod immediately.

Step 6: Flutter Polls for OCR Status

Service: Flutter App (/kyc/processing screen)
Action: Every 10 seconds, calls KycEndpoint.getKycStatus(). Waits until status changes from in_review to either ocr_complete or ocr_failed.

Step 7: KYC Worker — Generate Presigned URLs

Service: KYC Service (BullMQ Worker, concurrency=3)
Action: Worker picks up the job. Calls s3.getPresignedUrl() for the passport and selfie S3 keys to generate temporary GET URLs accessible to the Gemini API.
Data: Presigned GET URLs with short TTL.

Step 8: KYC Worker — OCR on ID Document

Service: KYC Service → Google Gemini API
Action: Calls gemini.extractOcr(passportUrl). Gemini reads the document photo and extracts structured identity fields.
Data returned:

{
  "fullNameTh":       "นาย สมชาย ใจดี",
  "fullNameEn":       "Somchai Jaidee",
  "dateOfBirth":      "1990-05-15",
  "nationality":      "Thai",
  "nationalIdNumber": "1234567890123",
  "address":          "...",
  "dateOfIssue":      "2020-01-01",
  "dateOfExpiry":     "2030-01-01",
  "gender":           "Male"
}

Validation: If all OCR fields are null → UnrecoverableError('INVALID_PHOTO:passport:...'). No retry — immediately marks job as failed with failureCode = 'invalid_passport'.

Step 9: KYC Worker — Face Match

Service: KYC Service → Google Gemini API
Action: Calls gemini.matchFaces(passportUrl, selfieUrl). Gemini compares the face in the ID document against the selfie.
Data returned: { score: 0.93, reasoning: "Faces match with high confidence" }

Validation: If score = 0 AND reasoning contains "no face" / "impossible" / "cannot" → UnrecoverableError('INVALID_PHOTO:selfie:...'). No retry — failureCode = 'invalid_selfie'.

Step 10: KYC Worker — Write Result to Database

Service: KYC Service → PostgreSQL
Action: Calls db.updateKycResult(). Updates kyc_verifications with OCR data and face match score.
Data:

UPDATE kyc_verifications
SET status = 'ocr_complete',
    ocrResult = '{"ocrData": {...}, "faceMatchScore": 0.93, "faceMatchReasoning": "..."}',
    reviewedAt = NOW()
WHERE id = <kycId>;

On failure after exhausted retries:

UPDATE kyc_verifications
SET status = 'ocr_failed',
    ocrResult = '{"error": "...", "failureCode": "processing_error", "failedAt": "..."}',
    reviewedAt = NOW()
WHERE id = <kycId>;

Step 11: Flutter Routes to Review Screen

Service: Flutter App
Action: getKycStatus() returns ocr_complete. App fetches OCR data via KycEndpoint.getOcrResult() and navigates to /kyc/review.

On ocr_failed: shows error screen. invalid_passport / invalid_selfie → "Retake photos" button only. processing_error → both "Try again" (retry without re-upload) and "Retake photos".

Step 12: User Reviews and Edits OCR Data

Service: Flutter App (/kyc/review screen)
Action: User sees 10 pre-filled fields from OCR. User corrects any errors. Taps "Yes, continue".
Action: Calls KycEndpoint.storeReviewedData(fields...). Flutter navigates to /kyc/terms.

Step 13: User Accepts Terms & Conditions

Service: Flutter App (/kyc/terms screen)
Action: User reads T&C, checks the checkbox, taps "Next". Calls KycEndpoint.confirmKycData(fields...).

Step 14: KYC Data Confirmed — Phase 1 Activation

Service: Auth Center (Serverpod)
Action: confirmKycData() executes: 1. Upserts user_profiles with all OCR-reviewed fields and termsAcceptedAt = NOW() 2. Updates kyc_verifications.status = 'pending_operator_review' 3. Calls activatePhase1(): creates internal TigerBeetle account for the user (wallet balance account) 4. Updates users.status to reflect KYC pending review state

Data:

-- user_profiles upsert:
INSERT INTO user_profiles (userId, fullNameTh, fullNameEn, dateOfBirth, ..., termsAcceptedAt)
VALUES (<userId>, ..., NOW())
ON CONFLICT (userId) DO UPDATE SET ...;

-- kyc_verifications update:
UPDATE kyc_verifications SET status='pending_operator_review' WHERE userId=<userId>;

Flutter navigates to /kyc/pending. User can now access /home in read-only mode while waiting.

Step 15: Operator Reviews in Admin Panel

Service: Admin Panel (SvelteKit)
Action: Operator sees the submission in the KYC review queue (status = pending_operator_review). Opens the detail view: presigned S3 URLs for document photos, OCR-extracted fields. Decides: Approve or Reject.

Step 16: Operator Approves — Phase 2 Activation

Service: Admin Panel → Auth Center (Serverpod)
Action: Operator clicks "Approve". Serverpod: 1. Updates kyc_verifications.status = 'fully_verified', approvedAt = NOW() 2. Creates Blnk Identity in blnkfinance and links existing balance account 3. Registers user wallet in IPPS PPXC: POST /register-wallet-user via KYCService

IPPS registration:

POST /register-wallet-user
{
  "externalWalletUserId": "user_<userId>",
  "customerName": "Somchai Jaidee",
  "taxId": "1234567890123"
}
 { "walletId": "400110891234567" }

walletId saved to ppxc_wallets in IPPS Adapter DB. Stored in pm.tb_account_map as ipps_wallet_id.

  1. Sends push notification to user: "Your identity has been verified"

Step 17: User Gains Full Access

Service: Flutter App
Action: On next app open or push notification tap, Auth Center returns kycStatus = fully_verified. App unlocks all payment features.

State Transitions

pending
  → in_review               (submitForReview — photos uploaded, job queued)
      → ocr_complete        (Gemini OCR + face match succeeded)
      |     → pending_operator_review  (confirmKycData + T&C accepted, Phase 1 activated)
      |               → fully_verified   (operator approved, Phase 2 activated)
      |               → rejected         (operator rejected)
      → ocr_failed          (all retries exhausted: invalid photo or API error)
            → in_review     (retryKycProcessing — reuse same S3 keys, new BullMQ job)
            → pending       (user chooses "Retake photos" — new upload required)
Status Home Access Payment Access KYC Screens
pending No (→ /kyc) No Yes
in_review No (→ /kyc) No Yes
ocr_complete No (→ /kyc) No Yes
ocr_failed No (→ /kyc) No Yes
pending_operator_review Yes (read-only) No No (→ /kyc/pending)
fully_verified Yes Yes No
rejected No No Yes (restart)

Error Cases

Scenario Where Detected Outcome Recovery
Passport photo unreadable (all OCR fields null) KYC Worker (Step 8) UnrecoverableError; no retry; failureCode = 'invalid_passport' User retakes passport photo
Selfie has no detectable face KYC Worker (Step 9) UnrecoverableError; no retry; failureCode = 'invalid_selfie' User retakes selfie
Gemini API 429 rate limit KYC Worker BullMQ retries with exponential backoff (up to 5 attempts: 15s → 240s) Automatic
Gemini API error (network, 5xx) KYC Worker BullMQ retries with backoff; on 5th failure: failureCode = 'processing_error' User can retry without re-upload via retryKycProcessing()
KYC Service unreachable Auth Center submitForReview Fire-and-forget; no error to user; status remains in_review; Serverpod can retry Manual retry via retryKycProcessing()
Operator rejects KYC Admin Panel (Step 15) status = 'rejected'; user notified; must restart from photo upload User re-submits
IPPS wallet registration fails (Step 16) Auth Center Phase 2 fully_verified not set; alert to operations Ops retries wallet registration manually
User abandons registration (7-day TTL) Cron job (daily 02:00) users.is_archived = true, status = 'registration_expired'; S3 files deleted after 30 days User must start fresh registration
  • /docs/03-kyc-module.md — Full KYC module documentation (4-step mobile flow, data model, retry strategy, business rules)
  • /docs/02-auth-module.md — Registration flow context (RegistrationService, registration_token lifecycle, temp user cleanup)
  • /docs/05-ipps-ppxc-adapter.md — IPPS Wallet Registration endpoint (POST /register-wallet-user, ppxc_wallets table)