Files: - STACK.md - FEATURES.md - ARCHITECTURE.md - PITFALLS.md - SUMMARY.md Key findings: - Stack: Next.js 15 + TypeScript + PostgreSQL + Prisma monolith; BullMQ for billing cron jobs; MikroTik client needs library verification (LOW confidence) - Architecture: Modular monolith with shared-DB multi-tenancy; JournalEntryService as sole ledger gateway; derived financial state (no balance fields) - Critical pitfall: Cross-tenant data leakage + fake accounting + billing state machine must all be solved in Phase 1 before any feature work Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
30 KiB
Architecture Patterns
Domain: Multi-tenant ISP Operations Management SaaS Project: NetForge Researched: 2026-03-04 Confidence: HIGH (established patterns, well-documented domain — verified against training knowledge through August 2025; external verification tools unavailable, document from domain expertise)
Recommended Architecture
NetForge is best structured as a modular monolith with tenant-scoped data isolation, not microservices. For a small-to-medium SaaS targeting ISPs with under 500-5,000 subscribers per tenant, microservices add operational complexity that outweighs their benefits. A monolith with clear internal module boundaries allows rapid iteration, is easier to reason about, and can be extracted later if one module scales differently.
The core architectural principle: every database query is scoped to a tenant_id. This is enforced at the repository/data-access layer, not at the application layer. No business logic should ever bypass tenant scoping.
┌─────────────────────────────────────────────────────────────────┐
│ Client Layer │
│ Admin Portal Staff Portal Collector App Client Portal │
│ (web, full) (web, full) (web, mobile) (web, limited)│
└──────────────────────────┬──────────────────────────────────────┘
│ HTTPS / REST or tRPC
┌──────────────────────────▼──────────────────────────────────────┐
│ API Gateway / Auth Layer │
│ - JWT validation │
│ - Tenant resolution (subdomain → tenant_id) │
│ - Role extraction │
│ - Route-level permission guards │
└──────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────────┐
│ Application Modules (Monolith) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │Subscriber│ │ Billing │ │ Payment │ │ Accounting │ │
│ │ Mgmt │ │ Engine │ │ Tracker │ │ (Ledger) │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Ticketing│ │ Job Order│ │Technician│ │ Inventory │ │
│ │ System │→ │ Workflow │ │ Mgmt │ │ (Assets) │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │
│ │ Expense │ │Dashboard │ │ Report │ │ Notification │ │
│ │ Tracking │ │& Metrics │ │ Engine │ │ (Internal) │ │
│ └──────────┘ └──────────┘ └──────────┘ └───────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Tenant Context Middleware │ │
│ │ (all modules receive tenant_id from request ctx) │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────────┐
│ Data Layer │
│ PostgreSQL (primary) Redis (session/cache) │
│ - tenant_id on all rows - rate limiting │
│ - Row-Level Security - background job queues │
│ - Soft deletes - temp calculations │
└─────────────────────────────────────────────────────────────────┘
Component Boundaries
1. Auth and Tenant Resolution
| Component | Responsibility | Communicates With |
|---|---|---|
| Auth Module | JWT issuance, refresh, logout, session management | All modules (provides tenant_id + role to request context) |
| Tenant Resolver | Maps subdomain/slug to tenant_id, validates tenant exists and is active | Auth module, database |
| Permission Guard | Route-level RBAC enforcement based on role from JWT | All route handlers |
Key rule: The JWT payload carries { user_id, tenant_id, role }. Every request arriving at the application layer already knows its tenant. No module may accept a request without tenant context.
Role matrix summary:
| Permission Area | Admin | Office Staff | Collector | Technician | Client |
|---|---|---|---|---|---|
| Full system config | Yes | No | No | No | No |
| Billing & subscriber mgmt | Yes | Yes | No | No | No |
| Payment recording | Yes | Yes | Yes (own collections) | No | No |
| Ticket creation | Yes | Yes | No | No | Yes |
| Job order assignment | Yes | Yes | No | No | No |
| Job order completion | Yes | No | No | Yes (own) | No |
| Inventory adjustments | Yes | Yes | No | No | No |
| Accounting / journal entries | Yes | No | No | No | No |
| Own portal (view bills, pay) | No | No | No | No | Yes |
2. Subscriber Management
| Component | Responsibility | Communicates With |
|---|---|---|
| Subscriber Module | Registration, plan assignment, status (active/suspended/disconnected), contact info | Billing engine (triggers first bill), Inventory (asset assignment), Auth (creates client portal account) |
Data owned: subscribers, service_plans, subscriber_plan_history
Key boundary: Subscriber management does not know about accounting. When a subscriber is activated, it emits an event (or calls a service interface) to the Billing engine. The Billing engine then creates the appropriate ledger entries via the Accounting module. Subscriber module never writes to the journal directly.
3. Billing Engine
This is the most complex module. It must handle prepaid and postpaid models with different logic.
| Component | Responsibility | Communicates With |
|---|---|---|
| Billing Engine | Bill generation (scheduled + manual), billing cycle tracking, invoice creation, prepaid/postpaid logic, due date calculation, overdue detection | Subscriber module (read plan), Accounting module (write journal entries), Notification module (triggers bill-ready notices) |
Prepaid logic: Bill is generated before the service period begins. Subscriber must pay before activation or renewal. Overdue = service suspended.
Postpaid logic: Bill is generated at end of service period. Subscriber pays after consuming service. Overdue = follow-up by collector.
Data owned: invoices, billing_cycles, billing_settings (per tenant: cycle day, grace period, etc.)
Key boundary: The Billing engine does not record payments — that is the Payment Tracker's responsibility. An invoice has a status field (unpaid, partial, paid, overdue) that is updated by the Payment Tracker when a payment is recorded against it.
4. Payment Tracker
Tracks all money received. Collector-aware — the system must know which collector received which payment for reconciliation.
| Component | Responsibility | Communicates With |
|---|---|---|
| Payment Tracker | Recording cash/online payments, linking payment to invoice, updating invoice status, collector balance tracking, reconciliation | Billing engine (read invoice, update status), Accounting module (write journal entry: cash received → accounts receivable cleared), Collector module (update collector running balance) |
Data owned: payments, collector_remittances, payment_methods
Key design decision: Payments are immutable once recorded. Corrections happen via reversal entries, not edits. This is essential for audit integrity and aligns with double-entry accounting principles.
Collector flow:
Collector records cash payment from subscriber
→ Payment created (linked to collector + invoice)
→ Invoice status updated (partially or fully paid)
→ Collector running balance increases (they are holding the cash)
→ When collector remits to office: remittance event created
→ Collector balance decreases, office cash account increases
→ Accounting module writes journal entries for both events
5. Accounting Module (Double-Entry Ledger)
The accounting module is a foundational module that every money-touching module writes to, but it does not initiate transactions itself.
| Component | Responsibility | Communicates With |
|---|---|---|
| Chart of Accounts | Tenant-specific account structure (assets, liabilities, equity, income, expense) | All money modules (read accounts to post to) |
| Journal Entry Service | Creating balanced journal entries (debit = credit always), posting, voiding | Payment Tracker, Billing Engine, Expense Module, Inventory (asset capitalization) |
| Report Engine | Generating Trial Balance, Balance Sheet, Income Statement from journal entries | No writes — read-only from journal |
Standard accounts (auto-provisioned per tenant at signup):
Assets:
- Cash (office)
- Cash in Transit (collector-held)
- Accounts Receivable (subscriber balances)
- Inventory (network equipment)
- Fixed Assets (deployed equipment)
Liabilities:
- (minimal in v1 — can add later)
Equity:
- Owner's Equity
Income:
- Subscription Revenue
Expenses:
- Technician Labor
- Network Equipment (expense)
- Operating Expenses
Key rule: Journal entries are never edited after posting. Corrections happen via reversing journal entries (a new entry that exactly offsets the original, then a correct entry). This is a non-negotiable accounting principle.
Data owned: chart_of_accounts, journal_entries, journal_entry_lines, accounting_periods
6. Ticketing and Job Order Workflow
These two are closely related but have distinct responsibilities.
| Component | Responsibility | Communicates With |
|---|---|---|
| Ticket System | Receiving client issues (from client portal or staff manual entry), ticket lifecycle (open → assigned → resolved → closed), priority, category | Job Order module (a ticket can spawn one or more job orders), Subscriber module (read subscriber data), Notification module |
| Job Order Module | Technical work orders created from tickets, technician assignment, scheduling, completion recording, job type tracking | Ticket system (reads parent ticket), Technician module (assignment), Inventory module (parts used), Accounting module (record labor cost as journal entry) |
Flow:
Client submits ticket (portal) OR staff creates ticket (from call/text)
→ Ticket is open
→ Staff reviews → creates Job Order from ticket
→ Job Order assigned to technician
→ Technician completes job → marks completed (optionally with parts used)
→ If parts used → Inventory module decrements stock
→ Technician compensation calculated
→ Accounting: debit Labor Expense, credit Technician Payable
→ Ticket marked resolved
7. Technician Management
| Component | Responsibility | Communicates With |
|---|---|---|
| Technician Module | Technician profiles, compensation model (per-job vs salary), job rate lookup by job type, compensation calculation, payroll summary | Job Order module (receives completion events), Accounting module (writes compensation journal entries) |
Data owned: technicians, job_types, job_rates, technician_compensation_records
Two compensation models must coexist:
- Per-job: each job order completion calculates
job_rate * completionand credits technician payable - Monthly salary: fixed monthly amount, booked at period end regardless of job count
8. Inventory Module
Manages two distinct concerns: stock on hand and deployed assets.
| Component | Responsibility | Communicates With |
|---|---|---|
| Stock Management | Items in warehouse/storage, quantity tracking, purchase recording, low-stock alerts | Expense/Accounting module (purchase creates inventory asset entry), Job Order module (receives depletion events) |
| Asset Management | Equipment deployed to subscribers (router serial, ONU serial, location), asset lifecycle (deployed → returned → re-stocked or written off) | Subscriber module (asset linked to subscriber), Job Order module (technician records what was installed) |
Data owned: inventory_items, inventory_categories, stock_transactions, deployed_assets, asset_assignments
Key distinction:
- An item is "stock" until it is deployed to a subscriber address
- Once deployed, it becomes a "deployed asset" tracked per subscriber
- Return = asset moves back to stock (if functional) or written off (if damaged)
9. Expense Tracking
| Component | Responsibility | Communicates With |
|---|---|---|
| Expense Module | Recording non-inventory operational expenses, category assignment, expense approval (optional), attachment of receipts | Accounting module (each expense creates a journal entry: debit Expense account, credit Cash or Payable) |
Data owned: expenses, expense_categories
10. Dashboard and Reporting
| Component | Responsibility | Communicates With |
|---|---|---|
| Dashboard Service | Aggregating KPIs: total subscribers, active/suspended, monthly revenue, outstanding receivables, collector balances, open tickets, pending job orders | Read-only from all modules |
| Report Engine | Formal financial reports (Balance Sheet, Income Statement, Trial Balance), operational reports (collector reconciliation, technician productivity, inventory valuation) | Read-only from journal entries and operational tables |
Key rule: The Dashboard and Report Engine are read-only. They never write. All their data comes from the operational modules. Complex reports should be pre-computed on a schedule (via background jobs) rather than computed on-demand to avoid slow queries on large datasets.
11. Client Portal
| Component | Responsibility | Communicates With |
|---|---|---|
| Client Portal | Subscriber-facing view: view current bill, payment history, submit support ticket, view service plan, view deployed equipment | Billing engine (read invoices), Payment Tracker (read payment history), Ticket system (create/read own tickets), Subscriber module (read own profile) |
Key boundary: The Client Portal is READ-HEAVY with one write action (submit ticket, initiate online payment). It is scoped strictly to the authenticated subscriber — a client JWT cannot access any other subscriber's data. This is enforced at both the permission guard layer AND the data access layer.
12. Notification Module (Internal)
| Component | Responsibility | Communicates With |
|---|---|---|
| Notification Module | In-app notifications for staff (new ticket, job order assigned, overdue invoices), optionally email later | Receives events from Billing engine, Ticket system, Job Order module |
Scope: v1 should be in-app only (database-backed notifications, polled or WebSocket-pushed). Email/SMS integration is post-v1.
Data Flow
Money Flow (Complete Picture)
[Service Activation]
Billing Engine generates Invoice
→ Invoice record created (status: unpaid)
→ Journal Entry: DR Accounts Receivable / CR Subscription Revenue
[Cash Payment via Collector]
Collector records payment
→ Payment record created (linked to invoice + collector)
→ Invoice status updated (partial/paid)
→ Journal Entry: DR Cash in Transit (collector) / CR Accounts Receivable
[Collector Remits to Office]
Remittance recorded
→ Journal Entry: DR Cash (office) / CR Cash in Transit (collector)
→ Collector balance zeroed
[Online Payment - future gateway]
Payment gateway webhook → Payment Tracker
→ Same flow as cash minus collector step
[Expense Recorded]
Expense Module creates expense
→ Journal Entry: DR Expense Account / CR Cash or Accounts Payable
[Inventory Purchase]
Stock purchase recorded
→ Journal Entry: DR Inventory Asset / CR Cash or Accounts Payable
[Equipment Deployed (Capitalized)]
Asset assigned to subscriber via Job Order
→ Journal Entry: DR Fixed Assets (deployed) / CR Inventory
[Technician Compensation (Per Job)]
Job Order completed
→ Journal Entry: DR Labor Expense / CR Technician Payable
[Technician Paid]
Payroll disbursement recorded
→ Journal Entry: DR Technician Payable / CR Cash
Ticket-to-Resolution Flow
Client Portal or Staff Input
→ Ticket Created (open)
→ Staff Reviews
→ Job Order Created from Ticket
→ Technician Assigned (notification sent)
→ Technician Completes Work
→ Parts Used? → Inventory depleted
→ Job compensation calculated
→ Job Order Closed
→ Ticket Resolved
→ Client notified (in-app)
Tenant Isolation Flow
Request arrives → DNS resolves subdomain (e.g., acme.netforge.app)
→ Tenant Resolver: subdomain → tenant_id (cached in Redis)
→ Auth validates JWT → extracts user_id, tenant_id, role
→ Request context carries { tenant_id, user_id, role }
→ Every repository method receives tenant_id as first parameter
→ All queries include WHERE tenant_id = ? as baseline
→ PostgreSQL Row Level Security as second defense layer
Suggested Build Order (Phase Dependencies)
This ordering reflects hard dependencies — later components cannot function without earlier ones.
Tier 1: Foundation (Must Build First)
These components have no dependencies on other domain modules.
- Tenant provisioning + Auth — Without this, nothing is isolated or authenticated. All other modules receive tenant context from here.
- Chart of Accounts (Accounting Module — schema only) — Every money event needs accounts to post to. Provision default accounts at tenant signup.
- Subscriber Management — The core entity. Billing, inventory, and ticketing all reference subscribers.
Tier 2: Core Operations (Depends on Tier 1)
- Billing Engine — Depends on subscribers and service plans. Generates invoices.
- Payment Tracker — Depends on invoices from Billing Engine. Records what comes in.
- Journal Entry Service — Depends on Chart of Accounts. Now that billing and payments exist, we can post entries. Build this as a service that Billing and Payment Tracker call.
Tier 3: Operational Modules (Depends on Tier 1 + 2)
- Inventory Module (Stock) — Depends on Accounting (inventory purchases create journal entries).
- Expense Tracking — Depends on Accounting (expense journal entries).
- Ticketing System — Depends on Subscribers (tickets belong to subscribers).
- Job Order Module — Depends on Tickets, Technician Module, Inventory (parts), Accounting (labor cost).
- Technician Management — Can be built alongside Job Orders; they depend on each other.
Tier 4: Visibility and Access (Depends on Tier 2 + 3)
- Dashboard and Metrics — Depends on all operational modules being populated with data.
- Report Engine (Financial) — Depends on Journal Entry Service having real data.
- Client Portal — Depends on Billing Engine, Payment Tracker, Ticketing System.
Tier 5: Enhancements
- Notification Module — Can be added incrementally. Start with in-app, extend to email later.
- Asset Management — Extends Inventory; tracks deployed equipment per subscriber.
- Collector Remittance Workflow — Extends Payment Tracker.
Architecture Decisions
Decision 1: Shared Database with Row-Level Security
What: All tenants in one PostgreSQL database. Every table has tenant_id. PostgreSQL Row Level Security (RLS) policies enforce isolation at the database level as a backstop.
Why: Simpler to operate than per-tenant databases. Schema migrations run once. Connection pooling works. For SMB SaaS targeting small ISPs (not enterprise), this is the standard and correct approach.
Tradeoff: A noisy tenant (one ISP with 10,000 subscribers generating lots of queries) can affect others. Mitigation: rate limiting at the API layer, background job queuing.
Confidence: HIGH — this is the established pattern for SMB SaaS (used by Shopify, Linear, many others at this scale tier).
Decision 2: Event-Driven Internally, Not a Message Bus
What: Modules communicate through in-process function/service calls, not a message queue. When the Billing Engine generates an invoice, it calls AccountingService.postEntry(...) directly. No Kafka, no RabbitMQ.
Why: A message bus is justified when modules are deployed separately (microservices) or when processing is async by requirement. For a monolith targeting sub-second response times on a small-medium dataset, synchronous in-process calls are simpler and correct. Async background jobs (via a job queue like BullMQ or pg-based queue) handle scheduled billing runs and report generation.
Tradeoff: If one module's accounting write fails, the whole transaction fails. This is actually correct behavior for financial data — you don't want a payment recorded without its journal entry.
Confidence: HIGH — this is the correct tradeoff for this scale.
Decision 3: Double-Entry Enforced at Service Layer
What: The JournalEntryService is the only way to write to journal_entry_lines. It validates that debits = credits before persisting. No module writes directly to accounting tables.
Why: Prevents imbalanced books. A bug in the Payment Tracker cannot create an unbalanced journal entry if all writes go through a single service that enforces balance.
Confidence: HIGH — standard accounting software pattern.
Decision 4: Immutable Financial Records
What: Payments and journal entries cannot be edited after creation. Corrections use reversal entries.
Why: Audit trail integrity. Tax compliance. Debugging is possible. Financial regulators (even informal ones like business owners doing year-end) need to see the full history of what happened, not what it looks like after edits.
Confidence: HIGH — non-negotiable for any accounting system.
Decision 5: Collector Balance as Derived State
What: A collector's current cash balance is not stored as a single field. It is computed from: SUM(payments.amount WHERE collector_id = X AND NOT remitted) - SUM(remittances.amount WHERE collector_id = X). This can be cached in Redis for performance.
Why: A stored balance field is prone to drift (bugs, race conditions). The authoritative answer always comes from the transaction log.
Tradeoff: Slightly more expensive to compute. Cache with a short TTL (or invalidate on new payment/remittance) to handle at scale.
Confidence: HIGH — event sourcing / derived state principle.
Anti-Patterns to Avoid
Anti-Pattern 1: Application-Layer-Only Tenant Isolation
What: Relying solely on WHERE tenant_id = ? in application code, without any database-level backstop.
Why bad: A single bug (missing WHERE clause) leaks data across tenants. This is a catastrophic security failure for a SaaS product.
Instead: Add PostgreSQL Row Level Security as a second layer. Even if the application omits the tenant filter, RLS prevents the query from returning another tenant's rows. Set RLS on every table that contains tenant data.
Anti-Pattern 2: Billing Engine Writing Directly to Cash Tables
What: The Billing Engine or Payment Tracker directly incrementing/decrementing account balances in a "balances" table.
Why bad: Balance tables drift. Concurrent updates cause race conditions. You lose the transaction history needed for reconciliation.
Instead: No balance table exists. Balances are always derived from journal entry lines. SUM(amount WHERE account_id = X AND type = 'debit') - SUM(amount WHERE account_id = X AND type = 'credit') IS the balance. Cache for performance, but the source of truth is the ledger.
Anti-Pattern 3: Merging Ticket and Job Order into One Entity
What: Treating "ticket" and "job order" as the same record.
Why bad: A single client issue might spawn multiple job orders (initial visit + follow-up). A job order might exist without a client ticket (proactive maintenance). They have different lifecycles, different owners (client-facing vs technician-facing), and different data shapes.
Instead: Keep them separate. Tickets have a has_many :job_orders relationship. Job orders have belongs_to :ticket (nullable for non-ticket-originating work).
Anti-Pattern 4: Storing Computed Financial State
What: Storing "total revenue this month" or "outstanding balance per subscriber" as columns that get updated on every transaction.
Why bad: Race conditions. Bugs cause silent drift. Hard to audit. Hard to debug when numbers don't match.
Instead: Compute on demand from the ledger. Cache aggressively in Redis with appropriate invalidation. For reports, use materialized views or pre-computed report snapshots run on a schedule.
Anti-Pattern 5: Granting Client Role DB-Level SELECT on All Tables
What: Building RBAC only at the API route level, without data-access-layer scoping for the client role.
Why bad: A client who discovers an API endpoint (through network inspection) can potentially access other subscribers' invoices if the data layer doesn't enforce subscriber-scoping on top of tenant-scoping.
Instead: Client Portal requests are scoped at TWO levels: tenant_id (from JWT) AND subscriber_id (from JWT). The data access layer for all client-facing queries adds AND subscriber_id = ? in addition to AND tenant_id = ?.
Anti-Pattern 6: Soft Deletes Without Accounting for Them
What: Adding deleted_at soft deletes everywhere without ensuring all queries include WHERE deleted_at IS NULL.
Why bad: Deleted records silently appear in reports, counts, and joins if any query forgets the filter.
Instead: Use a database view or ORM default scope that filters deleted records. Alternatively, use PostgreSQL RLS to filter them. Never rely on developers remembering to add the filter manually.
Scalability Considerations
| Concern | At 10 tenants (MVP) | At 100 tenants | At 1,000 tenants |
|---|---|---|---|
| Database size | Single PostgreSQL, no concerns | Same, add indexes | Consider read replicas for reports |
| Billing runs | Cron job, sequential per tenant | Background job queue with tenant workers | Distributed job queue (BullMQ), parallelized |
| Report generation | On-demand from ledger | Nightly pre-computed snapshots | Materialized views, async report jobs |
| Tenant isolation | RLS policies | Same | Same, plus connection pooling limits per tenant |
| API performance | No concerns | Cache hot reads in Redis | Horizontal API scaling, DB read replicas |
| Concurrent collectors | Not a concern | Optimistic locking on payment records | Same |
Sources
All findings based on established SaaS architecture patterns, double-entry accounting principles, and multi-tenant database design patterns. External verification tools were unavailable during this research session.
Confidence notes by section:
- Multi-tenant shared DB with RLS: HIGH (industry standard, well-documented)
- Double-entry accounting patterns: HIGH (GAAP principles, stable for decades)
- Modular monolith over microservices for this scale: HIGH (well-established guidance from Martin Fowler, Sam Newman, others)
- Billing engine prepaid/postpaid model: HIGH (standard ISP billing concepts)
- Collector balance as derived state: HIGH (event sourcing principle)
- Specific framework/library choices: Deferred to STACK.md