---
phase: 14-pax8-ui-surface
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - app/api/pax8/companies/route.ts
  - app/api/pax8/companies/[id]/route.ts
autonomous: true
requirements: [PAX8-13]
must_haves:
  truths:
    - "An authenticated user can GET /api/pax8/companies and receive a paginated list of PAX8 companies with their matched Autotask company name and active subscription count"
    - "An authenticated user can GET /api/pax8/companies/[id] and receive that company's current subscriptions plus each subscription's latest actually-billed amount, grouped by subscription/product per D-03"
    - "Both routes reject unauthenticated requests with 401; both use requireAuth() only, never requirePermission, per D-07"
  artifacts:
    - path: "app/api/pax8/companies/route.ts"
      provides: "GET company list (paginated, sortable, searchable), requireAuth-gated"
      exports: ["GET"]
    - path: "app/api/pax8/companies/[id]/route.ts"
      provides: "GET single-company subscriptions + per-subscription latest-period cost breakdown"
      exports: ["GET"]
  key_links:
    - from: "app/api/pax8/companies/route.ts"
      to: "pax8_companies LEFT JOIN companies"
      via: "postgresClient.query on autotask_company_id"
      pattern: "LEFT JOIN companies c ON c.id = pc.autotask_company_id"
    - from: "app/api/pax8/companies/[id]/route.ts"
      to: "pax8_order_items"
      via: "DISTINCT ON (subscription_id) windowed query"
      pattern: "DISTINCT ON \\(subscription_id\\)"
---

<objective>
Create the two read-only API routes that back the Companies tab of `/pax8`: a paginated/sortable/searchable company list, and a single-company drill-down returning subscriptions joined to their latest actually-billed cost line.

Purpose: PAX8-13 requires `/pax8` to list PAX8 companies with subscriptions and a cost breakdown. These routes are the data layer; the page (Plan 04) consumes them. Splitting the aggregation server-side (per the Architectural Responsibility Map) keeps the non-trivial per-subscription windowed join out of the browser.
Output: `app/api/pax8/companies/route.ts` (list) and `app/api/pax8/companies/[id]/route.ts` (drill-down).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/14-pax8-ui-surface/14-RESEARCH.md
@.planning/phases/14-pax8-ui-surface/14-PATTERNS.md
@.planning/phases/14-pax8-ui-surface/14-CONTEXT.md

<interfaces>
<!-- Executor uses these directly — no codebase exploration needed. -->

Auth helper (from lib/auth-utils.ts) — D-07 requires requireAuth() ONLY, never requirePermission:
  export async function requireAuth(): Promise<{ session: Session | null; error: NextResponse | null }>;
  // usage: const { error } = await requireAuth(); if (error) return error;

Postgres access (from lib/services/postgres-client.ts):
  import postgresClient from '@/lib/services/postgres-client';
  postgresClient.query<T>(sql: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number }>;
  // ALWAYS parameterized ($1,$2) — never string-interpolate user input.

Relevant schema (verified live, migrations 091/092/093):
  pax8_companies(id UUID, name TEXT, external_id TEXT, website TEXT, status TEXT,
                 city TEXT, state_or_province TEXT, postal_code TEXT, country TEXT,
                 raw_payload JSONB, synced_at TIMESTAMPTZ, is_deleted BOOL,
                 autotask_company_id BIGINT, match_confidence NUMERIC(4,3),
                 match_method TEXT, matched_at TIMESTAMPTZ)
  companies(id BIGINT, company_name VARCHAR, is_active BOOL, is_deleted BOOL)
  pax8_subscriptions(id UUID, pax8_company_id UUID, product_id UUID, quantity INT,
                     billing_term TEXT, status TEXT, start_date TIMESTAMPTZ,
                     price NUMERIC(12,2), partner_cost NUMERIC(12,2), currency CHAR(3),
                     is_deleted BOOL)
  pax8_products(id UUID, sku TEXT, vendor_sku TEXT, name TEXT, category TEXT, is_deleted BOOL)
  pax8_order_items(id UUID, order_id UUID, product_id UUID, quantity INT,
                   unit_price NUMERIC(12,2), line_total NUMERIC(12,2), currency CHAR(3),
                   pax8_company_id UUID, subscription_id UUID, item_type TEXT, sku TEXT,
                   description TEXT, start_period TIMESTAMPTZ, end_period TIMESTAMPTZ,
                   partner_cost NUMERIC(12,2), partner_cost_total NUMERIC(12,2), is_deleted BOOL)
  Indexes present: idx_pax8_order_items_company(pax8_company_id), idx_pax8_order_items_period.

