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)
31 KiB
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.
// 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.
// 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.
// 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:
// 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.
// 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.
// 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.
// 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).
// 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
recordPaymentwith 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:
// 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.
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
collectedBalanceorremittedBalancefield 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
txcallback receives a raw client without the extension. Always passtenantIdexplicitly 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_MODELSwith 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)
// 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)
// 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
// 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
// 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 byzoneIdFK in 03-01TicketandJobOrderCASL subjects: already declared insrc/lib/casl/types.tsTechnician Compensation(5020) andSalary Expense(5010): already in COA
Open Questions
-
Collector zone scoping strictness
- What we know: Collector can only collect from subscribers in their assigned zones
- What's unclear: Should
recordCollectionthrow 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.
-
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.
-
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
checkTicketRevertToOpenlogic symmetric tocheckTicketAutoResolve— if all job orders on a ticket are CANCELLED (none pending/in-progress/completed), revert ticket to OPEN. This avoids stuck ASSIGNED state.
-
Report query performance
- What we know: Daily collection summary and compensation summary aggregate across potentially large datasets
- What's unclear: Whether Prisma's
groupByandaggregateare 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 conventionssrc/lib/prisma-tenant.ts— TENANT_SCOPED_MODELS, withTenantContext pattern, full extension block structuresrc/lib/services/payment-service.ts— service function pattern, FIFO allocation, $transaction with explicit tenantIdsrc/lib/accounting/journal-entry-service.ts— JournalEntryService gateway pattern, sequential number generationsrc/lib/accounting/chart-of-accounts.ts— COA structure, account code ranges, existing accountssrc/lib/middleware/authorize.ts— withPermission HOF, AuthorizedContext interfacesrc/lib/casl/types.ts— existing AppSubjects (Ticket, JobOrder already declared)src/lib/casl/permissions.ts— role-based permission matrix, existing COLLECTOR/TECHNICIAN rulessrc/lib/tenant.ts— createTenant transaction, seedChartOfAccounts integration pointsrc/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.mdthrough03-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.