# Phase 14: /pax8 UI Surface - Research

**Researched:** 2026-07-11
**Domain:** Next.js App Router page + API routes over existing Postgres schema (no new external integration, no new library) — read-heavy list/detail UI plus a small admin-gated resolve mutation.
**Confidence:** HIGH (all claims below verified directly against the live codebase and a live Postgres query against this project's actual `pax8_*` tables — no external library research was needed for this phase)

## Summary

Phase 14 is a pure internal-consistency phase: every table, service, and UI primitive it needs already exists in this repo. There is no new library to evaluate and no external API to integrate — the work is (1) two or three new `app/api/pax8/*` route handlers doing parameterized `postgresClient` queries, (2) a new `app/pax8/page.tsx` composed from `DataTable` + `DetailModal` + shadcn `Tabs`, and (3) one new nav entry. `device-link-conflicts` (page + its two API routes) is a near-exact structural precedent for the review/resolve half of this phase and should be mirrored closely.

The one real risk in this phase is **not** schema or auth — it's `DetailModal.tsx`. Its "Formatted" tab is not a generic renderer: it hardcodes two field-group tables (`TICKET_GROUPS`, `COMPANY_GROUPS`) selected by sniffing specific keys in the data object (`'ticket_number' in data`, `'company_name' in data`), and every field type it knows how to render is a single scalar (date, bool, badge, phone, url, id). It has **no mechanism to render an array of subscription/cost-breakdown rows** — the exact content D-02/D-03 need front and center. CONTEXT.md's D-02 says "used as-is," but as verified below, rendering the per-company cost breakdown will require an additive extension to `DetailModal.tsx`, not just passing it data. This is flagged as Pitfall 1 and should be budgeted as a task, not discovered mid-implementation.

Live data was queried directly against the project's Postgres container to ground every join and count below (118 `pax8_companies`, 80 auto-matched, 38 open review rows [16 no-candidate / 22 ambiguous], 445 `pax8_subscriptions`, ~28.8K `pax8_order_items` with a company set). That data surfaced a second load-bearing pitfall: PAX8's "New Commerce Experience" subscriptions bill on **per-subscription anniversary cycles**, not a shared calendar month — so "the latest order_items period" is not one date, it's one date *per subscription*. Any cost-breakdown query that takes a single global `MAX(start_period)` will silently drop every subscription whose anniversary isn't the most recent one. See Pitfall 2.

**Primary recommendation:** Build `/pax8` as a client page with two `Tabs` panels ("Companies", "Needs Review") per D-04; back the Companies tab with `DataTable` querying `pax8_companies LEFT JOIN companies ON companies.id = pax8_companies.autotask_company_id`; back the drill-down with an extended `DetailModal` (new field-group branch + a new array-rendering path) fed by a per-company query that joins `pax8_subscriptions` (current state: qty/billing-term/status) to each subscription's *own* latest `pax8_order_items` row (windowed per `subscription_id`, not a single global cutoff) for the actual billed amount; back the Needs Review tab with a list+resolve pair that mirrors `device-link-conflicts` exactly, except the resolve transaction must write to **both** `pax8_companies` (autotask_company_id/match_confidence/match_method='manual'/matched_at) **and** `pax8_company_match_review` (resolved_at/resolved_by_user_id/resolved_to_company_id/resolution_note) — confirmed necessary by reading `pax8-company-matcher.ts`'s own re-scoring-eligibility guard (see Architecture Patterns).

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Company list + subscription summary | API / Backend (`app/api/pax8/companies`) | Frontend Server (page shell) | Aggregation/joins belong server-side; page just renders `DataTable` rows from the API response |
| Per-company cost breakdown | API / Backend (`app/api/pax8/companies/[id]/cost-breakdown` or embedded in company row detail fetch) | Browser (DetailModal render) | The windowed "latest period per subscription" join (Pitfall 2) is non-trivial SQL — must not be reimplemented client-side |
| Needs Review queue (list) | API / Backend (`app/api/pax8/company-matches`) | — | Mirrors `device-link-conflicts` route exactly: parameterized query, admin-gated |
| Resolve action (mutation) | API / Backend (`app/api/pax8/company-matches/[id]/resolve`) | Database (transaction) | Must be a single transaction touching two tables (`pax8_companies` + `pax8_company_match_review`) — see Architecture Patterns |
| Manual company search fallback (D-05) | Browser (client-side filter) | API / Backend (`/api/data/companies-list`, already exists) | Only ~242 active companies — fetch once, filter client-side; no new search endpoint needed |
| Nav entry | Browser (client component) | — | `components/navigation/app-navigation.tsx`'s `navigationItems` array; both desktop dropdown-menu and mobile Sheet consume the same array, so one edit covers both |
| View/resolve permission split | API / Backend (route-level guard) | — | `requireAuth()` (view) vs `requirePermission('admin','access')` (resolve) per D-07/D-08 — enforced in the route handler, not middleware |

## Project Constraints (from CLAUDE.md)

- No ORM — use `postgresClient.query()` with parameterized SQL, manual snake_case → camelCase transform in the route handler.
- No server actions — API routes called via `fetch()` from a `'use client'` page.
- No SWR/react-query/new state libraries — `useState`/`useEffect`/`fetch`, matching every other Pulse page.
- No Zod in API routes unless it "matters" — the resolve mutation is a good candidate for a small Zod schema (mirrors `device-link-conflicts/[id]/resolve/route.ts`'s existing `ResolveBody = z.object({...})` precedent exactly); the read-only list routes don't need it.
- Files kebab-case, components PascalCase-exported from kebab-case files, icons from `lucide-react`, toasts from `sonner`.
- New SQL is a new numbered migration, `IF NOT EXISTS` guarded — **this phase should need zero new migrations**; all tables/columns it reads already exist (091, 092, 093, 094, 095, 096). Confirm during planning that no new column is actually required before adding one.
- Every new API route must have an explicit `requireAuth()`/`requirePermission()` call (explicit CONTEXT.md directive, echoing 13-REVIEW.md CR-02 — the adjacent `/api/pax8/sync` route currently has **no** auth check at all; do not copy that route as a pattern).
- `/pax8` is not in `middleware.ts`'s `publicRoutes` list (confirmed) — it requires a session cookie by default; no middleware change needed, but do **not** add it to `publicRoutes`.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** Main company list uses `components/admin/DataTable.tsx`, not a bespoke card grid.
- **D-02:** Per-company cost breakdown is a drill-down via `components/admin/DetailModal.tsx` (formatted/raw tab pattern), not inline in the table row. Formatted tab: subscriptions (product name, quantity, billing term) + cost summary. Raw tab: underlying JSON.
- **D-03:** Cost breakdown is grouped **by subscription/product**, not a single total. Cost data must be joined from `pax8_order_items.pax8_company_id` (and `start_period`/`end_period`), never from `pax8_orders` (its `pax8_company_id` is always NULL).
- **D-04:** The flagged/ambiguous review queue is a **section/tab within `/pax8`** (e.g., "Companies" / "Needs Review" tabs), not a separate top-level route. Reuse `device-link-conflicts`'s card-list-with-resolve-action UI pattern, embedded as a tab.
- **D-05:** Resolution UI offers **both**: pick from top-3 stored candidates (`pax8_company_match_review.candidate_company_ids` + `match_confidences`) as primary path, plus a manual company search/picker as fallback (required for the zero-candidate case, useful even when candidates exist but none are correct).
- **D-06:** Resolving writes `resolved_at`, `resolved_by_user_id`, `resolved_to_company_id`, optional `resolution_note` — mirrors `device_link_review`'s resolve shape.
- **D-07:** Viewing `/pax8` requires only `requireAuth()` — any authenticated user.
- **D-08:** The resolve action requires `requirePermission('admin', 'access')` — matching `/api/admin/device-link-conflicts/[id]/resolve`'s pattern exactly. Every new route this phase adds must have an explicit auth call.
- **D-09:** A review row with an empty `candidate_company_ids` array shows the same review-section UI with an explicit empty state ("No suggested matches — search manually"), handled by the D-05 manual-search fallback, no special-cased flow.

### Claude's Discretion

- Exact tab/section labels, table column set, and DetailModal field layout.
- Whether "Needs Review" tab shows a count badge, and sort/filter options on the main company list.

### Deferred Ideas (OUT OF SCOPE)

None — discussion stayed within Phase 14's scope. PAX8 write access, seat-adjustment actions, and any general natural-language assistant over this data are already explicitly out of scope per PROJECT.md, not deferred from this discussion.
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| PAX8-12 | An admin can view flagged/ambiguous company matches and manually resolve them to the correct Autotask company | Architecture Patterns → "Needs Review tab + resolve transaction"; Code Examples → resolve route; verified schema for `pax8_company_match_review` and `pax8_companies` match columns |
| PAX8-13 | A new `/pax8` page lists PAX8 companies with their subscriptions and a cost breakdown | Architecture Patterns → company list query; Pitfall 1 (DetailModal extension) and Pitfall 2 (per-subscription latest period) directly govern correct implementation |
| PAX8-14 | The `/pax8` page surfaces flagged/ambiguous company matches (PAX8-11) for manual resolution | Same as PAX8-12 — this is the UI-surfacing half of the same review queue |
</phase_requirements>

## Standard Stack

No new packages. Everything needed is already in `package.json` and already imported by the precedents cited below.

### Core (reused, not new)
| Library | Version (installed) | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `@tanstack/react-table` (via `DataTable.tsx`) | 8.21.3 | Company list table | Existing wrapper, used by every other tabular list in Pulse |
| `pg` (via `postgresClient`) | 8.11.0 | All Postgres access | No-ORM convention |
| shadcn `Tabs`/`Dialog`/`Card`/`Badge`/`Select` | n/a (local components) | Page/section/modal chrome | Already vendored in `components/ui/` |
| `sonner` | 2.0.7 | Resolve success/error toasts | Matches `device-link-conflicts` page exactly |
| `lucide-react` | 0.562.0 | Icons (nav entry, review-tab alert icon, etc.) | Project convention |
| `zod` | 4.3.5 | Resolve-body validation | Matches `device-link-conflicts/[id]/resolve` exactly |

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `DataTable` for the company list | Bespoke card grid | Rejected by CONTEXT.md D-01 — no sorting/pagination/search for free, inconsistent with rest of app |
| Extending `DetailModal` | A bespoke `Dialog` built from scratch, matching the visual style only | Extending is more consistent with the rest of the codebase (one shared component, one place to fix bugs) but requires touching a file used by every ticket/company detail view in the app — must be additive-only (new branch, no changes to existing `TICKET_GROUPS`/`COMPANY_GROUPS` behavior). See Pitfall 1 for the concrete tradeoff analysis. |
| Client-side company search combobox (shadcn `Command`) | Server-side `ILIKE` search endpoint | Only ~242 active companies (verified: `companies-list` route has no pagination) — fetching once and filtering client-side is simpler and avoids a new endpoint; a `Command`+`Popover` combobox (shadcn primitives already vendored) is the natural UI for this |

**Installation:** none required.

## Package Legitimacy Audit

Not applicable — this phase introduces zero new npm packages. All libraries used are already installed and in active use elsewhere in the repo (confirmed via `package.json` and direct imports in `DataTable.tsx`, `DetailModal.tsx`, `device-link-conflicts/page.tsx`).

## Architecture Patterns

### System Architecture Diagram

```
Browser (manager, any authenticated user)
   │
   │  GET /pax8  (page load, Next.js page component)
   ▼
app/pax8/page.tsx  ('use client')
   │
   ├─ Tab "Companies" ──────────────────────────────────────────┐
   │     useEffect → fetch('/api/pax8/companies?...')           │
   │     └─ requireAuth() only (D-07)                           │
   │           SELECT pax8_companies LEFT JOIN companies         │
   │           ON companies.id = pax8_companies.autotask_company_id
   │           → DataTable renders rows                          │
   │           → onRowClick → fetch per-company cost detail       │
   │              GET /api/pax8/companies/[id]  (or embed in row) │
   │                 pax8_subscriptions JOIN pax8_products        │
   │                 + LATERAL latest pax8_order_items per        │
   │                   subscription_id (Pitfall 2)                │
   │           → DetailModal (EXTENDED, see Pitfall 1) renders    │
   │              Formatted tab: subscriptions table + cost sum   │
   │              Raw tab: raw_payload JSON                       │
   │                                                               │
   ├─ Tab "Needs Review" ────────────────────────────────────────┘
   │     useEffect → fetch('/api/pax8/company-matches')
   │     └─ requireAuth() only to VIEW (D-07) — resolve is gated
   │           SELECT pax8_company_match_review
   │           WHERE resolved_at IS NULL
   │           JOIN pax8_companies (name)
   │           + bulk-fetch candidate `companies` names
   │           → card list, top-3 candidates + manual search (D-05)
   │
   │  User picks a candidate OR searches manually → clicks "Link"
   ▼
POST /api/pax8/company-matches/[id]/resolve
   └─ requirePermission('admin','access') (D-08)
        BEGIN
          UPDATE pax8_companies
             SET autotask_company_id=$X, match_method='manual',
                 matched_at=NOW(), match_confidence=NULL
           WHERE id = $pax8CompanyId
          UPDATE pax8_company_match_review
             SET resolved_at=NOW(), resolved_by_user_id=$user,
                 resolved_to_company_id=$X, resolution_note=$note
           WHERE id = $reviewId AND resolved_at IS NULL
        COMMIT
   → both writes make the resolution visible in the Companies tab
     (via pax8_companies.autotask_company_id) AND permanent against
     future re-matching (via pax8-company-matcher.ts's eligibility
     guard, which excludes match_method='manual' AND rows with a
     resolved review — see below)
```

### Recommended Project Structure
```
app/
├── pax8/
│   └── page.tsx                              # NEW — Companies / Needs Review tabs
├── api/
│   └── pax8/
│       ├── sync/route.ts                      # existing — do not modify (Phase 13 scope)
│       ├── companies/
│       │   ├── route.ts                       # NEW — GET list (paginated, sortable, searchable)
│       │   └── [id]/route.ts                   # NEW — GET one company's subscriptions + cost breakdown
│       └── company-matches/
│           ├── route.ts                        # NEW — GET unresolved review queue
│           └── [id]/resolve/route.ts            # NEW — POST resolve (admin-gated)
components/
├── admin/
│   ├── DataTable.tsx                           # reused as-is
│   └── DetailModal.tsx                         # EXTENDED (additive) — see Pitfall 1
├── navigation/
│   └── app-navigation.tsx                      # EDITED — add one `navigationItems` entry
```

### Pattern 1: Company list query (D-01, D-03's join note)

`pax8_companies.autotask_company_id` (set by both the auto-matcher and — after this phase — the manual resolve action) is the **live source of truth** for "which Autotask company is this PAX8 company linked to." `pax8_company_match_review.resolved_to_company_id` is an audit trail of the resolution *event*, not a live join target — confirmed by reading `pax8-company-matcher.ts`'s re-scoring query, which checks `pax8_companies.match_method` and a `NOT EXISTS` against resolved review rows, never reads `resolved_to_company_id` to decide what to display. `[VERIFIED: codebase — lib/services/pax8-company-matcher.ts lines 216-231]`

```sql
-- Source: adapted from scripts/verify-pax8-orders-matching.ts's own join pattern
-- (lines 76-85), which already joins pax8_companies -> companies via
-- autotask_company_id in this exact shape and is live-verified against
-- real data (118 companies, 80 matched).
SELECT
  pc.id, pc.name, pc.status, pc.city, pc.state_or_province, pc.country,
  pc.autotask_company_id, pc.match_confidence, pc.match_method,
  c.company_name AS matched_company_name,
  (SELECT count(*) FROM pax8_subscriptions s
    WHERE s.pax8_company_id = pc.id AND s.is_deleted = false
      AND s.status = 'Active') AS active_subscription_count
FROM pax8_companies pc
LEFT JOIN companies c ON c.id = pc.autotask_company_id
WHERE pc.is_deleted = false
ORDER BY pc.name
LIMIT $1 OFFSET $2;
```

### Pattern 2: Per-company cost breakdown — subscriptions + latest-period actuals (D-02, D-03)

**Two data sources are needed, not one.** `pax8_subscriptions` gives the stable "what exists" view (quantity, billing term, status — matches D-02's formatted-tab spec directly). `pax8_order_items` gives the actual billed amount, which differs from `subscriptions.price × quantity` due to proration/discounts (live-verified: one sampled line had `unit_price=155.65, quantity=1` but `line_total=109.00` — a ~30% discount not visible on the subscription record itself).

```sql
-- Step 1: current subscriptions (the "what am I paying for" list)
SELECT s.id AS subscription_id, s.product_id, p.name AS product_name, p.sku,
       s.quantity, s.billing_term, s.status, s.price, s.partner_cost, s.currency
FROM pax8_subscriptions s
LEFT JOIN pax8_products p ON p.id = s.product_id
WHERE s.pax8_company_id = $1 AND s.is_deleted = false
ORDER BY p.name NULLS LAST;

-- Step 2: latest actually-billed line per subscription (see Pitfall 2 for
-- why this MUST be windowed per subscription_id, not a single MAX(start_period)
-- across the whole company).
SELECT DISTINCT ON (subscription_id)
       subscription_id, product_id, sku, description, item_type,
       start_period, end_period, quantity, unit_price, line_total,
       partner_cost, partner_cost_total
FROM pax8_order_items
WHERE pax8_company_id = $1 AND is_deleted = false
  AND subscription_id IS NOT NULL
ORDER BY subscription_id, start_period DESC;
```
Join Step 1 and Step 2 in application code (or a single query with a `LATERAL` join) keyed on `subscription_id`; fall back to `subscriptions.price * quantity` when no order-item match exists (verified: 3 of 436 distinct `subscription_id`s referenced by order items have no matching row in `pax8_subscriptions` — likely tombstoned/cancelled subscriptions still present in historical invoice data; these should still appear in the cost breakdown using the order-item data alone, with no `billing_term`/`status` from step 1).

`[VERIFIED: live DB query]` — sample counts and the discount example above were pulled directly from this project's Postgres container on 2026-07-11 (118 companies / 445 subscriptions / 28,826 order-items-with-company / 20,174 `item_type='subscription'` rows, all with `subscription_id` populated).

### Pattern 3: Needs Review list (D-04, D-05)

Directly mirrors `app/api/admin/device-link-conflicts/route.ts`'s shape — same bulk-fetch-candidates-in-one-query optimization applies (candidate ids are `bigint[]` referencing `companies`, exactly like `device_link_review.candidate_ci_ids` references `configuration_items`):

```typescript
// Source: pattern lifted from app/api/admin/device-link-conflicts/route.ts
const reviews = await postgresClient.query(
  `SELECT r.id::text, r.detected_at::text, r.candidate_company_ids,
          r.match_confidences,
          pc.id AS pax8_company_id, pc.name AS pax8_company_name
     FROM pax8_company_match_review r
     JOIN pax8_companies pc ON pc.id = r.pax8_company_id
    WHERE r.resolved_at IS NULL
    ORDER BY r.detected_at DESC
    LIMIT $1 OFFSET $2`,
  [limit, offset]
);
// Bulk-fetch all candidate company names in one query (same optimization
// device-link-conflicts uses for configuration_items):
const allCandidateIds = new Set<number>();
for (const r of reviews.rows) for (const id of r.candidate_company_ids ?? []) allCandidateIds.add(Number(id));
const companyNames = await postgresClient.query(
  `SELECT id, company_name FROM companies WHERE id = ANY($1::bigint[])`,
  [Array.from(allCandidateIds)]
);
```

Note the type difference from `device-link-conflicts`: `candidate_company_ids` is `BIGINT[]` (Autotask company IDs, integers) not `UUID[]` — no `::text[]` cast needed on the array itself, but each row's `id` in the `companies` lookup is `bigint` matching Postgres's native JS number handling (values fit safely in JS number range for this dataset — confirmed IDs are small integers, not snowflake-scale).

### Pattern 4: Resolve action — two-table transaction (D-06, D-08)

This is the one place this phase's implementation must **diverge** from the `device-link-conflicts` resolve route it's told to mirror. `device-link-conflicts/[id]/resolve` only touches one other table (`device_external_ids`) because that table has no separate "confidence tier" the matcher checks before re-scoring. PAX8's matcher does: `pax8-company-matcher.ts`'s `matchPax8Companies()` selects its eligible-for-rescoring set with:

```sql
-- Source: lib/services/pax8-company-matcher.ts lines 216-231 (verbatim)
WHERE c.is_deleted = false
  AND c.match_method IS DISTINCT FROM 'manual'
  AND NOT EXISTS (
    SELECT 1 FROM pax8_company_match_review r2
     WHERE r2.pax8_company_id = c.id AND r2.resolved_at IS NOT NULL
  )
```

For a manual resolution to actually "stick" against future syncs (SC#4 / D-06's "persist and be respected"), the resolve transaction must set **both** signals this query checks — `pax8_companies.match_method = 'manual'` (not just marking the review row resolved). Confirmed by direct inspection of the matcher's own SQL, not inferred:

```typescript
// app/api/pax8/company-matches/[id]/resolve/route.ts — NEW
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  const { session, error } = await requirePermission('admin', 'access'); // D-08
  if (error) return error;
  const { id } = await params;
  // ... validate body (companyId: number, note?: string) with zod, mirroring
  // device-link-conflicts/[id]/resolve's ResolveBody shape ...
  return postgresClient.transaction(async (tx) => {
    const reviewRes = await tx.query(
      `SELECT pax8_company_id, candidate_company_ids, resolved_at
         FROM pax8_company_match_review WHERE id = $1 FOR UPDATE`,
      [id]
    );
    if (reviewRes.rowCount === 0) return NextResponse.json({ error: 'Review not found' }, { status: 404 });
    if (reviewRes.rows[0].resolved_at) return NextResponse.json({ error: 'Already resolved' }, { status: 409 });
    // NOTE: unlike device-link-conflicts, do NOT restrict companyId to the
    // candidate list — D-05 explicitly requires a manual-search fallback for
    // cases with no correct candidate, so any active Autotask company id is
    // a valid resolution target here (validate it exists + is_active, but
    // don't require candidate-list membership).

    await tx.query(
      `UPDATE pax8_companies
          SET autotask_company_id = $2, match_confidence = NULL,
              match_method = 'manual', matched_at = NOW()
        WHERE id = $1`,
      [reviewRes.rows[0].pax8_company_id, companyId]
    );
    await tx.query(
      `UPDATE pax8_company_match_review
          SET resolved_at = NOW(), resolved_by_user_id = $2,
              resolved_to_company_id = $3, resolution_note = $4
        WHERE id = $1`,
      [id, session?.user?.id ?? null, companyId, note ?? null]
    );
    return NextResponse.json({ ok: true });
  });
}
```

This is the concrete, code-verified answer to CONTEXT.md's open research question ("does resolving also update `pax8_companies.autotask_company_id`?") — **yes, it must**, or the resolution will appear to work in the Needs Review tab (row disappears) but silently fail to show up in the Companies tab's `autotask_company_id` join, and worse, `matchPax8Companies()` would immediately re-flag the company on the next sync since `match_method` was never set to `'manual'`.

### Anti-Patterns to Avoid
- **Restricting the resolve action's target company to `candidate_company_ids`** (like `device-link-conflicts` does for `ciId`): D-05 explicitly requires a manual-search fallback for when none of the top-3 candidates are correct, and D-09's zero-candidate case has an empty candidate array by design — a candidate-membership check would make both of those cases impossible to resolve.
- **Reading `pax8_company_match_review.resolved_to_company_id` as the display-time join target**: it's an audit column, not a live pointer. Always join through `pax8_companies.autotask_company_id`.
- **A single `MAX(start_period)` cutoff for "current" cost data**: see Pitfall 2 — this silently drops subscriptions on an earlier anniversary cycle.
- **Passing raw PAX8 field names straight through to `DetailModal`'s formatted tab expecting automatic formatting**: `detectGroups()` doesn't know about PAX8 shapes at all today; without extension everything falls into the generic `{ label: 'Fields', fields: Object.keys(data) }` fallback — a flat, unstyled key/value dump with no subscriptions table.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Paginated/sortable/searchable table | Custom table + manual pagination state | `components/admin/DataTable.tsx` (D-01) | Already handles manual sort/pagination dispatch, CSV export, loading/empty states |
| Formatted/raw detail drill-down shell | New Dialog component from scratch | `components/admin/DetailModal.tsx`, extended (D-02) | Keeps visual consistency (header style, tab chrome, copy-to-clipboard) with every other detail view in the app — see Pitfall 1 for the specific extension needed |
| Company name matching for the manual-search fallback | New fuzzy-search endpoint | Existing `/api/data/companies-list` (already returns `{id, company_name}` for all active companies) + client-side filter | Only ~242 rows; no pagination exists on that endpoint today because it's designed to be fetched once |
| Auth/permission checks | Custom role-check logic in the route | `requireAuth()` / `requirePermission('admin','access')` from `lib/auth-utils.ts` (D-07/D-08) | Exact signatures already used by `device-link-conflicts` routes; `admin`/`access` resource-action pair confirmed to exist in `lib/permissions.ts`'s `statement` object |
| Nav item styling/active-state logic | New nav component | Add one object to `navigationItems` in `components/navigation/app-navigation.tsx` | Both desktop `NavigationMenu` and mobile `Sheet` (`MobileNav`) consume the same array — one edit, two surfaces |

**Key insight:** every "don't hand-roll" item here is an existing in-repo component, not an external package — this phase's leverage comes entirely from following precedent, not researching new tools.

## Common Pitfalls

### Pitfall 1: DetailModal.tsx cannot render the cost breakdown without an additive extension
**What goes wrong:** CONTEXT.md's D-02 says "used as-is" for the drill-down. Reading the actual component (`components/admin/DetailModal.tsx`) shows `detectGroups(data)` only recognizes two shapes — `'ticket_number' in data` → `TICKET_GROUPS`, `'company_name' in data` → `COMPANY_GROUPS` — and falls back to a flat, un-grouped key/value dump for anything else. Every `FieldType` branch in `resolveLabel()` renders a single scalar (date/bool/badge/phone/url/id); none render an array. A PAX8 company object has a `name` field (not `company_name`), and even if aliased, the subscriptions/cost-breakdown array has no representation path at all.
**Why it happens:** `DetailModal` was built ticket/company-specific and extended in place; it was never designed as a generic field-group renderer with a public extension point.
**How to avoid:** Budget a task to extend `DetailModal.tsx` additively: (1) add an explicit optional `kind` prop (e.g. `kind?: 'ticket' | 'company' | 'pax8_company'`) so `detectGroups` no longer has to guess from ambiguous field-name sniffing — pass `kind="pax8_company"` from the new page; (2) add a `PAX8_COMPANY_GROUPS` field-group set for identity/contact/system fields (reusing existing `FieldType`s: `url` for website, `bool` for `is_deleted`); (3) add a dedicated non-`FieldGroup` section rendered unconditionally in the formatted tab when `data.subscriptions` (or similar) is an array — a compact table of product/qty/billing-term/cost rows plus a summed total, styled consistently with the existing rounded-border card sections. Do not touch `TICKET_GROUPS`/`COMPANY_GROUPS` or their existing detection branches — this must be a pure addition.
**Warning signs:** If a plan task says "pass company + subscriptions to DetailModal" without a corresponding "extend DetailModal.tsx" task, the formatted tab will render as an unstyled field dump with no subscriptions data visible at all.

### Pitfall 2: NCE subscriptions bill on per-subscription anniversary cycles — no single "current period" cutoff exists
**What goes wrong:** A naive cost-breakdown query using `WHERE pax8_company_id = $1 ORDER BY start_period DESC LIMIT N` (or `MAX(start_period)` as a single cutoff) will return only the one or two subscriptions whose billing anniversary happens to be most recent, silently omitting every other active subscription on this company.
**Why it happens:** Live-verified: a single sampled company's most-recent order-items spanned `start_period` values of `2026-07-01`, `2026-06-28`, `2026-06-22`, `2026-06-15`, and `2026-06-08` — five different subscriptions, five different anniversary dates, in the same "current" snapshot. This is Microsoft NCE's per-license anniversary billing model, not a data quality bug.
**How to avoid:** Window the "latest" lookup **per `subscription_id`** (`DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC`, or an equivalent `ROW_NUMBER() OVER (PARTITION BY subscription_id ...)`), never a single company-wide cutoff. See Pattern 2's SQL.
**Warning signs:** A cost breakdown that shows far fewer line items than the company's active-subscription count in `pax8_subscriptions`.

### Pitfall 3: `unit_price × quantity` does not equal the actual amount billed
**What goes wrong:** Computing a cost summary as `SUM(unit_price * quantity)` overstates real spend.
**Why it happens:** PAX8 applies discounts/proration at the line level; `line_total` (and `amountDue` in the raw API shape) is the actual charged amount, `unit_price`/`price` is closer to list price. Live-verified example: `unit_price=155.65, quantity=1, line_total=109.00` on a real synced row.
**How to avoid:** Use `line_total` (and `partner_cost_total` for the reseller-cost figure, if shown) directly as the per-line amount; never recompute from `unit_price * quantity`.
**Warning signs:** Cost summary totals that look suspiciously round or higher than what the company's actual PAX8 invoice shows.

### Pitfall 4: Some referenced products/subscriptions have blank names or no matching row
**What goes wrong:** A cost-breakdown row renders with an empty product name, or a `LEFT JOIN` to `pax8_subscriptions` returns no billing-term/status because the subscription was later tombstoned.
**Why it happens:** Live-verified: 3 of 436 distinct `subscription_id` values referenced by `pax8_order_items` have no matching `pax8_subscriptions` row (likely historical/cancelled subscriptions whose parent row was tombstoned or never synced). Separately, some order-item `description`/`sku` combinations exist with entirely blank product names in the raw payload (e.g. ad-hoc "Rate Plan Adjustment" / "Change of Channel" line items that aren't real product SKUs).
**How to avoid:** Build a fallback label chain in the query or render layer: `COALESCE(product.name, order_item.description, order_item.sku, 'Unknown item')`. Don't `INNER JOIN` `pax8_products`/`pax8_subscriptions` when building the cost breakdown — use `LEFT JOIN` throughout, exactly as the existing sync service and matcher already do everywhere else in this schema.
**Warning signs:** Blank cells in the subscriptions table, or rows silently disappearing from the cost breakdown vs. what a raw count of `pax8_order_items` for that company would suggest.

### Pitfall 5: `pax8_order_items` is large (28.8K rows with a company set, live-verified) — always filter by `pax8_company_id` and `is_deleted = false`, never scan the whole table
**What goes wrong:** An unfiltered or company-unscoped query against `pax8_order_items` for the cost-breakdown drill-down will scan far more data than needed (one company's history can be 5,000+ rows — the busiest sampled company had 5,856).
**Why it happens:** The table accumulates full historical invoice line items since first sync (some as far back as 2019), not just current-state data.
**How to avoid:** Every cost-breakdown query must be scoped by `pax8_company_id = $1` (indexed: `idx_pax8_order_items_company`) and `is_deleted = false`; never load the table for the main company-list route, only for the per-company drill-down.
**Warning signs:** Slow drill-down modal opens; `postgresClient`'s built-in slow-query warning (`> 1000ms`) firing in logs.

## Code Examples

See Architecture Patterns 1-4 above for verified, live-data-grounded query and route-handler examples (company list join, per-company cost breakdown windowed join, review-queue bulk-fetch, and the two-table resolve transaction). All are adapted directly from existing in-repo code (`device-link-conflicts` routes, `pax8-company-matcher.ts`, `verify-pax8-orders-matching.ts`) rather than external sources.

## State of the Art

Not applicable in the usual sense — nothing in this phase is a library/framework whose "state of the art" shifts over time. The one internal precedent worth noting:

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|---------------|--------|
| `pax8_orders.pax8_company_id` assumed usable for per-company cost joins (Phase 10's original schema comment) | `pax8_order_items.pax8_company_id` is the only usable per-company cost join key | Migration 093 (Phase 12), confirmed still true live | Any query joining through `pax8_orders` for company-scoped cost data will return nothing — this is documented in the migration's own header comment and re-confirmed by this research's live query |

**Deprecated/outdated:** none — this schema is 8 migrations old (091→096) as of this research, all still current.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The exact API route paths (`app/api/pax8/companies`, `app/api/pax8/company-matches`, `.../[id]/resolve`) are a naming recommendation, not verified against any existing convention document — CONTEXT.md itself calls these "left to the planner." | Recommended Project Structure | Low — purely a naming choice, doesn't affect correctness; planner can rename freely without invalidating any other finding in this document |
| A2 | The recommended `DetailModal` extension approach (add a `kind` prop + a dedicated array-rendering section) is this researcher's design recommendation, not something confirmed by reading a similar precedent elsewhere in the codebase — no other phase has extended `DetailModal.tsx` for a non-ticket/non-company shape. | Pitfall 1 | Medium — if the planner instead builds a fully separate bespoke modal component, that's also valid; the risk is only in underestimating the DetailModal-reuse work if this extension path is chosen |
| A3 | `match_confidence` on `pax8_companies` should be set to `NULL` (not e.g. `1.000`) for a manually-resolved match, since there's no numeric trigram score for a human decision. | Pattern 4 (resolve transaction) | Low — cosmetic; if the planner prefers `1.000` for display purposes ("100% confidence"), that's a one-line change with no schema impact (`match_confidence` is nullable) |

## Open Questions (RESOLVED)

1. **Exact column set / labels for the Companies-tab table and the cost-breakdown formatted view**
   - What we know: DataTable/DetailModal mechanics, available columns on every relevant table (verified via `\d` against the live schema), and D-02's minimum spec (product name, quantity, billing term, cost summary).
   - What's unclear: whether the main list should show a subscription *count* column (query pattern shown in Pattern 1), a matched/unmatched status badge, or both — CONTEXT.md explicitly leaves this to the planner ("Claude's Discretion").
   - Recommendation: include at minimum `name`, matched-Autotask-company (or "Unmatched"/badge), active subscription count, and city/country as a `DataTable` sort target — matches the level of detail `app/admin/data-browser/companies/page.tsx` uses for its own company list.
   - **RESOLVED:** Plan 14-04's Companies tab column set matches this recommendation exactly.

2. **Whether the cost-breakdown summary should also surface `partner_cost_total` (reseller cost) alongside `line_total` (customer-facing cost)**
   - What we know: both columns exist and are populated on `pax8_order_items`; in the sampled data, most rows had `partner_cost_total ≈ line_total` (no visible margin), but D-03/PAX8-13 only asks for "a cost breakdown," not a margin analysis.
   - What's unclear: whether managers need to see partner cost at all, given margin/reselling isn't in this milestone's stated scope (`REQUIREMENTS.md` frames this purely as "see PAX8 subscription costs... without manually cross-referencing PAX8's own portal").
   - Recommendation: show only the customer-facing `line_total`/`price` in the primary breakdown; if there's appetite for a "raw" partner-cost column it belongs in the Raw tab (which already dumps `raw_payload`), not the Formatted tab.
   - **RESOLVED:** Plans 14-01/14-03 show only `line_total`/`price` in the Formatted view; `partner_cost_total` stays in the Raw tab only, per the recommendation.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| PostgreSQL | All queries in this phase | ✓ | 16 (pulse-postgres container, healthy) | — |
| Docker Compose stack (pulse-app, pulse-postgres, pulse-redis) | Local verification during planning/implementation | ✓ | running (pulse-app up ~1h, pulse-postgres up ~2h at research time) | — |
| PAX8 API credentials | N/A — this phase reads only already-synced Postgres data, never calls PAX8 directly | not needed | — | — |

No missing dependencies — this phase has no new external dependency at all.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | vitest 4.1.5 |
| Config file | `vitest.config.ts` — `test.include: ['lib/**/*.test.ts']` (confirmed: page components and `app/api/**` route handlers are **structurally excluded** from `npm test` today) |
| Quick run command | `npx tsc --noEmit --pretty` (the only automated gate that actually covers `app/pax8/**` and `app/api/pax8/**`) |
| Full suite command | `npm test` (covers `lib/services/analyzer/**`, `lib/services/rmm/**`, `lib/services/b2/**` only — will not exercise any file this phase adds unless logic is extracted into `lib/services/`) |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| PAX8-13 | Company list renders with subscription/cost data | manual (page has no test coverage under current vitest glob) | `npx tsc --noEmit --pretty` (type-check only) | ❌ Wave 0 (no test file planned; matches `device-link-conflicts` precedent, which also has no test file) |
| PAX8-12 / PAX8-14 | Resolve action writes both `pax8_companies` and `pax8_company_match_review` correctly and is admin-gated | unit (if cost-breakdown/resolve SQL is extracted into a `lib/services/pax8-*` helper) OR manual otherwise | `npx vitest run lib/services/pax8-company-matches.test.ts` (if extracted) | ❌ Wave 0 — recommend extracting the resolve transaction's write logic into a small `lib/services/pax8-company-match-resolver.ts` function specifically so it becomes unit-testable under the existing `lib/**/*.test.ts` glob, following `pax8-company-matcher.test.ts`'s existing mock-postgresClient convention |

### Sampling Rate
- **Per task commit:** `npx tsc --noEmit --pretty`
- **Per wave merge:** `npm test` (won't cover new files unless logic is extracted per above, but must still pass — regressions elsewhere would still be caught)
- **Phase gate:** Full suite green + manual click-through of both tabs (list, drill-down, resolve-with-candidate, resolve-via-manual-search, empty-state for zero-candidate row) before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] Decide whether to extract resolve/cost-breakdown SQL into `lib/services/` for unit-test coverage, or accept manual-only verification (matches `device-link-conflicts`'s existing precedent — it also has zero automated tests)
- [ ] If extracted: `lib/services/pax8-company-match-resolver.test.ts` — mock `postgresClient.query`, following `pax8-company-matcher.test.ts`'s existing convention (`vi.mock('@/lib/services/postgres-client', ...)`)
- [ ] No new test framework/config needed — vitest is already configured project-wide

*(If the planner accepts manual-only verification for this phase, matching `device-link-conflicts`'s existing precedent: state that explicitly in the plan rather than silently having zero coverage.)*

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | Better Auth session cookie, checked via `requireAuth()`/`requirePermission()` — no new auth surface |
| V3 Session Management | no | Unchanged — Better Auth handles this, out of scope for this phase |
| V4 Access Control | yes | `requireAuth()` for view routes (D-07), `requirePermission('admin','access')` for the resolve mutation (D-08) — verified exact signatures in `lib/auth-utils.ts` |
| V5 Input Validation | yes | Zod schema on the resolve POST body (companyId numeric, optional note capped in length), mirroring `device-link-conflicts/[id]/resolve`'s `ResolveBody` exactly |
| V6 Cryptography | no | No new secrets/crypto in this phase |

### Known Threat Patterns for this stack

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| SQL injection via company name / search query params | Tampering | Parameterized `postgresClient.query()` calls throughout (never string-interpolate user input) — matches every existing route in the codebase, including `pax8-company-matcher.ts`'s own `similarity($1, company_name)` discipline |
| Privilege escalation via missing auth check on a new route (the exact gap CR-02 flagged on `/api/pax8/sync`) | Elevation of Privilege | Every new route in this phase must open with an explicit `requireAuth()` or `requirePermission()` call — no route may rely on middleware alone (middleware only checks session-cookie *presence*, never role) |
| Resolve action linking a PAX8 company to an arbitrary/non-existent or inactive Autotask company id | Tampering | Validate the submitted `companyId` exists and (recommended) `is_active = true` in `companies` before the UPDATE — device-link-conflicts validates candidate-list membership instead, but D-05 requires accepting IDs outside the candidate list, so existence/active-state validation is the correct substitute check here |

## Sources

### Primary (HIGH confidence — direct codebase/DB verification, no external research needed)
- `migrations/091_pax8_tables.sql`, `092_pax8_subscription_costs.sql`, `093_pax8_orders_company_matching.sql`, `094_pax8_order_items_quantity_numeric.sql`, `095_pax8_order_items_partner_cost_numeric.sql`, `096_pax8_daily_schedule.sql` — full schema history read directly
- Live `docker exec pulse-postgres psql` queries against the actual running database (schema `\d` dumps + row counts + sample cost/period data) — run 2026-07-11
- `lib/services/pax8-company-matcher.ts`, `lib/services/pax8-sync-service.ts`, `lib/types/pax8.ts` — read in full
- `app/admin/device-link-conflicts/page.tsx`, `app/api/admin/device-link-conflicts/route.ts`, `app/api/admin/device-link-conflicts/[id]/resolve/route.ts` — read in full, the direct structural precedent
- `components/admin/DataTable.tsx`, `components/admin/DetailModal.tsx`, `lib/auth-utils.ts`, `lib/permissions.ts`, `middleware.ts`, `components/navigation/app-navigation.tsx`, `components/navigation/mobile-nav.tsx` — read in full
- `app/admin/data-browser/companies/page.tsx` — DataTable+DetailModal composition precedent
- `app/api/data/companies-list/route.ts` — manual-search fallback data source
- `.planning/phases/12-orders-invoices-company-matching/12-PATTERNS.md` — corroborates the resolve-transaction two-table requirement independently

### Secondary / Tertiary
None — this phase required no external library, framework, or web-search research; every claim traces to a file read or a live query in this session.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — zero new packages, every component already in active use
- Architecture: HIGH — every query pattern verified against live data; the resolve-transaction design is derived directly from reading the matcher's own eligibility SQL, not inferred
- Pitfalls: HIGH — all five pitfalls are backed by a live query result from this project's actual database, not general PAX8/NCE domain knowledge

**Research date:** 2026-07-11
**Valid until:** No natural expiry — this is an internal-schema/internal-component phase with no external dependency to go stale. Re-verify only if a future migration changes `pax8_*` schema before Phase 14 is implemented.
