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

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

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 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:

// 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 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)

// 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 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.