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:
527
.planning/research/ARCHITECTURE.md
Normal file
527
.planning/research/ARCHITECTURE.md
Normal 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
|
||||
Reference in New Issue
Block a user