Lewati ke konten utama

KTP OCR → Dashboard Review Cross-Check (Plan)

Status: Not started — planning only. Owner: TBD Dependencies: already in place from the 2026-04-22 session.

Goal

Leverage the derivative results of NIK (gender, date of birth, BPS region code) that are already extracted by the mobile parser — but not displayed in the mobile UI — as an automatic validation tool in the merchant registration review dashboard. The admin reviewer can see, side-by-side:

  • Data the user entered into the registration form
  • Data derived from the NIK (ground truth from NIK digits)
  • Region names looked up from public.ref_* (db_reference)

If there is a mismatch, the review panel flags it red → reviewer follows up / rejects.

Background

As of the 2026-04-22 session, the KtpIdentityParser already extracts:

identity.sex // 'L' or 'P'
identity.birthDate // DateTime
identity.provinceCode // '73' (digits 1-2)
identity.cityCode // '7371' (digits 1-4)
identity.districtCode // '737112' (digits 1-6)
identity.nikConfidence
identity.nameConfidence

See ktp_identity_parser.dart and ktp_identity_data.dart.

Per the user's decision: derived fields are not shown to the user in the mobile form (the registration flow does not ask for date of birth / sex). However, this audit data is useful for the dashboard reviewer — that is the scope of this document.

Region reference source (already in place)

Tables in db_reference (schema public):

TableRelevant columns
ref_provinceprovince_id, province_code, province_name
ref_citycity_id, province_id, city_code, city_name, city_type
ref_districtdistrict_id, city_id, district_code, district_name
ref_subdistrictsubdistrict_id, district_id, subdistrict_code, subdistrict_name

The Go store already exists: merchant_core_api/internal/dbreference/locations.go — methods ListProvinces, ListCitiesByProvince, ListDistrictsByCity, ListSubdistrictsByDistrict. Only lookup-by-code methods need to be added.

Scope

Phase A — Mobile: send derived fields on submit

  1. merchant_api_service.submitRegistration (merchant_api_service.dart) — add an optional field:

    required Map<String, dynamic> ktpOcrAudit, // or a separate class

    JSON payload:

    {
    "ktp_ocr_audit": {
    "nik": "7371122309850002",
    "nik_confidence": 95,
    "owner_name": "MUSTAQIM ZULKIFLI",
    "name_confidence": 85,
    "extracted_sex": "L",
    "extracted_birth_date": "1985-09-23",
    "extracted_province_code": "73",
    "extracted_city_code": "7371",
    "extracted_district_code": "737112"
    }
    }
  2. merchant_activation_summary_page.dart — when building the submit payload, read derived fields from MerchantRegistrationDraft (or memory state) and include them in the ktpOcrAudit parameter.

  3. MerchantRegistrationDraftService — extend saveIdentityExtraction to also persist sex, birthDate, provinceCode, cityCode, districtCode. Currently it only persists nik + ownerName.

  4. _captureIdentityDocument in merchant_owner_identity_page.dart — pass through derived fields to the draft service alongside NIK + name. No 75% gate is needed for audit fields (always save when present).

Phase B — Backend: store the audit payload in the review_payload JSONB

  1. CreateMerchantRegistration (merchant_core_api/internal/merchant/merchant.go) — accept ktp_ocr_audit from the request body and persist it to the existing merchant_registration_requests.review_payload JSONB column:
    UPDATE merchant.merchant_registration_requests
    SET review_payload = COALESCE(review_payload, '{}'::jsonb)
    || jsonb_build_object('ktp_ocr', $2::jsonb)
  2. The review_payload column already exists from migration 037 — no new schema change is required.

Phase C — Dashboard-API: cross-check enrichment endpoint

  1. Add helpers in dbreference/locations.go:

    func (s *Store) LookupProvinceByCode(ctx, code string) (Province, error)
    func (s *Store) LookupCityByCode(ctx, code string) (City, error)
    func (s *Store) LookupDistrictByCode(ctx, code string) (District, error)

    Query: WHERE province_code = $1 LIMIT 1, etc.

  2. Extend the existing merchant registration review endpoint (see services/dashboard_api/internal/app/dashboard_merchant_registration.go) — when handleGetMerchantRegistrationDetail returns data, enrich it with:

    {
    "ktp_ocr": {
    "nik": "7371122309850002",
    "extracted_sex": "L",
    "extracted_sex_label": "Laki-laki",
    "extracted_birth_date": "1985-09-23",
    "extracted_province_code": "73",
    "extracted_province_name": "SULAWESI SELATAN",
    "extracted_city_code": "7371",
    "extracted_city_name": "KOTA MAKASSAR",
    "extracted_district_code": "737112",
    "extracted_district_name": "MANGGALA"
    }
    }

