DEV Community

Cover image for Building a Production-Ready Multi-Tenant ISP Billing System in 2 Days with Qoder AI
gnetid
gnetid

Posted on

Building a Production-Ready Multi-Tenant ISP Billing System in 2 Days with Qoder AI

TL;DR πŸš€

I built a complete multi-tenant ISP billing system from scratch and deployed it to production in just 48 hours using Qoder AI. The system now handles real ISP operations including:

  • βœ… Multi-tenant architecture with full data isolation
  • βœ… PPPoE user management & FreeRADIUS integration
  • βœ… Payment processing (Midtrans & Xendit)
  • βœ… WhatsApp notifications with load balancing
  • βœ… Hotspot voucher system with e-commerce
  • βœ… Real-time router monitoring
  • βœ… Financial tracking & reporting

Tech Stack: Next.js 15, Prisma ORM, FreeRADIUS, Mikrotik RouterOS, MySQL


The Challenge πŸ’‘

ISP billing systems are notoriously complex. You need to handle:

  • Customer management across multiple branches
  • Real-time integration with network equipment (Mikrotik routers)
  • Payment gateway integration with webhook handling
  • Automated notifications (WhatsApp, Email)
  • Multi-tenant data isolation
  • Financial reporting

Traditionally, building such a system would take weeks or months. I had 2 days.


Why Qoder AI? πŸ€–

I've used various AI coding assistants before, but Qoder's memory feature changed the game completely. Here's what made the difference:

1. Context Retention Across Sessions

Qoder remembered our previous conversations about the architecture. When I came back the next day, it didn't ask "what are you building?" - it just continued where we left off.

2. Understanding Complex Architecture

Multi-tenant systems are tricky. Qoder helped me:

  • Design proper tenant isolation in the database
  • Implement domain-based tenant detection
  • Handle centralized services with tenant overrides (VPN, Payment Gateway)

3. Production-Ready Code

Not just prototypes - Qoder helped write actual production code with:

  • Proper error handling
  • Security considerations (tenant isolation, authentication)
  • Performance optimizations
  • Real-world edge cases

The Architecture πŸ—οΈ

Multi-Tenant Design

The system supports two operational modes:

HEAD Office:

// SuperHead admin manages all tenants
// Access: /mgmt
// Role: HEAD_ADMIN
// tenantId: null
Enter fullscreen mode Exit fullscreen mode

Branch Offices:

// Each tenant has isolated data
// Access: /admin  
// Role: BRANCH_ADMIN
// tenantId: specific UUID
Enter fullscreen mode Exit fullscreen mode

Database Schema Highlights

// All core models have tenant isolation
model PppoeUser {
  id        String   @id @default(uuid())
  tenantId  String?  // NULL = shared, UUID = specific tenant
  username  String   @unique
  // ... other fields

  tenant    Tenant?  @relation(fields: [tenantId], references: [id])
  @@index([tenantId])
}

model PaymentGateway {
  id        String   @id @default(uuid())
  tenantId  String?  // NULL = HEAD (central), UUID = tenant override
  provider  String   // midtrans, xendit, duitku
  // ... credentials

  @@unique([tenantId, provider])
}
Enter fullscreen mode Exit fullscreen mode

Key Technical Decisions

1. Centralized Services with Override Capability

Some services (VPN Server, Payment Gateway) are shared by default but can be overridden per tenant:

// Payment Gateway Selection Logic
async function getActivePaymentGateway(provider: string, tenantId: string | null) {
  // Check tenant override flag
  const tenant = await prisma.tenant.findUnique({
    where: { id: tenantId },
    select: { useCustomPaymentGw: true }
  })

  if (tenant?.useCustomPaymentGw) {
    // Use tenant-specific gateway
    return await prisma.paymentGateway.findFirst({
      where: { provider, tenantId, isActive: true }
    })
  }

  // Fallback to HEAD (central) gateway
  return await prisma.paymentGateway.findFirst({
    where: { provider, tenantId: null, isActive: true }
  })
}
Enter fullscreen mode Exit fullscreen mode

2. Domain-Based Tenant Detection

Public-facing pages (e-voucher store, customer portal) auto-detect tenant from domain:

export async function GET(req: NextRequest) {
  const host = req.headers.get('host') || ''
  const domain = host.split(':')[0].toLowerCase()

  const tenant = await prisma.tenant.findFirst({
    where: {
      OR: [
        { domain: domain },
        { alternativeDomain: domain }
      ],
      isActive: true
    }
  })

  // Filter data by detected tenant
  const profiles = await prisma.hotspotProfile.findMany({
    where: { 
      tenantId: tenant?.id,
      eVoucherAccess: true 
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

3. Webhook Handler with Tenant Auto-Detection

Single webhook endpoint serves all tenants - auto-detects from invoice/order:

// Centralized webhook: /api/payment/webhook
export async function POST(req: NextRequest) {
  // 1. Detect payment gateway
  const gateway = detectGateway(req)

  // 2. Extract order ID
  const orderId = extractOrderId(body, gateway)

  // 3. Auto-detect tenant from invoice/order
  let tenantId: string | null = null

  if (orderId.startsWith('EV')) {
    // E-voucher order
    const order = await prisma.hotspotVoucherOrder.findFirst({
      where: { orderNumber: orderId },
      select: { tenantId: true }
    })
    tenantId = order?.tenantId || null
  } else {
    // Invoice payment
    const invoice = await prisma.invoice.findUnique({
      where: { paymentToken: orderId },
      select: { tenantId: true }
    })
    tenantId = invoice?.tenantId || null
  }

  // 4. Get tenant-aware payment gateway config
  const gatewayConfig = await getActivePaymentGateway(gateway, tenantId)

  // 5. Verify signature & process payment
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Development Journey πŸ“…

Day 1: Core Foundation (12 hours)

Morning (4 hours):

  • βœ… Set up Next.js 15 project with Prisma
  • βœ… Design multi-tenant database schema
  • βœ… Implement authentication (NextAuth with dual admin types)
  • βœ… Create SuperHead dashboard for tenant management

Afternoon (4 hours):

  • βœ… PPPoE user management UI
  • βœ… FreeRADIUS integration for authentication
  • βœ… Mikrotik RouterOS API integration
  • βœ… Real-time session monitoring

Evening (4 hours):

  • βœ… Payment gateway integration (Midtrans & Xendit)
  • βœ… Invoice generation system
  • βœ… Webhook handler (centralized, tenant-aware)

Day 2: Integration & Polish (12 hours)

Morning (4 hours):

  • βœ… WhatsApp notification system with load balancing
  • βœ… Hotspot voucher generation
  • βœ… E-voucher public store with payment
  • βœ… VPN client management

Afternoon (4 hours):

  • βœ… Financial tracking (Keuangan module)
  • βœ… Multi-tenant payment gateway override
  • βœ… Domain-based tenant detection
  • βœ… Tenant-specific redirect URLs

Evening (4 hours):

  • βœ… Production deployment
  • βœ… Testing with real Mikrotik routers
  • βœ… Live payment testing
  • βœ… Bug fixes & optimizations

How Qoder AI Accelerated Development πŸš€

1. Architectural Guidance

When designing the multi-tenant architecture, I asked Qoder about the VPN server pattern from a previous project. It remembered the implementation and suggested:

"Om, untuk VPN server bisa pakai pattern centralized dengan tenant override capability. VPN Server dibuat shared (tenantId = null), tapi setiap tenant punya VPN Client sendiri."

This saved hours of architectural decision-making.

2. Code Generation with Context

Qoder didn't just generate isolated code snippets. It understood the entire codebase structure:

Me: "Add payment gateway override toggle in admin panel"

Qoder generated:

  1. βœ… API endpoint: /api/tenant/payment-gateway-override
  2. βœ… Database query with proper tenant filter
  3. βœ… UI component with toggle switch
  4. βœ… Integration with existing payment flow
  5. βœ… Updated helper functions to respect override flag

All in one response, fully integrated with existing code!

3. Bug Hunting & Fixes

When I encountered a Prisma error with compound unique keys:

Error: Type '{ provider: string; }' is not assignable to type 'PaymentGatewayWhereUniqueInput'
Enter fullscreen mode Exit fullscreen mode

Qoder immediately identified the issue and provided the fix:

// Before (broken)
where: { provider: 'midtrans' }

// After (fixed)
where: {
  tenantId_provider: {
    tenantId: tenantId,
    provider: 'midtrans'
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Production Best Practices

Qoder proactively suggested production improvements:

  • "Add tenantId index for faster queries"
  • "Use transaction for payment webhook to avoid race conditions"
  • "Implement exponential backoff for WhatsApp API retries"
  • "Add domain validation before tenant creation"

These weren't things I asked for - Qoder anticipated production needs!


Code Examples πŸ’»

Example 1: Tenant-Aware API Endpoint

// app/api/pppoe/users/route.ts
export async function GET() {
  const session = await getServerSession(authOptions)

  // Build tenant filter
  const tenantWhere = session.user.role === 'HEAD_ADMIN'
    ? {}  // HEAD sees all tenants
    : { tenantId: session.user.tenantId }  // Branch sees only their data

  const users = await prisma.pppoeUser.findMany({
    where: {
      ...tenantWhere,
      status: 'ACTIVE'
    },
    include: {
      profile: true,
      router: true
    }
  })

  return NextResponse.json({ users })
}
Enter fullscreen mode Exit fullscreen mode

Example 2: WhatsApp Load Balancing

// lib/whatsapp-service.ts
async function selectProvider(tenantId: string | null): Promise<WhatsAppProvider> {
  const providers = await prisma.whatsappProvider.findMany({
    where: {
      tenantId: tenantId,
      isActive: true
    }
  })

  // Calculate current usage for each provider
  const now = new Date()
  const oneMinuteAgo = new Date(now.getTime() - 60000)

  const providerUsage = await Promise.all(
    providers.map(async (provider) => {
      const recentCount = await prisma.whatsAppHistory.count({
        where: {
          providerId: provider.id,
          createdAt: { gte: oneMinuteAgo }
        }
      })

      return {
        provider,
        usage: recentCount,
        capacity: provider.maxPerMinute
      }
    })
  )

  // Select provider with lowest usage ratio
  return providerUsage
    .filter(p => p.usage < p.capacity)
    .sort((a, b) => (a.usage / a.capacity) - (b.usage / b.capacity))[0]
    ?.provider
}
Enter fullscreen mode Exit fullscreen mode

Results & Impact πŸ“Š

Production Metrics (After 1 Month)

  • 6 Tenants actively using the system
  • 500+ PPPoE Users managed across tenants
  • 1,000+ Invoices processed
  • 10,000+ Hotspot Vouchers generated
  • 100% Payment Success Rate (webhook handling)
  • 99.9% Uptime (real-time monitoring with alerts)

Business Value

  1. Rapid Deployment: ISP branches can onboard in < 5 minutes
  2. Cost Savings: No per-tenant infrastructure needed
  3. Centralized Management: HEAD office monitors all branches
  4. Flexibility: Branches can customize payment gateway & WhatsApp
  5. Scalability: Add unlimited tenants without code changes

Technical Achievements

  • βœ… Complete tenant isolation (data, domains, services)
  • βœ… Real-time FreeRADIUS integration
  • βœ… Multi-gateway payment processing
  • βœ… Automated WhatsApp notifications with load balancing
  • βœ… VPN-based router management
  • βœ… Public e-voucher store with payment

Lessons Learned πŸŽ“

What Worked Amazingly Well

1. Qoder's Memory Feature

The ability to continue conversations across sessions was a game-changer. I could work in short bursts (2-3 hours) and Qoder always picked up exactly where we left off.

2. Multi-Tenant from Day 1

Designing for multi-tenancy from the start (even for a single client) proved invaluable. When new branches wanted to use the system, it was just "create tenant" - done!

3. Centralized Services with Override

The VPN Server and Payment Gateway pattern (shared by default, override per tenant) gave perfect balance between simplicity and flexibility.

4. Domain-Based Tenant Detection

Auto-detecting tenant from domain eliminated configuration complexity for public-facing features (e-voucher store, customer portal).

Challenges & Solutions

Challenge 1: Prisma Compound Unique Keys

  • Issue: Couldn't use where: { provider } due to @@unique([tenantId, provider])
  • Solution: Use compound key syntax or findFirst with full where clause
  • Lesson: Always check Prisma schema for compound uniques!

Challenge 2: FreeRADIUS Restart Required

  • Issue: Router changes didn't reflect until FreeRADIUS restarted
  • Solution: Automated sudo systemctl restart freeradius after NAS changes
  • Lesson: External service integration needs automation!

Challenge 3: Webhook Tenant Detection

  • Issue: Single webhook URL for all tenants - how to know which tenant?
  • Solution: Auto-detect from invoice/order β†’ get tenant-specific config
  • Lesson: Design webhooks for multi-tenancy from the start!

Qoder Pro Tips

  1. Use Memory Wisely: Tell Qoder to remember important architectural decisions
  2. Show Context: Share relevant code when asking for changes
  3. Iterate in Chunks: Break features into smaller tasks for better results
  4. Ask for Explanations: Understanding the "why" helps catch issues early

5. Production Mindset: Ask about error handling, edge cases, security

What's Next? πŸš€

The system is live and stable, but there's always room for improvement:

Planned Features:

  • πŸ“Š Advanced analytics dashboard (revenue trends, user growth)
  • πŸ€– Automated customer onboarding via chatbot
  • πŸ“± Mobile app for admins and agents
  • πŸ”” Telegram bot for real-time alerts
  • 🌐 Multi-language support
  • πŸ’Ύ Automated database backups with Telegram integration

Technical Improvements:

  • Redis caching for session data
  • Elasticsearch for invoice search
  • Webhook retry queue with Bull
  • API rate limiting per tenant

Conclusion 🎯

Building a production-ready ISP billing system in 2 days seemed impossible. With Qoder AI, it became reality.

The key wasn't just AI-generated code - it was having an AI assistant that:

  • Remembered our architectural decisions
  • Understood the business context
  • Suggested production-ready patterns
  • Caught errors before they hit production
  • Stayed consistent across long development sessions

For developers building complex, multi-tenant systems - Qoder's memory feature is a superpower. It's like pair programming with someone who never forgets, never gets tired, and always has production best practices in mind.

Huge thanks to Nathan Steel and the Qoder team for building such an incredible tool! πŸ™


Links & Resources

  • Project Type: ISP Billing & Management System
  • Tech Stack: Next.js 15, Prisma, FreeRADIUS, Mikrotik
  • Deployment: Production (6 active tenants)
  • Development Time: 48 hours
  • AI Assistant: Qoder AI

Want to discuss multi-tenant architecture or Qoder tips? Drop a comment below! πŸ‘‡


Tags: #qoder #nextjs #typescript #prisma #multitenant #casestudy #isp #billing #production

Top comments (0)