List query template (RESEARCH.md Pattern 1, live-verified):
  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 <whitelisted column> <dir>
   LIMIT $1 OFFSET $2;

Drill-down two-query approach (RESEARCH.md Pattern 2 + Pitfalls 2/3/4):
  Step 1 — current subscriptions:
    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 (windowed, NOT a global MAX):
    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 in JS on subscription_id. Fall back to price*quantity ONLY when no order-item row exists.
</interfaces>

# Do NOT reference other pax8 API routes — this plan's two routes are self-contained.
</context>

<tasks>

<task type="auto">
  <name>Task 1: GET /api/pax8/companies — paginated company list</name>
  <read_first>
    - app/api/admin/device-link-conflicts/route.ts (query construction, limit/offset parsing, snake_case→camelCase map, response envelope — the structural template)
    - lib/auth-utils.ts (requireAuth signature — use requireAuth, NOT requirePermission, per D-07)
    - lib/services/pax8-company-matcher.ts (lines 216-231: confirms pax8_companies.autotask_company_id / match_method are the live join-and-confidence signals this list surfaces)
    - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 1, Pitfall 5)
  </read_first>
  <files>app/api/pax8/companies/route.ts</files>
  <action>
    Create `GET(request: NextRequest)`. First line of the handler: `const { error } = await requireAuth(); if (error) return error;` (D-07 — any authenticated user, no admin gate). Parse `limit` (default 50, clamp max 200), `offset` (default 0, floor 0) exactly as device-link-conflicts/route.ts does. Parse `sort` and `order`: map `sort` through an explicit whitelist object to a real column — allowed keys `name`→`pc.name`, `status`→`pc.status`, `city`→`pc.city`, `country`→`pc.country`, `subscriptions`→`active_subscription_count`, `match`→`pc.match_method`; any other/absent value falls back to `pc.name`. Map `order` to `ASC` unless it equals `desc` (case-insensitive) → `DESC`. NEVER interpolate the raw `sort`/`order` strings into SQL — only the whitelisted literals may be concatenated into the ORDER BY. Parse optional `search`: when present, add `AND pc.name ILIKE $N` with a parameterized `%search%` bound value. Run the RESEARCH.md Pattern 1 SELECT (see interfaces) with the resolved ORDER BY, plus a matching `SELECT COUNT(*) FROM pax8_companies pc WHERE pc.is_deleted = false [AND pc.name ILIKE $1]` for the total. Transform rows to camelCase: `{ id, name, status, city, stateOrProvince, country, autotaskCompanyId, matchConfidence, matchMethod, matchedCompanyName, activeSubscriptionCount }` (coerce `active_subscription_count` and `autotask_company_id` with Number). Return `NextResponse.json({ items, total, limit, offset })`. Wrap DB work in try/catch returning `{ error }` at status 500 with a `console.error('Failed to fetch PAX8 companies:', err)` per codebase convention.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - `app/api/pax8/companies/route.ts` exports an async `GET` whose first statement invokes `requireAuth()` and returns `error` when set (grep: `requireAuth` present, `requirePermission` absent in this file)
    - The ORDER BY column is selected from a hardcoded whitelist map — grep confirms no `searchParams.get('sort')` value is concatenated directly into the SQL string
    - All user-supplied values (search term, limit, offset) reach SQL only as `$N` bound parameters (no template-literal interpolation of request input)
    - Response JSON shape is `{ items: [...], total, limit, offset }` with camelCase item keys including `matchedCompanyName` and `activeSubscriptionCount`
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>GET /api/pax8/companies returns a 200 paginated list for an authenticated user and 401 otherwise; sort/search are injection-safe.</done>
</task>

