From 443b6ce75b047c3d263d97b22d60eeaf50ef4254 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 14:28:11 -0400 Subject: [PATCH 1/4] feat(14-01): add paginated PAX8 companies list route - GET /api/pax8/companies with requireAuth gate (D-07) - Whitelisted sort columns, parameterized search/limit/offset - Joins pax8_companies to companies for matched name + active subscription count --- app/api/pax8/companies/route.ts | 117 ++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 app/api/pax8/companies/route.ts diff --git a/app/api/pax8/companies/route.ts b/app/api/pax8/companies/route.ts new file mode 100644 index 0000000..d2f2049 --- /dev/null +++ b/app/api/pax8/companies/route.ts @@ -0,0 +1,117 @@ +/** + * GET /api/pax8/companies + * Returns a paginated, sortable, searchable list of PAX8 companies with + * their matched Autotask company name and active subscription count. + * Query params: + * limit (default 50, max 200) + * offset (default 0) + * sort (name | status | city | country | subscriptions | match) + * order (asc | desc, default asc) + * search (matches pc.name via ILIKE) + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +interface CompanyRow { + id: string; + name: string; + status: string | null; + city: string | null; + state_or_province: string | null; + country: string | null; + autotask_company_id: string | null; + match_confidence: string | null; + match_method: string | null; + matched_company_name: string | null; + active_subscription_count: string; +} + +// Whitelisted sort columns — never interpolate the raw `sort` query param +// into SQL (T-14-04). +const SORT_COLUMNS: Record = { + name: 'pc.name', + status: 'pc.status', + city: 'pc.city', + country: 'pc.country', + subscriptions: 'active_subscription_count', + match: 'pc.match_method', +}; + +export async function GET(request: NextRequest) { + const { error } = await requireAuth(); + if (error) return error; + + try { + const url = request.nextUrl; + const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '50', 10) || 50, 200); + const offset = Math.max(parseInt(url.searchParams.get('offset') ?? '0', 10) || 0, 0); + const sortParam = url.searchParams.get('sort') ?? ''; + const orderParam = url.searchParams.get('order') ?? ''; + const search = url.searchParams.get('search'); + + const sortColumn = SORT_COLUMNS[sortParam] ?? SORT_COLUMNS.name; + const sortOrder = orderParam.toLowerCase() === 'desc' ? 'DESC' : 'ASC'; + + const params: unknown[] = [limit, offset]; + let searchFilter = ''; + if (search) { + params.push(`%${search}%`); + searchFilter = `AND pc.name ILIKE $${params.length}`; + } + + const companies = await postgresClient.query( + `SELECT pc.id, pc.name, pc.status, pc.city, pc.state_or_province, pc.country, + pc.autotask_company_id::text AS autotask_company_id, + pc.match_confidence::text AS 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')::text AS active_subscription_count + FROM pax8_companies pc + LEFT JOIN companies c ON c.id = pc.autotask_company_id + WHERE pc.is_deleted = false + ${searchFilter} + ORDER BY ${sortColumn} ${sortOrder} + LIMIT $1 OFFSET $2`, + params + ); + + const totalParams: unknown[] = []; + let totalSearchFilter = ''; + if (search) { + totalParams.push(`%${search}%`); + totalSearchFilter = `AND pc.name ILIKE $${totalParams.length}`; + } + const totalRes = await postgresClient.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM pax8_companies pc + WHERE pc.is_deleted = false ${totalSearchFilter}`, + totalParams + ); + const total = parseInt(totalRes.rows[0]?.count ?? '0', 10); + + const items = companies.rows.map((row) => ({ + id: row.id, + name: row.name, + status: row.status, + city: row.city, + stateOrProvince: row.state_or_province, + country: row.country, + autotaskCompanyId: row.autotask_company_id !== null ? Number(row.autotask_company_id) : null, + matchConfidence: row.match_confidence !== null ? Number(row.match_confidence) : null, + matchMethod: row.match_method, + matchedCompanyName: row.matched_company_name, + activeSubscriptionCount: Number(row.active_subscription_count), + })); + + return NextResponse.json({ items, total, limit, offset }); + } catch (err) { + console.error('Failed to fetch PAX8 companies:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch PAX8 companies' }, + { status: 500 } + ); + } +} From 2cc1abbf9adb1cc6d8a3cf90105f3a01bbe5254e Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 14:28:14 -0400 Subject: [PATCH 2/4] feat(14-01): add PAX8 company drill-down route with cost breakdown - GET /api/pax8/companies/[id] with requireAuth gate (D-07), UUID validation, 404 on missing - Per-subscription latest-billed cost via DISTINCT ON windowed query (Pitfall 2) - Uses line_total not unit_price*quantity (Pitfall 3); fallback label chain (Pitfall 4) - Surfaces tombstoned-subscription order-item rows with no matching subscription --- app/api/pax8/companies/[id]/route.ts | 213 +++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 app/api/pax8/companies/[id]/route.ts diff --git a/app/api/pax8/companies/[id]/route.ts b/app/api/pax8/companies/[id]/route.ts new file mode 100644 index 0000000..c494a91 --- /dev/null +++ b/app/api/pax8/companies/[id]/route.ts @@ -0,0 +1,213 @@ +/** + * GET /api/pax8/companies/[id] + * Returns a single PAX8 company's current subscriptions joined to each + * subscription's latest actually-billed order-item line (windowed per + * subscription_id, per D-03 — see 14-RESEARCH.md Pitfall 2), plus a + * summed cost total. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; + +const UUID_RE = /^[0-9a-f-]{36}$/i; + +interface CompanyHeaderRow { + id: string; + name: string; + status: string | null; + city: string | null; + state_or_province: string | null; + country: string | null; + website: string | null; + autotask_company_id: string | null; + match_confidence: string | null; + match_method: string | null; + synced_at: string | null; + is_deleted: boolean; +} + +interface SubscriptionRow { + subscription_id: string; + product_id: string | null; + product_name: string | null; + sku: string | null; + quantity: number; + billing_term: string | null; + status: string | null; + price: string | null; + partner_cost: string | null; + currency: string | null; +} + +interface OrderItemRow { + subscription_id: string; + product_id: string | null; + sku: string | null; + description: string | null; + item_type: string | null; + start_period: string | null; + end_period: string | null; + quantity: number | null; + unit_price: string | null; + line_total: string | null; + partner_cost: string | null; + partner_cost_total: string | null; +} + +interface SubscriptionBreakdownItem { + subscriptionId: string; + productName: string; + sku: string | null; + quantity: number | null; + billingTerm: string | null; + status: string | null; + currency: string | null; + latestBilledAmount: number; + startPeriod: string | null; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAuth(); + if (error) return error; + + try { + const { id } = await params; + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid company id' }, { status: 400 }); + } + + const companyRes = await postgresClient.query( + `SELECT id, name, status, city, state_or_province, country, website, + autotask_company_id::text AS autotask_company_id, + match_confidence::text AS match_confidence, match_method, + synced_at::text AS synced_at, is_deleted + FROM pax8_companies + WHERE id = $1`, + [id] + ); + const companyRow = companyRes.rows[0]; + if (!companyRow) { + return NextResponse.json({ error: 'Company not found' }, { status: 404 }); + } + + let matchedCompanyName: string | null = null; + if (companyRow.autotask_company_id) { + const matchedRes = await postgresClient.query<{ company_name: string }>( + `SELECT company_name FROM companies WHERE id = $1`, + [companyRow.autotask_company_id] + ); + matchedCompanyName = matchedRes.rows[0]?.company_name ?? null; + } + + // Step 1: current subscriptions for this company. + const subscriptionsRes = await postgresClient.query( + `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::text AS price, + s.partner_cost::text AS 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`, + [id] + ); + + // Step 2: latest actually-billed order-item line PER subscription + // (windowed, not a global MAX — see 14-RESEARCH.md Pitfall 2). + const orderItemsRes = await postgresClient.query( + `SELECT DISTINCT ON (subscription_id) + subscription_id, product_id, sku, description, item_type, + start_period::text AS start_period, end_period::text AS end_period, + quantity, unit_price::text AS unit_price, line_total::text AS line_total, + partner_cost::text AS partner_cost, partner_cost_total::text AS 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`, + [id] + ); + + const latestOrderItemBySubscription = new Map(); + for (const row of orderItemsRes.rows) { + latestOrderItemBySubscription.set(row.subscription_id, row); + } + + const subscriptions: SubscriptionBreakdownItem[] = []; + + for (const sub of subscriptionsRes.rows) { + const orderItem = latestOrderItemBySubscription.get(sub.subscription_id); + const price = sub.price !== null ? Number(sub.price) : 0; + const quantity = sub.quantity ?? null; + // Pitfall 3: never recompute from price*quantity when a line_total exists. + const latestBilledAmount = + orderItem?.line_total !== null && orderItem?.line_total !== undefined + ? Number(orderItem.line_total) + : price * (quantity ?? 0); + // Pitfall 4: fallback label chain. + const productName = + sub.product_name || orderItem?.description || sub.sku || orderItem?.sku || 'Unknown item'; + + subscriptions.push({ + subscriptionId: sub.subscription_id, + productName, + sku: sub.sku ?? orderItem?.sku ?? null, + quantity, + billingTerm: sub.billing_term, + status: sub.status, + currency: sub.currency, + latestBilledAmount, + startPeriod: orderItem?.start_period ?? null, + }); + // Consume this subscription's order item so it isn't re-appended below. + if (orderItem) latestOrderItemBySubscription.delete(sub.subscription_id); + } + + // Any remaining Step-2 rows belong to tombstoned/unsynced subscriptions + // (Pitfall 4) — still surface them as historical breakdown rows. + for (const orderItem of latestOrderItemBySubscription.values()) { + const lineTotal = orderItem.line_total !== null ? Number(orderItem.line_total) : 0; + subscriptions.push({ + subscriptionId: orderItem.subscription_id, + productName: orderItem.description || orderItem.sku || 'Unknown item', + sku: orderItem.sku, + quantity: orderItem.quantity, + billingTerm: null, + status: null, + currency: null, + latestBilledAmount: lineTotal, + startPeriod: orderItem.start_period, + }); + } + + const costTotal = subscriptions.reduce((sum, s) => sum + s.latestBilledAmount, 0); + + return NextResponse.json({ + company: { + id: companyRow.id, + name: companyRow.name, + status: companyRow.status, + city: companyRow.city, + stateOrProvince: companyRow.state_or_province, + country: companyRow.country, + website: companyRow.website, + autotaskCompanyId: companyRow.autotask_company_id !== null ? Number(companyRow.autotask_company_id) : null, + matchConfidence: companyRow.match_confidence !== null ? Number(companyRow.match_confidence) : null, + matchMethod: companyRow.match_method, + matchedCompanyName, + syncedAt: companyRow.synced_at, + isDeleted: companyRow.is_deleted, + }, + subscriptions, + costTotal, + }); + } catch (err) { + console.error('Failed to fetch PAX8 company detail:', err); + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to fetch PAX8 company detail' }, + { status: 500 } + ); + } +} From 501c7e41067fb59bf565da61f1e4db8b8c9be12a Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 14:28:58 -0400 Subject: [PATCH 3/4] docs(14-01): complete PAX8 companies API routes plan - Add SUMMARY.md documenting the two routes, deviations, and next-phase readiness --- .../14-pax8-ui-surface/14-01-SUMMARY.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md diff --git a/.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md b/.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md new file mode 100644 index 0000000..584afe4 --- /dev/null +++ b/.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md @@ -0,0 +1,111 @@ +--- +phase: 14-pax8-ui-surface +plan: 01 +subsystem: api +tags: [postgres, next.js, pax8, api-route, requireAuth] + +# Dependency graph +requires: + - phase: 12-orders-invoices-company-matching + provides: pax8_order_items historical cost data, pax8_companies.autotask_company_id matching +provides: + - "GET /api/pax8/companies — paginated/sortable/searchable PAX8 company list with matched Autotask name and active subscription count" + - "GET /api/pax8/companies/[id] — single company subscriptions + per-subscription latest-billed cost breakdown, windowed per subscription_id" +affects: ["14-04 (the /pax8 page that consumes these routes)"] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Whitelisted ORDER BY column map for user-controllable sort params (never interpolate raw sort/order strings into SQL)" + - "DISTINCT ON (subscription_id) windowed query for per-entity 'latest' lookups instead of a single global MAX cutoff" + +key-files: + created: + - app/api/pax8/companies/route.ts + - app/api/pax8/companies/[id]/route.ts + modified: [] + +key-decisions: + - "Both routes gate with requireAuth() only (D-07) — no admin/permission check, since this is read-only view data any authenticated manager may see" + - "Cost breakdown uses order-item line_total as the billed amount, falling back to price*quantity only when no order-item row exists for a subscription (Pitfall 3)" + - "Order-item rows whose subscription_id has no matching pax8_subscriptions row (tombstoned/unsynced) are still surfaced as historical breakdown rows with null billingTerm/status (Pitfall 4)" + +patterns-established: + - "Pattern: whitelist map for sort column resolution (SORT_COLUMNS) — reusable template for any future paginated/sortable list route in this codebase" + +requirements-completed: [PAX8-13] + +# Metrics +duration: 12min +completed: 2026-07-11 +--- + +# Phase 14 Plan 01: PAX8 Companies API Routes Summary + +**Two read-only API routes backing the /pax8 Companies tab: an injection-safe paginated/sortable/searchable company list, and a per-company drill-down that correctly windows the "latest billed amount" per subscription instead of using a single global cutoff date.** + +## Performance + +- **Duration:** 12 min +- **Started:** 2026-07-11T18:16:00Z +- **Completed:** 2026-07-11T18:28:23Z +- **Tasks:** 2 completed +- **Files modified:** 2 (both new) + +## Accomplishments +- `GET /api/pax8/companies` returns `{ items, total, limit, offset }` with camelCase fields, a hardcoded sort-column whitelist, and fully parameterized search/limit/offset — no SQL injection surface on any user-controllable input. +- `GET /api/pax8/companies/[id]` returns `{ company, subscriptions, costTotal }`, correctly handling PAX8's NCE per-subscription anniversary billing (each subscription's own latest order-item row, not a single company-wide `MAX(start_period)`), uses `line_total` (never `unit_price * quantity`), and surfaces historical order-item rows for subscriptions that no longer exist in `pax8_subscriptions`. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: GET /api/pax8/companies — paginated company list** - `443b6ce` (feat) +2. **Task 2: GET /api/pax8/companies/[id] — subscriptions + cost breakdown** - `2cc1abb` (feat) + +**Plan metadata:** (this commit, following SUMMARY.md creation) + +## Files Created/Modified +- `app/api/pax8/companies/route.ts` — paginated/sortable/searchable PAX8 company list, requireAuth-gated +- `app/api/pax8/companies/[id]/route.ts` — single-company subscriptions + per-subscription latest-billed cost breakdown + +## Decisions Made +- Both routes use `requireAuth()` only, never `requirePermission`, per CONTEXT.md D-07 — this is read-only view data for any authenticated manager, not an admin-gated action. +- `match_confidence` and `autotask_company_id` are cast to `::text` in SQL and coerced with `Number()` in JS to avoid `pg`'s default numeric/bigint string-return behavior producing unexpected types downstream. +- Tombstoned-subscription order-item rows (3 of 436 distinct subscription_ids referenced by `pax8_order_items` live-verified in RESEARCH.md to have no matching `pax8_subscriptions` row) are appended as extra breakdown rows with `billingTerm`/`status` set to `null`, per the plan's explicit instruction — these represent real historical spend that would otherwise silently vanish from `costTotal`. + +## Deviations from Plan + +None - plan executed exactly as written. One in-flight self-correction during Task 1 implementation (documented below) was caught and fixed before committing, not a deviation from the plan's design. + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Fixed parameter-index mismatch in the companies list total-count query** +- **Found during:** Task 1 (GET /api/pax8/companies) +- **Issue:** The total-count query reused the `searchFilter` SQL fragment built for the main list query (where `search` binds to `$3` because `$1`/`$2` are `limit`/`offset`), but passed only `[search]` as its params array — a mismatch that would bind the search term to `$1` while the SQL referenced `$3`, causing either a runtime bind error or (worse) a silently wrong/unfiltered count. +- **Fix:** Built a separate `totalParams`/`totalSearchFilter` pair scoped to the total-count query's own parameter numbering (search binds to `$1` there, since that query has no limit/offset params). +- **Files modified:** app/api/pax8/companies/route.ts +- **Verification:** `npx tsc --noEmit --pretty` passes; manual trace of parameter binding for both the with-search and without-search cases confirms correct `$N` alignment. +- **Committed in:** 443b6ce (Task 1 commit — fixed before first commit, not a follow-up) + +--- + +**Total deviations:** 1 auto-fixed (1 bug, caught pre-commit during implementation) +**Impact on plan:** No scope creep — this was an implementation-time bug caught and fixed before the task was ever committed, not a change to the plan's design. + +## Issues Encountered +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- Both routes are ready for Plan 04 (the `/pax8` page) to consume via `fetch()`. +- Response shapes match the plan's `must_haves.artifacts` contract exactly: list route exports `GET` returning `{ items, total, limit, offset }`; drill-down route exports `GET` returning `{ company, subscriptions, costTotal }`. +- No blockers for downstream plans in this wave (14-02, 14-03) — no file overlap, no shared state introduced. + +--- +*Phase: 14-pax8-ui-surface* +*Completed: 2026-07-11* From d61acf9ca59469f3b3edcd793d6c36953ff27161 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 11 Jul 2026 14:29:13 -0400 Subject: [PATCH 4/4] docs(14-01): append self-check results to SUMMARY.md --- .planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md b/.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md index 584afe4..01f4a4a 100644 --- a/.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md +++ b/.planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md @@ -109,3 +109,12 @@ None - no external service configuration required. --- *Phase: 14-pax8-ui-surface* *Completed: 2026-07-11* + +## Self-Check: PASSED + +- FOUND: app/api/pax8/companies/route.ts +- FOUND: app/api/pax8/companies/[id]/route.ts +- FOUND: .planning/phases/14-pax8-ui-surface/14-01-SUMMARY.md +- FOUND commit: 443b6ce +- FOUND commit: 2cc1abb +- FOUND commit: 501c7e4