Files
NetForge/.planning/STATE.md
kevin-asprec bdd0fda7d2 docs(03-04): complete Job Orders plan
Tasks completed: 2/2
- Task 1: JobOrder schema, migration, and tenant scoping
- Task 2: Job order service, API routes, and integration tests

SUMMARY: .planning/phases/03-operational-modules/03-04-SUMMARY.md
2026-03-05 08:05:50 +08:00

13 KiB

Project State

Project Reference

See: .planning/PROJECT.md (updated 2026-03-04)

Core value: ISP owners can see exactly where their money is — who owes what, what's been collected, what's been spent, and what the business actually looks like financially — in real time. Current focus: Phase 3 - Operational Modules

Current Position

Phase: 3 of 5 (Operational Modules) — In progress Plan: 5 of 5 in phase 3 (16/20 total complete) Status: Phase 3 in progress. 03-01 (Zones), 03-02 (Collections/Remittances), 03-03 (Ticketing), 03-04 (Job Orders) complete. Last activity: 2026-03-05 — Completed 03-04-PLAN.md (Job Orders — 17 tests, bidirectional ticket sync, PENDING/IN_PROGRESS/COMPLETED/CANCELLED lifecycle)

Progress: [████████████████░] 80% (16/20 plans across all phases)

Performance Metrics

Velocity:

  • Total plans completed: 10
  • Average duration: 9.5 min
  • Total execution time: 95 min

By Phase:

Phase Plans Total Avg/Plan
01-foundation 5/5 complete 41 min 8.2 min
02-subscriber-and-billing-core 5/5 complete 57 min 11.4 min
03-operational-modules 4/5 in progress ~56 min ~14 min

Recent Trend:

  • Last 10 plans: 01-04 (7 min), 01-05 (6 min), 02-01 (7 min), 02-03 (7 min), 02-02 (16 min), 02-04 (12 min), 02-05 (8 min), 03-01 (15 min), 03-02 (11 min), 03-04 (6 min)
  • Trend: stable — 03-04 migration applied cleanly, 17 tests passed on second run (1 fix for FK cleanup ordering)

Updated after each plan completion

Accumulated Context

Decisions

