think
16px
820px

PR Review — dev/1 → master

Branch: dev/1 (merged from dev/2 + master)
Scope: 722 files changed, 64 k insertions, 13 k deletions
Review method: 7 parallel finder angles × candidate dedup × 1-vote verification


Result: 3 confirmed bugs, safe to fix before merging

All other candidates (17 total) were REFUTED by code inspection.


Bug 1 — HIGH · fast-track payment verification broken for all '2'-scheme vouchers

File: backend/src/lib/simpadhu-db.ts:49

// Current (broken):
/^(2\d{3})(\d{2})(\d{2})\d{4,}$/.exec(code)

// Should mirror the '8' scheme:
/^2(\d{4})(\d{2})(\d{2})\d{4,}$/.exec(code)

The '8'-scheme regex correctly anchors the sentinel outside the capture group: ^8(\d{4}) — literal 8, then 4 captured year digits. The '2'-scheme regex instead writes ^(2\d{3}), pulling the leading 2 inside the capture group, yielding m[1]='2YYY' (e.g. '2202' for year 2026), m[2]='60' (month), m[3]='70' (day). Month 60 and day 70 both fail the range guards, so every valid '2'-prefix voucher returns null — lookupBillingCode reports them as not found, and fast-track payment verification always fails for this scheme.

Fix: Change ^(2\d{3})^2(\d{4}).


Bug 2 — HIGH · BA RUPS authoritative branch silently bypassed when share percentages are null

File: backend/src/flow-engine/rules/pt-akta/rups-quorum.ts:67

if (c.rupsQuorum.hasBaRupsDoc && total > 0) {
  ratio = ...   // BA RUPS (authoritative — stricter ¾ threshold for ps89)
} else if (c.rupsQuorum.aktaKehadiranRatio != null) {
  ratio = c.rupsQuorum.aktaKehadiranRatio;  // weaker fallback
}

total is the sum of pemegangSaham[].persentase ?? 0. When a BA RUPS doc is uploaded (hasBaRupsDoc=true) but share-percentage extraction hasn't finished yet (all persentase are null → total===0), the compound condition is false. The rule falls through to the weaker aktaKehadiranRatio branch and emits a PASS at the lower akta threshold instead of waiting for the authoritative BA RUPS data.

For PENGGABUNGAN/AKUISISI/PELEBURAN (ps89 category) the legal first-meeting threshold is ¾; the akta fallback may be lower. A submission with an incomplete BA RUPS can silently receive a PASS verdict it doesn't deserve.

Fix: When hasBaRupsDoc && total === 0, treat as incomplete data (SKIPPED or WARNING) rather than falling through to the weaker source. One approach:

if (c.rupsQuorum.hasBaRupsDoc) {
  if (total === 0) {
    // BA RUPS present but percentages not yet resolved — don't fall through
    return { status: "SKIPPED", reason: "ba_rups_percentages_pending" };
  }
  ratio = /* BA RUPS ratio */;
} else if (c.rupsQuorum.aktaKehadiranRatio != null) {
  ratio = c.rupsQuorum.aktaKehadiranRatio;
}

Bug 3 — MEDIUM · dedupeByNomorSk collapses all null-SK companies onto one key

File: backend/src/services/company-lookup.ts:252 (called from lines 216 and 236)

function dedupeByNomorSk(matches: LookupMatch[]): LookupMatch[] {
  // ...
  const key = m.nomor_sk.replace(/\s+/g, "").toUpperCase();

rowToMatch (line 271) constructs nomor_sk: String(row.nomor_sk ?? ""), so every DB row with a NULL nomor_sk produces the string "". All such rows share the same dedup key "", and only the first survives the seen-Set filter. In the medium-confidence name-search path (LIMIT 10), if multiple distinct companies all have no recorded nomor_sk, all but the first are silently dropped — the user cannot find the correct company in AWAITING_COMPANY_SELECTION and the submission stays stuck.

Fix: Skip dedup when the key is empty (let nulls through as unique):

const key = m.nomor_sk.replace(/\s+/g, "").toUpperCase();
if (!key) return true;  // don't dedup null-SK entries against each other
if (seen.has(key)) return false;
seen.add(key);
return true;

What was checked and cleared

Area Angles run Candidates Confirmed
Flow-engine flows/rules/processor A, B 9 1 (rups-quorum)
Backend lib / OCR / persistence A, C 8 1 (simpadhu billing)
Frontend store / lib / routes A, B 7 0
Frontend pages A, B 6 0
company-lookup.ts A 1 1 (dedupeByNomorSk)

Notable refutations worth knowing:
- processor.ts timeout race — closed: the catch block also writes doc.status=ERROR; processOneDocument reads that before deciding to write DONE.
- isPerubahanFamilyAkta + AKTA_PENGGABUNGAN — intentional; all callsites are gated on routeFamily="perubahan" and handle penggabungan correctly.
- RUPS_TAHUNAN quorum rule removed — documented in-code as deliberate exclusion (laporan tahunan is administrative, not a voting decision).
- apostille decidedAt null crashdecidedAt is DateTime @default(now()) in the schema, non-nullable.
- rematch.ts skipping beforeValidation — pendirian derive logic is covered via standardPtMatchers in the rematch path.
- jenisPerubahanSelectionAtom stale — atom is reset in all three exit paths from the klasifikasi page.