From 9af86c54c3cdf61a67a11b4603c984d7efcb5195 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 06:20:41 +0800 Subject: [PATCH] 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) --- .../03-operational-modules/03-RESEARCH.md | 616 ++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 .planning/phases/03-operational-modules/03-RESEARCH.md diff --git a/.planning/phases/03-operational-modules/03-RESEARCH.md b/.planning/phases/03-operational-modules/03-RESEARCH.md new file mode 100644 index 0000000..b5b2815 --- /dev/null +++ b/.planning/phases/03-operational-modules/03-RESEARCH.md @@ -0,0 +1,616 @@ +# 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 { + // 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 { + 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 = { + 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.