In 2025, 85% of B2B companies offer an online supplier or vendor portal, up from 68% just a year prior. The business case is clear: a vendor portal reduces AP processing costs by eliminating manual invoice keying, speeds up payment cycles, and improves supplier relationships. For Indonesian manufacturing and trading companies that manage hundreds of vendors — many of whom still email PDF invoices and follow up via WhatsApp — a vendor portal is a significant operational upgrade. At Commsult, I built a vendor portal integrated directly with our custom ERP's procurement and AP modules. This post covers the architecture and lessons learned.
A production vendor portal needs four core capabilities: invoice submission (vendor uploads invoice PDF, enters line items, maps to PO), payment tracking (vendor can see payment status for each invoice), document management (vendor updates bank details, tax documents, legal certificates), and communication (structured messages linked to specific POs or invoices). All of these must be integrated with the ERP — a standalone portal that doesn't sync with the ERP just creates a parallel data silo.
We use a separate vendor-facing Next.js application (not the internal ERP frontend) with its own authentication. Vendors register with their NPWP (Indonesian tax registration number), business name, and email. The ERP admin approves registrations and links the vendor account to the vendor master in the ERP. Once linked, the vendor can see all POs issued to them and submit invoices against those POs. The authentication uses JWT with shorter expiry (4 hours) than internal users, given the external facing nature.
Vendor Portal Architecture
┌─────────────────────┐ ┌─────────────────────────┐
│ Vendor Portal │ │ Internal ERP │
│ (Next.js PWA) │ │ (Next.js + NestJS) │
│ │ │ │
│ 📋 Submit Invoice │ │ 📊 AP Module │
│ 💳 Track Payment │◄───►│ 📦 Purchase Orders │
│ 📄 Docs Upload │ │ ✅ 3-Way Matching │
│ 💬 Messages │ │ 💰 Payment Batches │
└──────────┬──────────┘ └──────────┬──────────────┘
│ Vendor-scoped JWT │
▼ ▼
┌────────────────────────────────────────────────────┐
│ NestJS API (shared) │
│ │
│ VendorGuard: extracts vendor from JWT │
│ → injects vendor_id into every query │
│ → PostgreSQL RLS enforces row-level isolation │
└──────────────────────┬─────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────┐
│ PostgreSQL (RLS enabled on vendor-facing tables) │
│ │
│ vendors │ purchase_orders │ ap_invoices │ payments│
└────────────────────────────────────────────────────┘From my experience building ERP systems at Commsult: give vendors a mobile-friendly portal, not just a desktop one. Many Indonesian supplier contacts operate primarily on mobile — they'll photograph an invoice and submit it from their phone. We built the portal as a PWA with mobile-optimized forms. Invoice photo capture (with automatic PDF conversion using pdf-lib) was the most-used feature in the first month. A portal that only works on desktop gets abandoned by vendors.
The invoice submission flow: vendor selects a PO from their open POs, confirms the line items and quantities being invoiced, enters the invoice number and date, uploads the PDF, and submits. The ERP automatically runs three-way matching: invoice quantity vs. PO quantity vs. goods receipt quantity. If all three match within tolerance, the invoice is auto-approved to payment queue. If there's a discrepancy, it flags for AP team review. This eliminates 80% of manual AP matching work.
Vendors can see a payment tracker for each invoice: submitted, under review, approved, payment scheduled (with expected payment date), and paid (with bank transfer reference). This visibility almost entirely eliminates the 'when will I get paid?' calls to the AP team. We found that 60-70% of AP team phone calls at our client were vendor payment inquiries — all eliminated by the portal. The payment scheduled status updates automatically when finance creates a payment batch in the ERP, and the paid status updates when the bank transfer confirmation is uploaded.
-- PostgreSQL: Row-Level Security for vendor portal
-- Vendors can ONLY see their own data
ALTER TABLE purchase_orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE ap_invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
-- Policy: vendor can only see their own POs
CREATE POLICY vendor_po_isolation ON purchase_orders
USING (vendor_id = current_setting('app.vendor_id')::UUID);
CREATE POLICY vendor_invoice_isolation ON ap_invoices
USING (vendor_id = current_setting('app.vendor_id')::UUID);
-- NestJS: Set vendor context before every query
@Injectable()
export class VendorContextMiddleware implements NestMiddleware {
async use(req: Request, res: Response, next: NextFunction) {
const vendor = req.user as VendorJwtPayload;
if (vendor?.vendorId) {
// Set PostgreSQL session variable for RLS
await this.dataSource.query(
"SELECT set_config('app.vendor_id', $1, true)",
[vendor.vendorId]
);
}
next();
}
}
// 3-way matching on invoice submission
async validateInvoiceAgainstPo(dto: SubmitInvoiceDto) {
const po = await this.poRepo.findOneOrFail(dto.poId);
const gr = await this.grRepo.findByPoId(dto.poId);
const grQty = gr.reduce((sum, r) => sum + r.quantity, 0);
const tolerance = 0.02; // 2% tolerance
for (const line of dto.lines) {
const poLine = po.lines.find(l => l.itemId === line.itemId);
if (!poLine) throw new BadRequestException(`Item ${line.itemId} not on PO`);
if (Math.abs(line.quantity - poLine.quantity) / poLine.quantity > tolerance) {
return { status: 'DISCREPANCY', requiresReview: true };
}
}
return { status: 'MATCHED', autoApprove: true };
}The vendor portal is a separate Next.js application that calls the same NestJS API as the internal ERP, but with vendor-scoped API keys. The NestJS vendor module enforces vendor_id on every query using a custom guard that extracts the vendor from the JWT and injects it into every service call. The portal frontend is deployed to Cloudflare Pages for performance and DDoS protection. The internal ERP's AP module has a 'vendor portal' tab on each invoice showing the vendor's submitted data and any messages.
The vendor portal contains sensitive financial data — payment amounts, bank account details, tax documents. Row-level security in PostgreSQL must ensure each vendor can only see their own data. We had an early implementation where a misconfigured API endpoint returned all vendors' payment statuses if the vendor_id parameter was tampered with. Always use PostgreSQL RLS policies on the vendor-facing tables, and test thoroughly with multiple vendor accounts before go-live. An IDOR (Insecure Direct Object Reference) vulnerability in a payment portal is a serious breach.
Indonesian procurement regulations require vendors to maintain current PKP (Pengusaha Kena Pajak) certificates, NPWP, SIUP (business license), and for some sectors, ISO or SNI certifications. Our portal lets vendors upload and update these documents. The ERP tracks expiry dates and flags vendors whose documents are expiring in 30 or 60 days — the procurement team is notified and cannot issue new POs to vendors with expired compliance documents. This replaced a manual spreadsheet the procurement manager was maintaining for vendor compliance.
Six months after launching the vendor portal for our client, the measurable improvements were: AP processing time per invoice dropped from 45 minutes to 8 minutes (three-way matching automation), vendor payment inquiry calls dropped by 65%, on-time payment rate improved from 72% to 91% (because the payment scheduling was more transparent and accountable), and vendor onboarding time reduced from 5 days to 1 day (digital document submission). The client's CFO described it as 'the single most impactful module we've deployed.'