Files
NetForge/.planning/phases/03-operational-modules/03-RESEARCH.md
kevin-asprec 9af86c54c3 docs(03): research phase domain
Phase 03: Operational Modules
- Standard stack identified (no new dependencies)
- Architecture patterns documented (10 patterns from codebase)
- Pitfalls catalogued (8 pitfalls from direct code inspection)
2026-03-05 06:20:41 +08:00

617 lines
31 KiB
Markdown

# Phase 3: Operational Modules - Research
**Researched:** 2026-03-05
**Domain:** Multi-module operational backend — collector workflows, ticketing, job orders, technician compensation. Built on top of the established Phase 2 Prisma/Next.js/TypeScript stack.
**Confidence:** HIGH (based on direct codebase inspection of established patterns, not external sources)
## Summary
Phase 3 adds five operational modules to an existing, well-established codebase. All architecture decisions are already locked through prior discussions (see CONTEXT.md). The research focus is on correctly extending the existing patterns — not on choosing libraries or frameworks.
The codebase uses: Next.js 14 App Router API routes, Prisma ORM with PostgreSQL, a tenant-scoped client (`withTenantContext`), CASL for RBAC, `JournalEntryService` as the sole accounting gateway, and Vitest with live PostgreSQL for integration tests. Every new module in Phase 3 must follow these established conventions exactly.
The five sub-plans are: zone management (03-01), collector field collection (03-02), ticketing system (03-03), job order workflow (03-04), and technician compensation (03-05). Plans 03-01 and 03-03 are wave 1 (no inter-plan dependencies), 03-02 and 03-04 are wave 2 (depend on wave 1), and 03-05 is wave 3 (depends on 03-04).
**Primary recommendation:** Treat the existing codebase as the specification. Copy patterns from `PaymentService`, `JournalEntryService`, and `payment.test.ts` exactly — do not invent new patterns. Every new model needs a corresponding block in `prisma-tenant.ts`, every new API route uses `withPermission()`, every new service receives `tenantPrisma` as first arg.
## Standard Stack
No new libraries required for Phase 3. All tools are already installed and in use.
### Core (already installed)
| Library | Version | Purpose | Status |
|---------|---------|---------|--------|
| Next.js | 14 (App Router) | API routes, server components | Already in use |
| Prisma | Current | ORM, migrations, schema | Already in use |
| PostgreSQL | Current | Database | Already in use |
| TypeScript | Current | Type safety | Already in use |
| `@casl/ability` | Current | RBAC permission checks | Already in use |
| Vitest | Current | Integration test runner | Already in use |
| `bcryptjs` | Current | Password hashing (not needed for Phase 3) | Already in use |
### No New Dependencies
Phase 3 introduces no new npm packages. All functionality is implemented using the existing stack.
**Installation:** None required.
## Architecture Patterns
### Recommended Project Structure for Phase 3
```
prisma/
└── schema.prisma # Add: Zone, ZoneAssignment, Collection, Remittance,
# Ticket, TicketCategory, JobOrder,
# TechnicianProfile, JobTypeRate
# Update: Subscriber (zoneId FK), User (new relations)
src/lib/
├── prisma-tenant.ts # Add tenant-scoped blocks for ALL new models
├── tenant.ts # Update: seed ticket categories on tenant creation
├── accounting/
│ ├── chart-of-accounts.ts # Add: 1030 Cash in Transit (for 03-02)
│ └── seed-coa.ts # Update if needed
└── services/
├── zone-service.ts # NEW (03-01)
├── collector-service.ts # NEW (03-02)
├── remittance-service.ts # NEW (03-02)
├── collection-report-service.ts # NEW (03-02)
├── ticket-category-service.ts # NEW (03-03)
├── ticket-service.ts # NEW (03-03)
├── job-order-service.ts # NEW (03-04)
├── technician-service.ts # NEW (03-05)
└── compensation-service.ts # NEW (03-05)
src/app/api/
├── zones/route.ts # GET, POST (03-01)
├── zones/[id]/route.ts # GET, PUT (03-01)
├── zones/[id]/subscribers/route.ts # POST, DELETE (03-01)
├── collectors/[id]/subscribers/route.ts # GET (03-01)
├── collections/route.ts # GET, POST (03-02)
├── collections/[id]/route.ts # GET (03-02)
├── collections/[id]/void/route.ts # POST (03-02)
├── remittances/route.ts # GET, POST (03-02)
├── remittances/[id]/verify/route.ts # POST (03-02)
├── reports/collections/route.ts # GET (03-02)
├── tickets/route.ts # GET, POST (03-03)
├── tickets/[id]/route.ts # GET, PUT (03-03)
├── tickets/[id]/status/route.ts # POST (03-03)
├── tickets/[id]/job-orders/route.ts # POST (03-04)
├── ticket-categories/route.ts # GET, POST (03-03)
├── ticket-categories/[id]/route.ts # PUT (03-03)
├── job-orders/route.ts # GET (03-04)
├── job-orders/[id]/route.ts # GET, PUT (03-04)
├── job-orders/[id]/status/route.ts # POST (03-04)
├── technicians/route.ts # GET, POST (03-05)
├── technicians/[id]/route.ts # GET, PUT (03-05)
├── technicians/[id]/compensation/route.ts # GET (03-05)
├── job-type-rates/route.ts # GET, POST (03-05)
├── job-type-rates/[id]/route.ts # PUT (03-05)
└── reports/compensation/route.ts # GET (03-05)
src/lib/__tests__/
├── zone-service.test.ts # NEW (03-01)
├── collector-service.test.ts # NEW (03-02)
├── remittance-service.test.ts # NEW (03-02)
├── ticket-service.test.ts # NEW (03-03)
├── job-order-service.test.ts # NEW (03-04)
└── compensation-service.test.ts # NEW (03-05)
```
### Pattern 1: Tenant-Scoped Service Functions
All services receive `tenantPrisma` (the return value of `withTenantContext(tenantId)`) as their first argument. No class instances, no constructors — pure functions.
```typescript
// Source: src/lib/services/payment-service.ts (established pattern)
export async function recordPayment(
tenantPrisma: TenantPrismaClient,
tenantId: string,
input: RecordPaymentInput
): Promise<RecordPaymentResult> {
// tenantPrisma auto-injects tenantId into all queries
// tenantId is passed explicitly for $transaction callbacks (raw client loses extension)
}
```
### Pattern 2: New Models in TENANT_SCOPED_MODELS
Every new Prisma model that includes a `tenantId` field MUST be added to `TENANT_SCOPED_MODELS` in `prisma-tenant.ts` AND given a full query-extension block. The block is verbose but must be copied exactly — findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, create, createMany, update, updateMany, delete, deleteMany, upsert, count, aggregate, groupBy.
```typescript
// Source: src/lib/prisma-tenant.ts (established pattern)
export const TENANT_SCOPED_MODELS = [
"user", "account", /* ... existing ... */,
"zone", "zoneAssignment", // 03-01
"collection", "remittance", // 03-02
"ticket", "ticketCategory", // 03-03
"jobOrder", // 03-04
"technicianProfile", "jobTypeRate", // 03-05
] as const;
```
### Pattern 3: API Route Authorization
All API routes use the `withPermission(action, subject)` HOF from `src/lib/middleware/authorize.ts`. CASL subjects (`Ticket`, `JobOrder`) are already declared in `src/lib/casl/types.ts`. No changes needed to the types file.
```typescript
// Source: src/app/api/payments/route.ts (established pattern)
export const POST = withPermission("create", "Payment")(
async (req: NextRequest, { user, ability }) => {
if (!user.tenantId) {
return NextResponse.json({ error: "No tenant context" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
// ... service calls ...
}
);
```
For dynamic [id] routes, `withPermission` wraps the inner handler and the dynamic param is destructured from the Next.js context (second argument from Next.js, not the CASL context). Pattern from existing code:
```typescript
// Dynamic route pattern — the id comes from Next.js route params
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
return withPermission("read", "Ticket")(
async (req, { user }) => {
const { id } = params;
// ...
}
)(req);
}
```
### Pattern 4: $transaction and tenantId Explicit Injection
Inside a `tenantPrisma.$transaction(async (tx) => { ... })` callback, the `tx` object is a raw Prisma client WITHOUT the tenant extension. All model operations inside the transaction MUST include `tenantId` explicitly in the data/where clauses.
```typescript
// Source: src/lib/services/payment-service.ts (established pattern)
const result = await tenantPrisma.$transaction(async (tx: TenantPrismaClient) => {
await tx.payment.create({
data: {
tenantId, // REQUIRED — tx lacks the extension that auto-injects this
subscriberId,
// ...
}
});
});
```
### Pattern 5: JournalEntryService for All Financial Events
The JournalEntryService is the SOLE gateway to the accounting ledger. The collector remittance verification (03-02) is the only Phase 3 financial event that requires a JE.
```typescript
// Source: src/lib/accounting/journal-entry-service.ts
const journalEntry = await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: remittanceDate,
description: `Remittance verified: ${remittance.id}`,
source: JournalEntrySource.SYSTEM, // SYSTEM = auto-POSTED
referenceType: "Remittance",
referenceId: remittanceId,
createdById: verifiedById,
lines: [
{ accountId: cashOnHandAccount.id, debit: verifiedTotal, credit: 0 },
{ accountId: cashInTransitAccount.id, debit: 0, credit: verifiedTotal },
],
});
```
### Pattern 6: Sequential Number Generation
Ticket numbers (TKT-NNNN), job order numbers (JO-NNNN) follow the same pattern as invoice numbers (INV-NNNN) and journal entry numbers (JE-YYYY-NNNN). Use the tenant-scoped client with startsWith query, order by desc, take 1, increment. No database sequence — application-level with the tenant filter providing isolation.
```typescript
// Source: src/lib/accounting/journal-entry-service.ts (generateEntryNumber helper)
// Adapt: "TKT-" prefix, no year segment, 4-digit zero-padded sequence
async function generateTicketNumber(tenantPrisma): Promise<string> {
const existing = await tenantPrisma.ticket.findMany({
where: { ticketNumber: { startsWith: "TKT-" } },
select: { ticketNumber: true },
orderBy: { ticketNumber: "desc" },
take: 1,
});
const nextNumber = existing.length === 0
? 1
: parseInt(existing[0].ticketNumber.slice(4), 10) + 1;
return `TKT-${String(nextNumber).padStart(4, "0")}`;
}
```
### Pattern 7: Integration Test Setup
Tests use a live PostgreSQL database. Setup creates tenant(s) + admin user + seeds COA in `beforeAll`. Cleanup runs in `afterAll` in reverse dependency order (children before parents).
```typescript
// Source: src/lib/__tests__/payment.test.ts (established pattern)
import { prisma } from "@/lib/prisma";
import { withTenantContext } from "@/lib/prisma-tenant";
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
const TS = Date.now(); // Unique suffix per test run to avoid conflicts
let tenantId: string;
let adminUserId: string;
beforeAll(async () => {
// Create tenant, user, seed COA
// Capture IDs for test use
});
afterAll(async () => {
// Delete in reverse dependency order
// Newer models first, Tenant last
});
```
### Pattern 8: PaymentService FIFO Reuse for Collector Collections
Collector collections (03-02) reuse the exact same FIFO allocation logic as PaymentService but with account 1030 (Cash in Transit) as the debit instead of 1010/1020. The cleanest approach is either:
- Option A: Extract FIFO allocation into a shared helper and call from both PaymentService and CollectorService
- Option B: Call `recordPayment` with a parameter to override the cash account code
The plan specifies Option B-style: CollectorService handles its own payment creation using the FIFO pattern, debiting 1030 instead of 1010. The key implementation note is that `recordPayment` in PaymentService looks up the cash account by code ("1010" or "1020"). CollectorService simply needs to perform the same lookup but with "1030" as the account code.
### Pattern 9: Collector Balance Derivation (No Stored Field)
Per the roadmap decision: collector balances are NEVER stored. They are always derived by querying the transaction log:
```typescript
// Derived from transactions — not a stored field
// collectedTotal = SUM(collection.amount WHERE collectorId = X AND date = D)
// remittedTotal = SUM(remittance.verifiedTotal WHERE collectorId = X AND date = D AND status = VERIFIED)
// variance = collectedTotal - remittedTotal
```
### Pattern 10: Auto-Resolve Ticket on All Job Orders Complete
When `updateStatus(COMPLETED)` is called on a job order in 03-04, the service must call `checkTicketAutoResolve`. The auto-resolve check loads all non-cancelled job orders for the ticket and resolves if ALL are COMPLETED.
```typescript
async function checkTicketAutoResolve(tenantPrisma, ticketId) {
const jobOrders = await tenantPrisma.jobOrder.findMany({
where: { ticketId, status: { not: 'CANCELLED' } },
select: { status: true },
});
if (jobOrders.length > 0 && jobOrders.every(j => j.status === 'COMPLETED')) {
await resolveTicket(tenantPrisma, ticketId);
}
// Edge case: if ALL job orders are CANCELLED (none completed), do NOT auto-resolve
}
```
### Anti-Patterns to Avoid
- **Storing derived balances:** Never add a `collectedBalance` or `remittedBalance` field to User/Collector. Always derive from Collection and Remittance records.
- **Writing to JournalEntry/JournalEntryLine directly:** All accounting writes MUST go through `JournalEntryService.createEntry`. The collector remittance verification JE is no exception.
- **Forgetting tenantId inside $transaction:** The `tx` callback receives a raw client without the extension. Always pass `tenantId` explicitly in create/update data inside transactions.
- **Omitting tenantId model from prisma-tenant.ts:** If a new model has a tenantId field but is not added to `TENANT_SCOPED_MODELS` with a full query extension block, queries will NOT be tenant-scoped — a security hole.
- **Allowing invalid status transitions:** Both TicketService and JobOrderService must throw on invalid transitions (e.g., CLOSED -> OPEN, COMPLETED -> IN_PROGRESS). Define transition guard maps and validate in the service before updating.
- **Blocking remittance on variance:** Per the locked decision, variance does NOT block remittance. Record the variance, post the JE with the verified amount, and complete the remittance regardless.
- **Storing skills as relations:** Technician skills are stored as `String[]` (PostgreSQL array), not as a separate Skills table. Simple and sufficient for this domain.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| FIFO payment allocation | Custom FIFO | Reuse/adapt PaymentService pattern | Already tested, handles edge cases |
| Accounting journal entries | Direct model writes | `JournalEntryService.createEntry()` | Enforces double-entry, period checks |
| Tenant data isolation | Custom query wrappers | `withTenantContext(tenantId)` | Already handles all Prisma operations |
| RBAC enforcement | Custom role checks | `withPermission(action, subject)` HOF | Consistent, CASL-backed |
| Sequential numbering | DB sequences | App-level pattern (same as JE/Invoice) | Already established, works with tenant scoping |
| Permission definitions | New CASL subjects | Ticket, JobOrder already in `types.ts` | Already declared in Phase 1 |
**Key insight:** The Phase 2 codebase is the template. Every Phase 3 feature is an extension of an existing pattern, not a new invention.
## Common Pitfalls
### Pitfall 1: Forgetting tenantId Inside Transactions
**What goes wrong:** Code works for reads (tenant extension auto-injects tenantId) but fails or leaks data on writes inside `$transaction`.
**Why it happens:** The `$transaction` callback receives a raw Prisma client (the `tx` parameter), not the extended `tenantPrisma`. The `withTenantContext` extension is NOT available inside the transaction callback.
**How to avoid:** Always pass `tenantId` explicitly in `data:` for create operations and in `where:` for update/delete operations inside any `$transaction` callback.
**Warning signs:** Tests passing for single-tenant scenarios but failing on cross-tenant isolation tests.
### Pitfall 2: Subscription Zone Field Replacement
**What goes wrong:** Subscriber model currently has `zone String?` (a plain string placeholder). Plan 03-01 replaces this with `zoneId String?` (a proper FK to Zone). The migration must DROP the old column and ADD the new FK column.
**Why it happens:** The `zone String?` field was a Phase 3 placeholder. It needs to be replaced, not supplemented.
**How to avoid:** The migration for `add-zones` must handle both the new Zone/ZoneAssignment models AND the Subscriber field replacement. Test by running `npx prisma migrate dev` after schema changes. No data migration is needed (no production data).
**Warning signs:** Prisma migration fails with constraint errors, or TypeScript types include both `zone` and `zoneId`.
### Pitfall 3: Missing TENANT_SCOPED_MODELS Registration
**What goes wrong:** A new model (Zone, Ticket, JobOrder, etc.) is created in the schema but not registered in `prisma-tenant.ts`. All queries bypass tenant filtering.
**Why it happens:** It's easy to forget the `prisma-tenant.ts` step when focused on the service/API implementation.
**How to avoid:** Add the model name to `TENANT_SCOPED_MODELS` and write the full query extension block as the first step of any schema task. Add a cross-tenant isolation test that explicitly verifies the new model is tenant-scoped.
**Warning signs:** Tenant isolation tests fail — tenant B can read tenant A's records.
### Pitfall 4: Collector JE Debits Wrong Cash Account
**What goes wrong:** Collector collections debit 1010 Cash on Hand (the same as a direct office payment) instead of 1030 Cash in Transit.
**Why it happens:** Calling `PaymentService.recordPayment` without modification would use 1010/1020 based on PaymentMethod. Collector collections should ALWAYS debit 1030.
**How to avoid:** CollectorService must NOT call `PaymentService.recordPayment` unmodified. It must implement its own JE creation with 1030 as the debit account, or adapt the pattern to pass the correct account. The correct flow: DR 1030 Cash in Transit, CR 1100 AR (when collector collects). Then on verified remittance: DR 1010 Cash on Hand, CR 1030 Cash in Transit.
**Warning signs:** Journal entries for collector collections show debit on 1010 instead of 1030. The trial balance will show inflated Cash on Hand before remittance verification.
### Pitfall 5: Ticket Auto-Resolve Race Condition
**What goes wrong:** Concurrent completion of the last two job orders on a ticket could each see "all others COMPLETED" and both call `resolveTicket`, resulting in a double-update.
**Why it happens:** The `checkTicketAutoResolve` read-then-write is not atomic.
**How to avoid:** Wrap the check-and-resolve in a `$transaction`. Or, given the low concurrency of ISP operations, accept the risk and ensure `resolveTicket` is idempotent (updating RESOLVED -> RESOLVED is a no-op). The simpler approach is idempotent resolution — if ticket is already RESOLVED when `resolveTicket` is called, return silently.
**Warning signs:** Tests with parallel job order completion fail or cause duplicate RESOLVED transitions.
### Pitfall 6: Compensation Calculation With Missing JobTypeRate
**What goes wrong:** A job order is COMPLETED for a job type with no configured rate in JobTypeRate. The compensation calculation throws instead of treating the missing rate as zero.
**Why it happens:** Naive implementation might throw on null rate lookup.
**How to avoid:** When no rate exists for a job type, treat the per-job bonus for that type as 0 (not an error). Build the rate lookup as a Map and default to 0 for missing entries.
**Warning signs:** Compensation tests for job types without configured rates fail with errors instead of returning 0 bonus.
### Pitfall 7: Deactivated Ticket Category Used on New Tickets
**What goes wrong:** Creating a ticket with a categoryId that points to a deactivated (isActive=false) category.
**Why it happens:** The API validates that the category exists but not that it's active.
**How to avoid:** In `TicketService.createTicket`, validate that the category exists AND `isActive = true` before creating the ticket.
**Warning signs:** Tests for "deactivated category cannot be used" fail.
### Pitfall 8: prisma-tenant.ts Block Incompleteness
**What goes wrong:** A new model's extension block is missing some operations (e.g., `groupBy`, `aggregate`) that are used in report queries.
**Why it happens:** Copying a block from a simpler model (like `invoiceLine` which only has a few operations) and not adding all operations.
**How to avoid:** Copy the full block from a comprehensive model like `subscriber` or `payment` which has all operations. The reports in 03-02 (`getDailyCollectionSummary`) and 03-05 (`getCompensationSummary`) likely use `aggregate` and/or `groupBy`.
**Warning signs:** TypeScript errors on missing methods, or runtime errors when calling aggregate/groupBy on models that don't have those operations registered.
## Code Examples
Verified patterns from existing codebase:
### Adding a New Model to TENANT_SCOPED_MODELS (prisma-tenant.ts)
```typescript
// Source: src/lib/prisma-tenant.ts — follow the subscriber block as the full template
// All 12 operations are needed for models used in reports (aggregate, groupBy)
zone: {
async findMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirst({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findFirstOrThrow({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async findUnique({ args, query }) {
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
return prisma.zone.findFirst({ ...args, where: { ...args.where, tenantId } });
}
return query(args);
},
async findUniqueOrThrow({ args, query }) {
if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) {
const result = await prisma.zone.findFirst({ ...args, where: { ...args.where, tenantId } });
if (!result) throw new Error("Record not found");
return result;
}
return query(args);
},
async create({ args, query }) {
args.data = { ...args.data, tenantId } as typeof args.data;
return query(args);
},
async update({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async updateMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async delete({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async deleteMany({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async count({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async aggregate({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
async groupBy({ args, query }) {
args.where = { ...args.where, tenantId };
return query(args);
},
},
```
### Collector JE Pattern (1030 Cash in Transit)
```typescript
// Source: derived from src/lib/services/payment-service.ts pattern
// Collection records (collector collects from subscriber):
// DR 1030 Cash in Transit (amount received)
// CR 1100 Accounts Receivable (AR reduced)
// Remittance verification (office counts and verifies):
// DR 1010 Cash on Hand (verified amount)
// CR 1030 Cash in Transit (cash moved from transit to hand)
const journalEntry = await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: verifiedAt,
description: `Remittance verified by ${verifiedByName}`,
source: JournalEntrySource.SYSTEM,
referenceType: "Remittance",
referenceId: remittanceId,
createdById: verifiedById,
lines: [
{ accountId: cashOnHandId, debit: verifiedTotal, credit: 0,
description: "Cash on Hand (office count)" },
{ accountId: cashInTransitId, debit: 0, credit: verifiedTotal,
description: "Cash in Transit (collector remittance)" },
],
});
```
### Compensation Calculation Pattern
```typescript
// Source: derived from compensation model decisions in CONTEXT.md
// compensationModel: PER_JOB | SALARY | HYBRID
// baseSalary: monthlySalary if SALARY/HYBRID else 0
// jobBonusTotal: sum of rates for completed job orders
const rateMap = new Map(
jobTypeRates.map(r => [r.jobType, new Prisma.Decimal(r.rate)])
);
let jobBonusTotal = new Prisma.Decimal(0);
for (const job of completedJobs) {
const rate = rateMap.get(job.jobType) ?? new Prisma.Decimal(0); // Missing rate = 0
jobBonusTotal = jobBonusTotal.plus(rate);
}
const baseSalary = (profile.compensationModel === 'SALARY' || profile.compensationModel === 'HYBRID')
? new Prisma.Decimal(profile.monthlySalary ?? 0)
: new Prisma.Decimal(0);
const totalCompensation = baseSalary.plus(jobBonusTotal);
```
### Status Transition Guard Pattern
```typescript
// Source: derived from existing pattern conventions in this codebase
// Define valid transitions as a map and validate before updating
const VALID_TICKET_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = {
OPEN: ['ASSIGNED', 'CLOSED'],
ASSIGNED: ['OPEN', 'RESOLVED'],
RESOLVED: ['CLOSED', 'OPEN'],
CLOSED: [], // Terminal state — no transitions out
};
async function transitionTicketStatus(tenantPrisma, ticketId, newStatus, ...) {
const ticket = await tenantPrisma.ticket.findFirst({ where: { id: ticketId } });
if (!VALID_TICKET_TRANSITIONS[ticket.status].includes(newStatus)) {
throw new Error(`Invalid transition: ${ticket.status} -> ${newStatus}`);
}
// ... perform update
}
```
## State of the Art
These patterns are specific to this codebase — not general ecosystem choices.
| Old Approach | Current Approach | Impact |
|--------------|-----------------|--------|
| `zone String?` on Subscriber | `zoneId String?` FK to Zone model | 03-01 must replace, not supplement |
| No 1030 account | 1030 Cash in Transit added in 03-02 | Must add to chart-of-accounts.ts and seed-coa.ts |
| No ticket categories | Admin-configurable with 6 ISP defaults | Seeded in tenant creation (tenant.ts) |
**Pre-existing Phase 3 preparations in the schema:**
- `zone String?` on Subscriber: Phase 3 placeholder, replaced by `zoneId` FK in 03-01
- `Ticket` and `JobOrder` CASL subjects: already declared in `src/lib/casl/types.ts`
- `Technician Compensation` (5020) and `Salary Expense` (5010): already in COA
## Open Questions
1. **Collector zone scoping strictness**
- What we know: Collector can only collect from subscribers in their assigned zones
- What's unclear: Should `recordCollection` throw if the subscriber is not in ANY of the collector's zones? Or is this a soft warning?
- Recommendation: Throw with a clear error message ("Subscriber not in your assigned zones"). This is a security boundary, not a UX suggestion.
2. **Ticket priority SLA implementation**
- What we know: Ticket priority levels are LOW, MEDIUM, HIGH, URGENT (defined in CONTEXT.md via plan 03-03)
- What's unclear: Whether SLA timers or escalation rules are needed in Phase 3 or deferred
- Recommendation: No SLA timers in Phase 3. Priority is a display/filter field only. SLAs can be added in Phase 5 if needed. Marked as "Claude's Discretion" in CONTEXT.md.
3. **Job order state machine: ASSIGNED vs OPEN after first job order**
- What we know: Creating the first job order on an OPEN ticket auto-transitions ticket to ASSIGNED
- What's unclear: If that first job order is cancelled and no other exists, should ticket revert to OPEN?
- Recommendation: Add `checkTicketRevertToOpen` logic symmetric to `checkTicketAutoResolve` — if all job orders on a ticket are CANCELLED (none pending/in-progress/completed), revert ticket to OPEN. This avoids stuck ASSIGNED state.
4. **Report query performance**
- What we know: Daily collection summary and compensation summary aggregate across potentially large datasets
- What's unclear: Whether Prisma's `groupBy` and `aggregate` are sufficient or if raw SQL is needed
- Recommendation: Use Prisma aggregate queries for Phase 3. The dataset will be small for ISP scale. If performance issues arise in Phase 5+, optimize with raw SQL. Marked as "Claude's Discretion" in CONTEXT.md.
## Sources
### Primary (HIGH confidence)
All findings are based on direct inspection of the NetForge codebase:
- `prisma/schema.prisma` — all existing models, enums, relations, field conventions
- `src/lib/prisma-tenant.ts` — TENANT_SCOPED_MODELS, withTenantContext pattern, full extension block structure
- `src/lib/services/payment-service.ts` — service function pattern, FIFO allocation, $transaction with explicit tenantId
- `src/lib/accounting/journal-entry-service.ts` — JournalEntryService gateway pattern, sequential number generation
- `src/lib/accounting/chart-of-accounts.ts` — COA structure, account code ranges, existing accounts
- `src/lib/middleware/authorize.ts` — withPermission HOF, AuthorizedContext interface
- `src/lib/casl/types.ts` — existing AppSubjects (Ticket, JobOrder already declared)
- `src/lib/casl/permissions.ts` — role-based permission matrix, existing COLLECTOR/TECHNICIAN rules
- `src/lib/tenant.ts` — createTenant transaction, seedChartOfAccounts integration point
- `src/lib/__tests__/payment.test.ts` — test setup pattern, beforeAll/afterAll, cleanup order
- `.planning/phases/03-operational-modules/03-CONTEXT.md` — all implementation decisions
- `.planning/phases/03-operational-modules/03-01-PLAN.md` through `03-05-PLAN.md` — existing detailed plans
### Secondary (MEDIUM confidence)
- Phase 2 commit history (reviewed via git log) confirms patterns are stable and consistently applied across all Phase 2 work.
### Tertiary (LOW confidence)
None — all research based on direct codebase inspection.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — confirmed by package.json and existing code
- Architecture: HIGH — patterns confirmed by reading actual implementation files
- Pitfalls: HIGH — derived from direct reading of existing code and constraints in CONTEXT.md
- Code examples: HIGH — examples are adaptations of existing working code
**Research date:** 2026-03-05
**Valid until:** This research is specific to the codebase at commit `e6e09bd`. Valid until schema or patterns change significantly. Estimated 90 days for this stable pattern set.