Plan: OCR-Verified Transaction Ingest (PP Rebuild)
Status: Proposal / design branch feature/ocr-verified-ingest. Nothing here is wired into AppModule yet; the intended endpoint is dormant behind a feature flag. Author: Efran Nathanael, 2026-07-13. Prepared with codebase evidence (file:line) from ahu-perseroan-perorangan-api @ main (8fc3bfa).
1. What this is
An external OCR/AI verification service (owned by the AHU-AI team) receives a user redirected from the PP portal, has them upload documents, runs AI extraction, and its own verifikators approve the transaction. When the transaction is finalized on the OCR side, it must push the verified result back into PP so the official Perseroan Perorangan record is created with all of PP's real side effects.
The OCR service is authoritative: PP should trust its verification verdict and skip PP's own manual review, but must still run all of PP's finalization (numbering, m_ptp master insert, DJP NPWP registration, pemilik-manfaat push, notifications, email).
2. Decision: API call, not direct DB write
Use a new authenticated API endpoint that calls PP's existing create* + verify* service methods. Do NOT write PP's tables directly.
A direct DB write looks cleaner but is never safe here, because "finalizing a pendirian" is not one insert — it is a transactional fan-out plus a cross-system job chain:
no_permohonan/id_permohonannumbering via read-max-then-write (src/pendirian/pendirian.repository.ts:1244-1296) — a raw insert collides with the app's own generator and violates the@uniqueconstraints (prisma/main/schema.prisma:241,245).id_ptpgeneration across two tables (generateNextIdPtp,pendirian.repository.ts:2418) and them_ptpmaster insert (:2547).- A Bull job chain that calls DJP CTAS to register a real NPWP (
src/pendirian/processors/pendirian.processor.ts:216) and pushes beneficial-owner data to the AHU portal (:417). - A compensating transaction,
revertVerifyChanges(:2310), that only makes sense as the exact inverse ofverifyPendirian.
A row insert would produce a record that exists in PP's DB but is unknown to DJP and the beneficial-owner registry, with no official numbers and no billing. So the ingest endpoint delegates; the only new DB object it introduces is an additive idempotency table (§6).
3. Good news: identity is a parameter, not a session
Every finalize method is public and takes the acting user as an explicit userId: string argument — none reads req.user. So a machine caller can invoke them server-to-server by passing an explicit id_user.
| Family | Public method | Ownership guard? | Needs a user bearer token? |
|---|---|---|---|
| Pendirian | verifyPendirian(id, userId, authorization?) pendirian.repository.ts:2468 |
Yes — existing.id_user === userId (:2486) |
Yes → pemilik-manfaat push |
| Perubahan | verifyPerubahan(id, userId, authorization?) perubahan.repository.ts:362 |
No | Yes → PM push |
| Pembubaran | verifyPembubaran(id, userId) pembubaran.repository.ts:118 |
No | No (simplest) |
| Perbaikan | verifyPerbaikan(id, userId, authorization?) perbaikan.repository.ts:289 |
No | Yes → PM push |
Finalization requires a pre-existing row: verify* fetches a record by id, checks status, then finalizes. So the sequence per transaction is create draft → verify:
POST ingest
→ dedupe on idempotency_key (new table, §6)
→ createDraft(payload{ status: 'Menunggu Konfirmasi Permohonan' }, id_user) // draft, verify-ready
→ verifyPendirian(draft.id, id_user, pmServiceToken?) // fires ALL side effects
→ store { idempotency_key → pendirian_id, response }
→ return { pendirian_id, no_permohonan }
verifyPendirian only accepts status Menunggu_Konfirmasi_Permohonan (:2491-2496), so the draft must be created in that state. (createDraft can create+partially-finalize if given a non-Draft status, but that shortcut skips the m_ptp insert / id_ptp / NPWP / PM push — so we must do the real two-step.)
Phase 1 = pendirian only (creates a brand-new entity). Perubahan / pembubaran / perbaikan operate on an existing id_ptp, which the OCR side must carry from PP (pp_master_id_ptp) — those are phase 2.
4. The one hard dependency: the pemilik-manfaat push token
verifyPendirian fans out to a Bull chain. Two downstream calls matter:
- DJP CTAS NPWP registration — fine for a machine. CTAS mints its own token from service credentials
CTAS_ID_REG001/CTAS_KEY_001(src/djp/ctas-auth.service.ts:52-54), independent of any user. No user token needed. - Pemilik-manfaat push to the AHU portal — the hard part. The chain forwards the end-user's Keycloak bearer all the way down (
verifyPendirian(…, authorization)→ queueauthorization:2670→handlePostAhuPm→postPemilikManfaatToAhuOnly(payload, authorization)→Authorization: <bearer>POST to${AHU_API_URL}/pemilik-manfaat/laporan,src/pemilik_manfaat/pmPerseroan.repository.ts:180-210). A machine push arriving days later has no user token. There is no client-credentials flow in this repo today (grep: 0 hits).
If the token is missing, the AHU API 401s and the job throws "AHU API response tidak mengandung id_laporan" (processor:445), which can trigger revertVerifyChanges — so skipping is not a clean no-op.
Resolution (go/no-go item): obtain a Keycloak client-credentials service token that the portal's /pemilik-manfaat/laporan route will accept, cache it (mirroring ctas-auth.service.ts), and pass it as verifyPendirian's authorization argument. If the portal team confirms a service subject is accepted, this "just works." If not, the processor needs a config switch to make the PM push optional (a small change outside these new files). Until resolved, the endpoint fails closed for pendirian/perubahan/perbaikan (refuses rather than risking a partial finalize + revert). Pembubaran needs no token and can proceed regardless.
5. Auth (dormant, dedicated key)
PP already has an M2M pattern: ApiKeyGuard (src/common/guards/api-key.guard.ts) reads x-api-key and compares to INTERNAL_API_KEY. Gotcha: the JWT guard is global (src/common/common.module.ts:61-65), so any API-key route must also be @Public() to bypass it — the established pattern (src/pt/pt.controller.ts:32-34).
Recommendation: a dedicated OcrIngestGuard + key, not the shared INTERNAL_API_KEY. The shared key already unlocks read-only name-lookup/CTAS routes; a finalizing, state-mutating endpoint deserves its own rotatable credential. The guard also enforces the dormant flag by throwing NotFoundException when disabled, so with the flag unset the route is indistinguishable from a non-existent path — byte-identical to current behavior.
New env (all default-off/empty): OCR_INGEST_ENABLED=false, OCR_INGEST_API_KEY=, OCR_INGEST_PM_SERVICE_TOKEN= (or SA client creds), OCR_INGEST_VOUCHER_CHECK=false. Config is read via the injectable global ConfigService (already registered, common.module.ts:39).
6. Idempotency (our push retries)
No natural key can dedupe: no_permohonan/id_ptp are @unique but generated server-side inside finalize, so we don't supply them. The eligibility rule (GET /pendirian/cek-eligibility, one-pendirian-per-account-per-year, :5465) is a separate FE call that createDraft never invokes, and it excludes expired rows — a naive retry after a half-finished push would create a second draft (then confusingly 400 on the yearly rule).
Proposal — a small additive dedupe table (no change to the finalize hot path):
model tr_ptp_ocr_ingest {
id Int @id @default(autoincrement())
idempotency_key String @unique @db.VarChar(120)
transaction_type String @db.VarChar(20)
id_user Int
pendirian_id Int? // resolved target row
status String @db.VarChar(20) // received | created | verified | failed
response_json Json?
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@schema("perseroan_perorangan")
}
Algorithm: on a fresh key → create draft → record pendirian_id/created → verify → verified + stored response. On a repeat key → replay the stored response (verified) or resume at verify (created). This is the one place the proposal touches shared schema — additive table only, no existing column/behavior change, dormant-safe. Requires one Prisma migration.
7. Server-side voucher verification (security gap to close)
id_voucher is never validated server-side — it is only persisted (pendirian.repository.ts:2832); payment validity is assumed to be checked by the FE before submit. An authoritative M2M ingest bypasses the FE, so the new endpoint must add server-side voucher verification (call the payment/voucher service, reject if unpaid/invalid) behind OCR_INGEST_VOUCHER_CHECK. No such client exists in-repo yet → marked TODO, default OFF so shipping isn't blocked, but documented as a security requirement before go-live.
8. Payload contract (what the OCR service must send)
Envelope + the real CreatePendirianDto payload (global ValidationPipe uses forbidNonWhitelisted, so every field must be declared):
| Field | Purpose |
|---|---|
transaction_type |
pendirian (phase 1); perubahan/pembubaran/perbaikan phase 2 |
id_user |
explicit acting/owning AHU user id (djahu_userId) |
idempotency_key |
deterministic dedupe key |
id_voucher |
carried into the draft; server-verified (§7) |
pp_master_id_ptp |
existing PTP (phase-2 families); null for pendirian |
pm_authorization |
optional service bearer for the PM push (§4) |
payload |
full CreatePendirianDto — company + owner (incl. nik, tanggal_lahir ≥18, pendirian.repository.ts:2785), kegiatan_usaha[] (KBLI), pemilik_manfaat[], address blocks; status_permohonan forced to Menunggu Konfirmasi Permohonan |
9. Side effects the endpoint must let fire (never replicate)
Numbering (:1244-1296), sertifikat/SK/QR (:1334-1395), id_ptp (:2418), m_ptp insert (:2547), KBLI/PM back-fill (:2540,:2590), notifications (:2600), Bull job chain → DJP CTAS NPWP (processor:216) + AHU portal PM push (processor:417) + PDF + email. All produced by createDraft + verifyPendirian — the endpoint calls them and reimplements none.
10. Module wiring
New additive OcrIngestModule (imports the four family modules, which already export their repos/service; PrismaService/ConfigService come from the @Global() CommonModule). Register in AppModule.imports. No existing route changes.
11. Open items (confirm before build / go-live)
- PM push service token (§4) — will the AHU portal
/pemilik-manfaat/laporanaccept a Keycloak service-account subject? (go/no-go for pendirian/perubahan/perbaikan) createDraftreturn shape — confirm exact keys to extractpendirian.idreliably.- Server-side voucher verification (§7) — wire a voucher/payment client before go-live.
- Idempotency migration (§6) — the one schema change.
- Phase-2 families — how OCR obtains/carries
pp_master_id_ptp. - Dedicated key provisioning — issue
OCR_INGEST_API_KEY(not the sharedINTERNAL_API_KEY).
12. Also found in passing (relay to PP maintainers — not blockers)
- Live credentials committed in
docker-compose.yaml. POST /pendirian/generate-npwp-by-ptp/:id_ptpis@Public()with no guard (pendirian.controller.ts:342).- Voucher/PNBP validation is frontend-only (§7).
Appendix A — Reference scaffold
These are proposal skeletons, kept in this doc (not committed under
src/) so the branch build stays green. Lift them into the listed paths when implementing. Business logic stays delegated to existingcreate*/verify*— nothing reimplements finalization. TODOs mark where the final OCR↔PP contract is needed.
src/common/guards/ocr-ingest.guard.ts
import { CanActivate, ExecutionContext, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
/** Dedicated M2M guard. Mirrors ApiKeyGuard but uses a dedicated key and fail-closes to 404 when dormant. */
@Injectable()
export class OcrIngestGuard implements CanActivate {
constructor(private readonly config: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
if (this.config.get<string>('OCR_INGEST_ENABLED', 'false') !== 'true') throw new NotFoundException(); // dormant
const req = context.switchToHttp().getRequest();
const apiKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('ApiKey ', '');
const expected = this.config.get<string>('OCR_INGEST_API_KEY');
if (!expected) throw new UnauthorizedException('OCR ingest not provisioned');
if (!apiKey || apiKey !== expected) throw new UnauthorizedException('Invalid API key');
return true;
}
}
src/ocr_ingest/dto/ocr-ingest.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { CreatePendirianDto } from '../../pendirian/dto/create_pendirian.dto';
export enum OcrTransactionType { Pendirian = 'pendirian', Perubahan = 'perubahan', Pembubaran = 'pembubaran', Perbaikan = 'perbaikan' }
export class OcrIngestDto {
@ApiProperty({ enum: OcrTransactionType }) @IsEnum(OcrTransactionType) transaction_type: OcrTransactionType;
@ApiProperty() @IsInt() id_user: number; // djahu user id (subject)
@ApiProperty() @IsString() @MaxLength(120) idempotency_key: string;
@ApiProperty({ required: false }) @IsOptional() @IsString() pp_master_id_ptp?: string;
@ApiProperty({ required: false }) @IsOptional() @IsString() id_voucher?: string;
@ApiProperty({ required: false }) @IsOptional() @IsString() pm_authorization?: string; // service bearer (§4)
@ApiProperty({ type: CreatePendirianDto }) @ValidateNested() @Type(() => CreatePendirianDto) payload: CreatePendirianDto;
}
src/ocr_ingest/ocr-ingest.service.ts
import { BadRequestException, Injectable, Logger, NotImplementedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../common/prisma.service';
import { PendirianRepository } from '../pendirian/pendirian.repository';
import { PerubahanRepository } from '../perubahan/perubahan.repository';
import { PembubaranService } from '../pembubaran/pembubaran.service';
import { PerbaikanRepository } from '../perbaikan/perbaikan.repository';
import { OcrIngestDto, OcrTransactionType } from './dto/ocr-ingest.dto';
/** Thin orchestrator. Delegates all business logic to existing create*/verify*. */
@Injectable()
export class OcrIngestService {
private readonly logger = new Logger(OcrIngestService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly pendirianRepo: PendirianRepository,
private readonly perubahanRepo: PerubahanRepository,
private readonly pembubaranService: PembubaranService,
private readonly perbaikanRepo: PerbaikanRepository,
) {}
async finalize(dto: OcrIngestDto) {
const userId = String(dto.id_user);
// 1) IDEMPOTENCY (§6): TODO create tr_ptp_ocr_ingest; replay 'verified', resume 'created'.
// 2) SERVER-SIDE VOUCHER (§7):
if (this.config.get<string>('OCR_INGEST_VOUCHER_CHECK', 'false') === 'true') {
// TODO: verify dto.id_voucher paid/valid; else throw new BadRequestException('Voucher tidak valid');
}
// 3) PM PUSH TOKEN (§4): fail-closed for families that need it.
const pmToken = dto.pm_authorization ?? this.config.get<string>('OCR_INGEST_PM_SERVICE_TOKEN');
const needsPmToken = dto.transaction_type !== OcrTransactionType.Pembubaran;
if (needsPmToken && !pmToken) throw new BadRequestException('PM push token belum tersedia — ingest ditolak (cegah finalisasi parsial)');
// 4) CREATE DRAFT + VERIFY (delegated)
switch (dto.transaction_type) {
case OcrTransactionType.Pendirian: {
const draftDto = { ...dto.payload, status_permohonan: 'Menunggu Konfirmasi Permohonan' as any };
const created: any = await this.pendirianRepo.createDraft(draftDto, userId);
const pendirianId = Number(created?.data?.id ?? created?.pendirian?.id ?? created?.id); // TODO confirm shape
const result = await this.pendirianRepo.verifyPendirian(pendirianId, userId, pmToken);
// TODO persist { idempotency_key -> pendirianId, status:'verified', response_json:result }
return result;
}
// Phase 2 (existing id_ptp = dto.pp_master_id_ptp): same create->verify with
// perubahanRepo.verifyPerubahan(id, userId, pmToken) / pembubaranService.verify(id, userId) / perbaikanRepo.verifyPerbaikan(id, userId, pmToken)
default: throw new NotImplementedException(`transaction_type "${dto.transaction_type}" belum didukung (phase 2)`);
}
}
}
src/ocr_ingest/ocr-ingest.controller.ts
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger';
import { Public } from '../decorators/public.decorator'; // bypass global JwtAuthGuard (common.module.ts:61)
import { OcrIngestGuard } from '../common/guards/ocr-ingest.guard';
import { OcrIngestService } from './ocr-ingest.service';
import { OcrIngestDto } from './dto/ocr-ingest.dto';
@ApiTags('OCR Ingest (Internal M2M)')
@Controller('internal/ocr-ingest')
export class OcrIngestController {
constructor(private readonly service: OcrIngestService) {}
@Public() @UseGuards(OcrIngestGuard) @ApiSecurity('api-key')
@Post('finalize')
@ApiOperation({ summary: 'Push OCR-verified transaction into PP; fires all finalization side effects. Dormant unless OCR_INGEST_ENABLED=true.' })
async finalize(@Body() dto: OcrIngestDto) { return this.service.finalize(dto); }
}
src/ocr_ingest/ocr-ingest.module.ts
import { Module } from '@nestjs/common';
import { PendirianModule } from '../pendirian/pendirian.module';
import { PerubahanModule } from '../perubahan/perubahan.module';
import { PembubaranModule } from '../pembubaran/pembubaran.module';
import { PerbaikanModule } from '../perbaikan/perbaikan.module';
import { OcrIngestController } from './ocr-ingest.controller';
import { OcrIngestService } from './ocr-ingest.service';
import { OcrIngestGuard } from '../common/guards/ocr-ingest.guard';
@Module({
imports: [PendirianModule, PerubahanModule, PembubaranModule, PerbaikanModule],
controllers: [OcrIngestController],
providers: [OcrIngestService, OcrIngestGuard],
})
export class OcrIngestModule {}
Then add OcrIngestModule to src/app.module.ts imports, and add the tr_ptp_ocr_ingest model (§6) + a migration.