Phase D — Dashboard Flutter: review dialog cross-check UI

File: apps/merchant_dashboard/lib/dashboard/features/home/presentation/widgets/panels/merchant_review_dialog.dart

Add a "Cross-Check NIK vs Form Input" section:

FieldFrom NIK (OCR)From User InputMatch
ProvinceSULAWESI SELATANSulawesi Selatanyes
CityKOTA MAKASSARKota Makassaryes
DistrictMANGGALAManggalayes
Sub-district(not available from NIK)(user input)N/A
GenderLaki-laki(not in form)
Date of Birth23 Sep 1985(not in form)

Match logic: case-insensitive, strip spaces, Levenshtein <= 2 tolerance.

Banner above the dialog:

  • Green "All locations match" if 3/3 match
  • Yellow "Region mismatch — manual review" if 1–2 mismatches
  • Red "NIK region completely different from merchant address" if 0/3 match (potential fraud signal)

Deliverables

  1. Mobile side: ktp_ocr_audit payload in the registration submit
  2. Backend: persist into review_payload JSONB + endpoint enrichment
  3. Dashboard Flutter: cross-check section in the review dialog
  4. (Optional) Auto-flag in review_payload.fraud_flags if there is a severe mismatch

Effort estimate

PhaseEffort
A — Mobile submit payload~1 hour
B — Backend persistence~30 minutes
C — Dashboard-API enrichment~1.5 hours
D — Dashboard Flutter UI~2-3 hours
Total~5-6 hours (1 working day)

NIK code <-> BPS mapping: important note

Verified from the source CSV dispusipda-kode_pos_kab_kota_indonesia.csv (2026-04-22):

LevelNIK digitsBPS format in CSVMatch status
Province2-digit (digits 1-2)2-digit province_code1:1 exact match
City/Regency4-digit (digits 1-4)4-digit city_code1:1 exact match
District2-digit (digits 5-6)7-digit district_code (e.g. 7371101)NOT 1:1 — NIK uses the older Kemendagri code, BPS uses the newer code with subdivisions
Sub-district(not encoded)10-digit subdistrict_codeNIK does not encode sub-district

MVP implication: Phase C lookup focuses on prov + city only. District mismatch produces too much noise because of subdivisions (e.g. NIK 12 in Makassar can be Manggala/Biring Kanaya/Tamalanrea, which in BPS = 7371101/7371110/7371111). It would flood the reviewer with false positives.

If we later need to extend to district: it requires a NIK-district-code → BPS-district-code mapping table curated manually from Kemendagri. Out of scope for now.

Out of scope (for now)

  • Auto-rejecting registration based on mismatches — the decision remains manual with the reviewer.
  • Mobile UI displaying sex/birthDate — per the user's decision, not done.
  • Dukcapil API verification — per the user's earlier decision, skipped.
  • Sub-district from NIK — NIK does not encode sub-district.
  • District cross-check — NIK 2-digit vs BPS 7-digit is not 1:1, requires a separate mapping table. Deferred until a clear business case emerges.

Preflight check before implementation

Before starting Phase A, ensure the BPS data is already imported in the target DB:

SELECT COUNT(*) FROM public.ref_province; -- expected: 38 (Indonesia 34 + several new)
SELECT COUNT(*) FROM public.ref_city; -- expected: ~514
SELECT province_name FROM public.ref_province WHERE province_code = '73';
-- → 'SULAWESI SELATAN'
SELECT city_name FROM public.ref_city WHERE city_code = '7371';
-- → 'KOTA MAKASSAR'

If the rows are empty, run first:

./scripts/import_ref_postal_code.sh \
./merchant_database/db_reference/raw/dispusipda-kode_pos_kab_kota_indonesia.csv \
development

References

  • ktp_identity_parser.dart:isStructurallyValidNik — NIK structure
  • locations.go — existing ref_* store
  • migration 037review_payload column
  • mobile-auth-session-lifecycle.md — similar architecture document pattern