In 2025, 85% of B2B companies offer an online customer portal or storefront, up from 68% the previous year. Companies that adopted digital B2B channels report an average 41% increase in sales revenue — more than a third saw growth over 50%. For Indonesian B2B businesses serving corporate clients, a customer portal transforms what was an email-and-phone ordering process into a self-service experience that scales without proportional headcount growth. At Commsult, I built a B2B customer portal integrated with our custom ERP for a manufacturing client. This post walks through the architecture and the challenges we solved.
B2B customer portals are not ecommerce stores. B2B clients need: to place orders against a negotiated price list (not a public catalog), to track their order status and delivery in real time, to access and pay their invoices, to see their transaction history, and to communicate about specific orders. Each of these is distinctly different from consumer ecommerce and requires ERP integration to be useful — a portal that shows a static product catalog with no real inventory or pricing data is useless to a procurement manager.
In Indonesian B2B, every major client has a negotiated price list. Our portal reads pricing from the ERP's customer_price_list table: each client has a price list ID, and when they browse products, they see their contracted price, not a public price. The portal also shows their available credit limit (from the AR module) and their current outstanding balance. If a client has exceeded their credit limit, the portal disables new orders and shows a 'Please contact your account manager' message — this is enforced at the API level, not just the UI.
B2B Customer Portal Architecture
Customer (browser / mobile)
│
▼
┌─────────────────────────────────┐
│ Next.js Customer Portal │ (deployed: Vercel)
│ │
│ 🛒 Place Order (vs. price list)│
│ 📦 Track Shipment Status │
│ 🧾 View & Pay Invoices │
│ 📄 Download e-Faktur PDF │
└──────────────┬──────────────────┘
│ customer-scoped JWT
▼
┌─────────────────────────────────┐
│ NestJS API (shared) │
│ │
│ CustomerGuard → customer_id │
│ PostgreSQL RLS enforcement │
│ │
│ /pricing → customer_price_list │
│ /orders → sales_orders │
│ /invoices → ar_invoices │
│ /pay → Midtrans gateway │
└──────────────┬──────────────────┘
│
┌──────────────┴──────────────────┐
│ Internal ERP (Next.js) │
│ Sales Order sync ↔ portal │
│ Shipment updates → portal │
│ Invoice status → portal │
└─────────────────────────────────┘From my experience building ERP systems at Commsult: build the credit check at the API level, not just the frontend. We had an early implementation where the credit limit check was only in the React UI — a sophisticated client could bypass it by calling the API directly. Always enforce business rules (credit limits, minimum order quantities, restricted products) in the NestJS service layer, with the UI as a convenience layer on top of those rules.
The order flow: customer places an order on the portal → order syncs to ERP as a Sales Order → ERP warehouse staff picks and ships → shipment updates flow back to the portal → customer sees real-time status. This bidirectional sync is the core integration challenge. We use a combination of database triggers and NestJS event emitters: when a Sales Order status changes in the ERP, it emits an event that updates the portal-facing status table. Customers can subscribe to email notifications for each status change.
B2B clients need to download documents: tax invoices (e-Faktur PDFs for PPN reconciliation), Delivery Orders (for their goods receipt process), and Certificates of Analysis for raw material suppliers. All of these are generated in the ERP and stored in object storage with signed URLs. The portal exposes a document center per order and per invoice. Clients can download and forward these documents directly without calling the sales team — a significant time-saver for both parties.
// NestJS: Midtrans webhook handler (idempotent)
@Post('/webhooks/midtrans')
async handleMidtransWebhook(@Body() body: MidtransNotification) {
// 1. Validate Midtrans signature
const expectedSig = crypto
.createHash('sha512')
.update(body.order_id + body.status_code + body.gross_amount
+ process.env.MIDTRANS_SERVER_KEY)
.digest('hex');
if (expectedSig !== body.signature_key) {
throw new UnauthorizedException('Invalid webhook signature');
}
// 2. Idempotency check — skip if already processed
const alreadyProcessed = await this.paymentRepo.existsByMidtransId(
body.transaction_id
);
if (alreadyProcessed) {
return { message: 'Already processed — skipping' };
}
// 3. Only process successful payments
if (body.transaction_status === 'settlement'
|| body.transaction_status === 'capture') {
await this.arService.recordPayment({
invoiceId: body.order_id.replace('INV-', ''),
amount: parseFloat(body.gross_amount),
paymentDate: new Date(body.settlement_time),
reference: body.transaction_id,
method: body.payment_type,
});
}
return { message: 'OK' };
}The customer portal is a separate Next.js application authenticated via a customer-specific JWT. The NestJS API has a customer context that restricts every query to the authenticated customer's data — all orders, invoices, and shipments are filtered by customer_id at the database level using PostgreSQL Row Level Security. The portal is deployed to Vercel (separate from the ERP which runs on a VPS). The API is shared between the internal ERP and the customer portal, with role-based permission sets controlling what each caller can access.
Customer price lists change — new contracts are signed, promotional prices expire, product costs change. If you cache price lists in the portal application layer (e.g., in Redis for performance), you must invalidate the cache when a price list is updated in the ERP. We experienced an embarrassing incident early in deployment where a client was quoted the old price for a full month after the price list was updated because the cache TTL was set to 24 hours. For pricing data, use a much shorter TTL (5 minutes maximum) or implement ERP-to-portal cache invalidation events via webhooks.
We integrated Midtrans (Indonesia's leading payment gateway) for online invoice payment. Clients can pay outstanding invoices directly from the portal. The payment flow: client selects invoice(s) to pay → Midtrans payment page opens → on success, Midtrans sends a webhook to the NestJS payment handler → payment is recorded in the AR module → invoice status updates to 'Paid' → client sees confirmation. The idempotency of the payment webhook handler is critical — Midtrans can send duplicate webhooks, and recording a payment twice would incorrectly zero out an invoice.
After 6 months of the customer portal at our client, 73% of orders were placed via the portal (up from 0%), average order processing time dropped from 4 hours (phone/email intake, manual ERP entry) to 15 minutes (automated sync), and customer service calls dropped by 55%. The metrics that matter for B2B portal success: portal adoption rate by customer account, orders via portal vs. traditional channels, invoice payment rate via online vs. bank transfer, and time from order placed to ERP order confirmed. Track these from day one.