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):
| Table | Relevant columns |
|---|---|
ref_province | province_id, province_code, province_name |
ref_city | city_id, province_id, city_code, city_name, city_type |
ref_district | district_id, city_id, district_code, district_name |
ref_subdistrict | subdistrict_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
-
merchant_api_service.submitRegistration(merchant_api_service.dart) — add an optional field:required Map<String, dynamic> ktpOcrAudit, // or a separate classJSON 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"}} -
merchant_activation_summary_page.dart— when building the submit payload, read derived fields fromMerchantRegistrationDraft(or memory state) and include them in thektpOcrAuditparameter. -
MerchantRegistrationDraftService— extendsaveIdentityExtractionto also persistsex,birthDate,provinceCode,cityCode,districtCode. Currently it only persistsnik+ownerName. -
_captureIdentityDocumentinmerchant_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
CreateMerchantRegistration(merchant_core_api/internal/merchant/merchant.go) — acceptktp_ocr_auditfrom the request body and persist it to the existingmerchant_registration_requests.review_payloadJSONB column:UPDATE merchant.merchant_registration_requestsSET review_payload = COALESCE(review_payload, '{}'::jsonb)|| jsonb_build_object('ktp_ocr', $2::jsonb)- The
review_payloadcolumn already exists from migration 037 — no new schema change is required.
Phase C — Dashboard-API: cross-check enrichment endpoint
-
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. -
Extend the existing merchant registration review endpoint (see
services/dashboard_api/internal/app/dashboard_merchant_registration.go) — whenhandleGetMerchantRegistrationDetailreturns 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:
| Field | From NIK (OCR) | From User Input | Match |
|---|---|---|---|
| Province | SULAWESI SELATAN | Sulawesi Selatan | yes |
| City | KOTA MAKASSAR | Kota Makassar | yes |
| District | MANGGALA | Manggala | yes |
| Sub-district | (not available from NIK) | (user input) | N/A |
| Gender | Laki-laki | (not in form) | — |
| Date of Birth | 23 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
- Mobile side:
ktp_ocr_auditpayload in the registration submit - Backend: persist into
review_payloadJSONB + endpoint enrichment - Dashboard Flutter: cross-check section in the review dialog
- (Optional) Auto-flag in
review_payload.fraud_flagsif there is a severe mismatch
Effort estimate
| Phase | Effort |
|---|---|
| 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):
| Level | NIK digits | BPS format in CSV | Match status |
|---|---|---|---|
| Province | 2-digit (digits 1-2) | 2-digit province_code | 1:1 exact match |
| City/Regency | 4-digit (digits 1-4) | 4-digit city_code | 1:1 exact match |
| District | 2-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_code | NIK 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 structurelocations.go— existing ref_* storemigration 037—review_payloadcolumnmobile-auth-session-lifecycle.md— similar architecture document pattern