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 } + ); + } +}