Decisions are logged in PROJECT.md Key Decisions table. Recent decisions affecting current work:

  • [Roadmap]: Accounting COA and JournalEntryService built in Phase 2 before first invoice — cannot be retrofitted
  • [Roadmap]: Inventory modeled as event-ledger (immutable movements) from Phase 4 — mutable quantity columns explicitly rejected
  • [Roadmap]: Collector balances derived from transaction log, never stored as mutable fields
  • [Roadmap]: PORT-05 (online payment) scaffolded in Phase 5 but payment gateway integration deferred to v2 per project out-of-scope decision
  • [01-01]: DATABASE_URL uses Docker service name db (for app container); DATABASE_URL_LOCAL uses localhost:5432 (for host Prisma CLI)
  • [01-01]: tenantId is nullable on User — super-admins have no tenant scope, avoiding a separate SuperAdmin model
  • [01-01]: Email uniqueness is @@unique([email, tenantId]) — same email can exist across different tenants (realistic for ISP domain)
  • [01-01]: Grace period fields (suspendedAt, gracePeriodEndsAt) included on Tenant at schema creation — cannot be retrofit later
  • [01-02]: NextAuth v4 chosen over v5/Auth.js beta — credentials provider stability priority
  • [01-02]: JWT carries tenantId + roles directly — no DB lookup on each request, stateless multi-tenancy
  • [01-02]: Super-admin authorize uses OR [isSuperAdmin, tenant.status=ACTIVE] — one Prisma query handles both user types
  • [01-02]: Seed uses findFirst+create for super-admin (null tenantId) — PostgreSQL NULL != NULL in unique constraints, upsert would create duplicates
  • [01-02]: SessionProvider wrapped at root layout via Providers component — enables useSession() in all client components
  • [01-03]: withTenantContext() creates new $extends per call — correct pattern, $extends is lightweight and request-scoped context is right
  • [01-03]: findUnique cross-tenant protection routes through findFirst internally — Prisma unique key cannot have tenantId injected without changing where shape
  • [01-03]: RLS USING allows null app.current_tenant_id — super-admin mode (no tenant context) sees all rows
  • [01-03]: Initial migration baselined with migrate resolve --applied (schema was created via db push in 01-01)
  • [01-04]: createMongoAbility used throughout (not PureAbility) — string subjects require conditionsMatcher which createMongoAbility provides built-in
  • [01-04]: cannot() rules excluded when merging multi-role abilities — additive union means more roles = more (never less) access
  • [01-04]: Condition objects cast via any for string subjects — CASL infers MongoQuery for strings; tighten when Prisma models defined in Phase 2+
  • [01-04]: Technician can("read", "Subscriber") coarse-grained — data layer enforces actual scope to assigned job contacts only
  • [01-04]: withPermission() HOF wraps Next.js route handlers; authorize() as convenience alias
  • [01-05]: withSuperAdmin() implemented as standalone HOF (not via CASL) — super-admin access is binary, not permission-based
  • [01-05]: Next.js 15 route params wrapped in Promise

    — HOF awaits params before passing to handler

  • [01-05]: subscriberCount hardcoded to 0 in admin API — Subscriber model added in Phase 2; API shape is forward-compatible
  • [01-05]: Dual guard strategy for /admin: middleware.ts (JWT edge), layout.tsx (server), API handlers (endpoint) — three defense-in-depth layers
  • [02-01]: ISP COA has 28 accounts (5 category headers 1000/2000/3000/4000/5000 + 23 leaf accounts) — hierarchical for reporting
  • [02-01]: Subscriber Credits (1150) is contra-asset with CREDIT normal balance — correctly reduces AR for overpayments
  • [02-01]: seedChartOfAccounts receives Prisma tx client — works inside createTenant $transaction for atomic provisioning
  • [02-01]: Accounting periods created on-demand via getOpenPeriod() — not pre-seeded on signup (no wasted periods for unused months)
  • [02-01]: close route uses closure pattern over withPermission HOF — withPermission doesn't support dynamic params directly; POST fn closes over Next.js params
  • [02-02]: JournalEntry self-referential reversal uses reversesEntryId @unique — one-to-one Prisma relation requires unique; semantically correct (one entry reverses at most one other)
  • [02-02]: tenantId passed explicitly in $transaction callbacks — raw tx client lacks the withTenantContext() extension; must inject tenantId manually in create data
  • [02-02]: startDate added to getAccountBalance — enables date-range balance queries; needed for period-scoped reporting and test isolation
  • [02-02]: Integer cents for debit=credit validation — Math.round(n*100) avoids floating point drift on decimal amounts
  • [02-02]: SYSTEM source auto-posts (POSTED status), MANUAL entries start DRAFT for maker-checker workflow
  • [02-02]: Self-approval allowed — single-person ISP operations are common; blocking self-approval breaks common use case
  • [02-02]: JournalEntryService is sole gateway — NO other code may write to JournalEntry/JournalEntryLine directly
  • [02-03]: creditBalance on Subscriber is operational convenience (FIFO credit allocation for 02-05), NOT a ledger balance — always updated atomically with journal entries
  • [02-03]: billingDay capped at 28 — subscribers signing up on days 29-31 get billingDay=28 to avoid month-length invoice generation issues
  • [02-03]: CANCELLED -> ACTIVE transition is reversible by design — ISPs frequently reinstate cancelled accounts per CONTEXT.md
  • [02-03]: as any cast in service create() calls — Prisma static type requires tenantId but withTenantContext() extension injects at runtime; cast is intentional
  • [02-04]: Invoice.amountPaid is transactional convenience field, NOT standalone stored balance — always updated atomically with JEs (same pattern as creditBalance from 02-03)
  • [02-04]: shouldBillToday PREPAID month-wrapping: actualLeadDay = lastDayOfCurrentMonth + (billingDay - leadDays) — uses current month's last day, not previous month's
  • [02-04]: generateInvoiceForSubscriber returns null (not error) for duplicates — idempotent by design; generateMonthlyInvoices tracks in skipped array
  • [02-04]: CreditService is standalone module — applyCredit() callable from BillingService (auto-apply) and PaymentService (02-05 overpayment)
  • [02-04]: Dynamic route handlers pattern: export function GET/POST(req, { params }) wrapping withPermission()(handler)(req) — required for Next.js 15 Promise params in [id] routes
  • [02-05]: PaymentAllocation as separate model — enables per-invoice allocation queries and void recalculation; each allocation row: paymentId + invoiceId + amount
  • [02-05]: FIFO by dueDate ASC — oldest due date allocated first (matches standard ISP billing practice)
  • [02-05]: Overpayment to subscriber.creditBalance atomically with JE — same pattern as invoice.amountPaid from 02-04
  • [02-05]: Outstanding report computed in JS after fetching — Prisma doesn't support computed fields in WHERE/ORDER BY; acceptable for ISP scale
  • [02-05]: Test invoiceCounter for periodStart uniqueness — monotonic counter generates unique periodStart per invoice, avoids @@unique([tenantId, subscriberId, periodStart]) in test helpers
  • [03-01]: Collector security boundary enforced at service layer: getCollectorSubscribers THROWS (not empty return) when collector has no zone assignments — zero-access default
  • [03-01]: ZoneAssignment upsert for idempotent collector assignment — duplicate assign calls don't throw errors
  • [03-01]: Subscriber.zone String? replaced with Subscriber.zoneId FK — required for relational queries and JOIN-based ordering
  • [03-01]: COLLECTOR gets can("read", "Zone") in CASL: coarse-grained gate, data layer enforces which specific zones
  • [03-01]: Migration applied via Docker exec psql + prisma migrate resolve --applied (non-interactive CLI workaround)
  • [03-03]: VALID_TICKET_TRANSITIONS guard map: OPEN->[ASSIGNED,CLOSED], ASSIGNED->[OPEN,RESOLVED], RESOLVED->[CLOSED,OPEN], CLOSED->[] (terminal)
  • [03-03]: resolveTicket is idempotent — checks if already RESOLVED and returns silently, preventing race conditions from multiple job completions
  • [03-03]: Ticket cleanup order in tests: tickets -> ticketCategories -> subscribers -> ... (categories seeded by createTenant must be deleted on teardown)
  • [03-03]: transitionTicketStatus is the single gateway for status changes — updateTicket explicitly excludes status field
  • [03-03]: 6 default ISP categories seeded in createTenant $transaction (No Connection, Slow Speed, Billing Inquiry, New Installation, Equipment Issue, Other)
  • [03-02]: Collection JE uses 1030 Cash in Transit (not 1010) — cash is in collector's hands until remitted to office
  • [03-02]: Remittance JE uses verifiedTotal on both DR 1010 and CR 1030 sides — variance is recorded on remittance record, not in ledger
  • [03-02]: Variance is non-blocking — any discrepancy is an audit record; remittance proceeds to VERIFIED regardless
  • [03-02]: Collection cleanup order: collectionAllocations → collections → invoiceLines → invoices → journalEntryLines → null reversesEntryId → journalEntries → zoneAssignments → subscribers → zones → servicePlans → accountingPeriods → accounts → users → tenant
  • [03-02]: ISP COA now has 29 accounts — added 1030 Cash in Transit between 1020 Cash in Bank and 1100 AR
  • [03-04]: checkTicketAutoResolve counts non-cancelled jobs: if count > 0 AND all COMPLETED -> resolve; if count == 0 (all cancelled) -> skip (revertToOpen handles that path)
  • [03-04]: checkTicketRevertToOpen only triggers on ASSIGNED tickets — RESOLVED/CLOSED tickets not reverted even if all jobs are cancelled
  • [03-04]: COMPLETED requires outcomeNotes validated at service layer (not API) — enforces completeness regardless of caller
  • [03-04]: TECHNICIAN self-service via getMyJobOrders delegates to listJobOrders with assignedToId filter; GET /api/job-orders checks !ADMIN && !OFFICE_STAFF for auto-filter to handle multi-role users

