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
This commit is contained in:
parent
443b6ce75b
commit
2cc1abbf9a
1 changed files with 213 additions and 0 deletions
213
app/api/pax8/companies/[id]/route.ts
Normal file
213
app/api/pax8/companies/[id]/route.ts
Normal file
|
|
@ -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<CompanyHeaderRow>(
|
||||
`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<SubscriptionRow>(
|
||||
`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<OrderItemRow>(
|
||||
`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<string, OrderItemRow>();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue