docs: complete project research for NetForge ISP Management SaaS

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>
This commit is contained in:
kevin-asprec
2026-03-04 17:21:44 +08:00
parent ec2e583787
commit c407e182f8
5 changed files with 1716 additions and 0 deletions

View File

@@ -0,0 +1,527 @@
# 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 * completion` and 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.
1. **Tenant provisioning + Auth** — Without this, nothing is isolated or authenticated. All other modules receive tenant context from here.
2. **Chart of Accounts (Accounting Module — schema only)** — Every money event needs accounts to post to. Provision default accounts at tenant signup.
3. **Subscriber Management** — The core entity. Billing, inventory, and ticketing all reference subscribers.
### Tier 2: Core Operations (Depends on Tier 1)
4. **Billing Engine** — Depends on subscribers and service plans. Generates invoices.
5. **Payment Tracker** — Depends on invoices from Billing Engine. Records what comes in.
6. **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)
7. **Inventory Module (Stock)** — Depends on Accounting (inventory purchases create journal entries).
8. **Expense Tracking** — Depends on Accounting (expense journal entries).
9. **Ticketing System** — Depends on Subscribers (tickets belong to subscribers).
10. **Job Order Module** — Depends on Tickets, Technician Module, Inventory (parts), Accounting (labor cost).
11. **Technician Management** — Can be built alongside Job Orders; they depend on each other.
### Tier 4: Visibility and Access (Depends on Tier 2 + 3)
12. **Dashboard and Metrics** — Depends on all operational modules being populated with data.
13. **Report Engine (Financial)** — Depends on Journal Entry Service having real data.
14. **Client Portal** — Depends on Billing Engine, Payment Tracker, Ticketing System.
### Tier 5: Enhancements
15. **Notification Module** — Can be added incrementally. Start with in-app, extend to email later.
16. **Asset Management** — Extends Inventory; tracks deployed equipment per subscriber.
17. **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