Skip to main content
BlogERP

Building a B2B Customer Portal Integrated with Your Custom ERP

How to design and build a customer-facing B2B portal that lets clients place orders, track shipments, view invoices, and pay online — fully integrated with your ERP's order and AR modules.

Matthews Wong

Software Engineer

June 202511 min read
ERP

Key Takeaway

In 2025, 85 percent of B2B companies offered an online customer portal, up from 68 percent a year earlier, and those adopting digital B2B channels reported an average 41 percent increase in sales revenue. A portal earns that only when it reads live ERP stock, pricing and credit data.

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. As a freelance ERP consultant, I built a B2B customer portal integrated with a custom ERP I developed for a manufacturing client. This post walks through the architecture and the challenges I solved.

What B2B Customers Actually Need

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.

Customer-Specific Pricing and Credit Limits

In Indonesian B2B, every major client has a negotiated price list. The 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 as a freelance consultant: build the credit check at the API level, not just the frontend. I 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.

Order Management and Real-Time Status Tracking

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. I 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.

Document Downloads — Invoices, DO, and COA

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' };
}

How I Approached This

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. I 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.

Online Invoice Payment Integration

I 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.

Adoption and Measuring Success

After 6 months of the customer portal at my 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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why must credit limit checks be enforced at the API level, not just in the UI?

A frontend-only credit limit check can be bypassed by a sophisticated client calling the API directly. Enforcing the rule in the NestJS service layer ensures the business rule is applied regardless of how the portal is accessed. The UI then acts as a convenience layer on top of those server-side rules.

How does the portal sync order status between the customer-facing app and the ERP?

The integration uses a combination of database triggers and NestJS event emitters. When a Sales Order status changes in the ERP, an event is emitted that updates the portal-facing status table, giving customers real-time visibility into their order and shipment progress. Customers can also subscribe to email notifications for each status change.

What is the recommended cache TTL for customer price list data, and why?

A maximum TTL of 5 minutes is recommended for pricing data, or ERP-to-portal cache invalidation events via webhooks should be implemented. Using a 24-hour TTL caused an incident where a client was quoted the old price for a full month after the price list was updated in the ERP. Shorter TTLs or event-driven invalidation prevent stale pricing from reaching customers.

Which payment gateway was used for online invoice payments, and how is duplicate payment prevented?

Midtrans, Indonesia's leading payment gateway, was integrated for online invoice payments. The webhook handler that records payments must be idempotent, because Midtrans can send duplicate webhooks — processing the same webhook twice would incorrectly zero out an invoice. Idempotency checks in the NestJS payment handler prevent this.

What measurable results did the portal deliver after six months?

After six months, 73% of orders were placed via the portal (up from 0%), average order processing time dropped from 4 hours to 15 minutes, and customer service calls dropped by 55%. These outcomes came from replacing a manual phone and email intake process with automated ERP sync and self-service document access.