Pending Todos

None.

Blockers/Concerns

  • [Phase 1 research flag]: MikroTik RouterOS Node.js client library maintenance status is LOW confidence — verify node-routeros vs mikronode before implementing router integration (MikroTik integration is v2, but adapter interface should be planned)
  • [Phase 3 research flag]: Semaphore SMS API pricing/stability for 2026 is MEDIUM confidence — verify before any SMS work (SMS is v2, but abstraction layer design is relevant)
  • [01-04 note]: CASL condition types for string subjects use any cast — upgrade to class-based subjects when Phase 2 Prisma models (Subscriber, Invoice, etc.) are defined
  • [01-05 note]: /admin/tenants/[id] detail page is a stub link ("View") — detail view not implemented yet (out of Phase 1 scope)
  • [02-02 note]: JournalEntry afterAll cleanup needs explicit order (lines → null reversesEntryId → entries → tenant) — document for any future test that involves journal entries
  • [02-04 note]: Invoice cleanup order in tests: invoiceLines → invoices → journalEntryLines → null reversesEntryId → journalEntries → subscribers → servicePlans → tenantSettings → accountingPeriods → accounts → users → tenant
  • [02-05 note]: Payment cleanup order in tests: paymentAllocations → payments → invoiceLines → invoices → journalEntryLines → null reversesEntryId → journalEntries → subscribers → servicePlans → tenantSettings → accountingPeriods → accounts → users → tenant

Session Continuity

Last session: 2026-03-05T00:04:26Z Stopped at: Completed 03-04-PLAN.md (Job Orders — 17 tests, bidirectional ticket sync, lifecycle enforcement) Resume file: None