<task type="auto">
  <name>Task 2: GET /api/pax8/companies/[id] — subscriptions + cost breakdown</name>
  <read_first>
    - app/api/admin/device-link-conflicts/route.ts (bulk-fetch-then-map-in-JS pattern for the two-query join)
    - lib/auth-utils.ts (requireAuth)
    - .planning/phases/14-pax8-ui-surface/14-RESEARCH.md (Pattern 2, Pitfall 2 per-subscription windowing, Pitfall 3 use line_total, Pitfall 4 fallback label chain, Pitfall 5 always scope by pax8_company_id + is_deleted)
    - lib/types/pax8.ts (Pax8Subscription / Pax8InvoiceItem field names for the transform)
  </read_first>
  <files>app/api/pax8/companies/[id]/route.ts</files>
  <action>
    Create `GET(request: NextRequest, { params }: { params: Promise<{ id: string }> })`. Gate with `requireAuth()` (D-07). `const { id } = await params;` and validate `id` matches a UUID shape (`/^[0-9a-f-]{36}$/i`), returning 400 otherwise. Fetch the company header row: `SELECT id, name, status, city, state_or_province, country, website, autotask_company_id, match_confidence, match_method, synced_at, is_deleted FROM pax8_companies WHERE id = $1` — 404 if no row. Also fetch the matched Autotask company name via `SELECT company_name FROM companies WHERE id = $1` when `autotask_company_id` is set. Run the Step 1 (current subscriptions) and Step 2 (per-subscription DISTINCT ON latest order-item) queries from the interfaces block, both scoped `WHERE pax8_company_id = $1 AND is_deleted = false`. Join in application code keyed on `subscription_id`: for each Step 1 subscription build `{ subscriptionId, productName, sku, quantity, billingTerm, status, currency, latestBilledAmount, startPeriod }` where `latestBilledAmount` = the matched order-item's `line_total` (Number) when present, else `price * quantity` fallback (Pitfall 3 — never recompute from unit_price×quantity when a line_total exists). Product label uses the COALESCE chain in JS: `product_name || description || sku || 'Unknown item'` (Pitfall 4). Then append any Step-2 order-item rows whose `subscription_id` has NO matching Step 1 subscription as extra breakdown rows (using their own product/description/sku label and `line_total`, with `billingTerm`/`status` null) — these are tombstoned-subscription historical lines that must still appear. Compute `costTotal` = sum of all rows' `latestBilledAmount`. Return `NextResponse.json({ company: {...camelCase header + matchedCompanyName...}, subscriptions: [...], costTotal })`. try/catch → 500 with console.error.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty</automated>
  </verify>
  <acceptance_criteria>
    - Handler gates with `requireAuth()` and validates the `[id]` param as a UUID (returns 400 on malformed id, 404 on missing company)
    - The order-item query uses `DISTINCT ON (subscription_id) ... ORDER BY subscription_id, start_period DESC` (grep confirms) — NOT a single company-wide `MAX(start_period)` cutoff
    - Both subscription and order-item queries include `pax8_company_id = $1 AND is_deleted = false` (Pitfall 5 scoping)
    - Per-line amount comes from `line_total` (grep: no `unit_price * quantity` used when a line_total is available)
    - Response includes `company`, `subscriptions` (array), and a numeric `costTotal`
    - `npx tsc --noEmit --pretty` passes
  </acceptance_criteria>
  <done>GET /api/pax8/companies/[id] returns one company's subscriptions with per-subscription latest billed amounts and a summed costTotal; every active subscription appears regardless of billing anniversary.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| browser → API route | Authenticated manager sends list/detail requests with attacker-controllable query params (sort, order, search, limit, offset) and path param (company id) |
| API route → Postgres | Route issues parameterized queries against pax8_* and companies tables |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-14-03 | Information Disclosure | GET /api/pax8/companies, GET /api/pax8/companies/[id] | mitigate | Both handlers open with `requireAuth()` (D-07); `/pax8` and `/api/pax8/companies*` are NOT in middleware.ts publicRoutes — do not add them |
| T-14-04 | Tampering | list route sort/order/search/limit/offset params | mitigate | `sort`/`order` resolved through a hardcoded column whitelist (never interpolated); `search`/`limit`/`offset` bound as `$N` parameters only |
| T-14-06 | Information Disclosure | drill-down `[id]` path param | mitigate | Validate UUID shape before query; parameterized `WHERE id = $1`; return 404 (not raw error) on missing row |
| T-14-SC | Tampering | npm/pip/cargo installs | accept | Zero new packages this phase (RESEARCH.md Package Legitimacy Audit: N/A) — no supply-chain surface introduced |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` passes with both new route files present
- Manual (deferred to Plan 06): both endpoints return expected JSON against the live dev DB (118 companies, 445 subscriptions) for an authenticated session
</verification>

<success_criteria>
- Two route files exist, both gated by `requireAuth()`
- List route is injection-safe (whitelisted ORDER BY, parameterized inputs)
- Drill-down route windows cost per subscription (Pitfall 2) and uses line_total (Pitfall 3) with fallback labels (Pitfall 4)
- Type check green
</success_criteria>

<output>
Create `.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md` when done
</output>
