diff --git a/.env.local.example b/.env.local.example index 67e9a77..384e01f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -77,4 +77,24 @@ ADMIN_REBUILD_COOLDOWN_MS=600000 # Bootstrap superadmin email. Must be a verified Google account. # This value is server-only; do not expose it to the browser. -SUPER_ADMIN_EMAIL=your-superadmin@example.com \ No newline at end of file +SUPER_ADMIN_EMAIL=your-superadmin@example.com + +# --- QuickBooks Online integration (server-only — never NEXT_PUBLIC_*) --- +# OAuth + API credentials from the Intuit Developer Portal app. Use the +# app's *Development* keys with QBO_ENVIRONMENT=sandbox (local dev and +# Vercel Preview) and *Production* keys with QBO_ENVIRONMENT=production +# (Vercel Production only). See docs/operations/quickbooks.md. +# QBO_ENVIRONMENT=sandbox +# QBO_CLIENT_ID= +# QBO_CLIENT_SECRET= +# Exact callback URL registered in the Intuit app's redirect-URI list, +# e.g. https:///api/admin/quickbooks/callback (https +# required outside localhost). +# QBO_REDIRECT_URI= +# Verifier token from the Intuit app's webhook configuration — HMAC key +# for POST /api/webhooks/quickbooks. +# QBO_WEBHOOK_VERIFIER_TOKEN= +# AES-256-GCM key for OAuth token material at rest: 64 hex chars or a +# base64-encoded 32-byte value. Generate with: +# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +# QBO_TOKEN_ENCRYPTION_KEY= \ No newline at end of file diff --git a/app/(pages)/admin-quickbooks-fixture/page.tsx b/app/(pages)/admin-quickbooks-fixture/page.tsx new file mode 100644 index 0000000..36f865d --- /dev/null +++ b/app/(pages)/admin-quickbooks-fixture/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AdminQuickbooksFixture } from "@/components/admin-quickbooks-fixture"; + +// Test-only rendering of the QuickBooks admin surface — same gate as +// /admin-fixture (ADMIN_A11Y_FIXTURE is set only by the Playwright webServer +// config; normal deployments 404). It grants no access to real data: the +// workspace's API calls are intercepted by Playwright route mocks. +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Admin QuickBooks fixture", + robots: { index: false, follow: false }, +}; + +export default function AdminQuickbooksFixturePage() { + if (process.env.ADMIN_A11Y_FIXTURE !== "1") { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/app/(pages)/admin/integrations/quickbooks/page.tsx b/app/(pages)/admin/integrations/quickbooks/page.tsx new file mode 100644 index 0000000..7ed6018 --- /dev/null +++ b/app/(pages)/admin/integrations/quickbooks/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { AdminQuickbooksPage } from "@/components/admin-quickbooks-page"; + +export const metadata: Metadata = { + title: "QuickBooks — Admin", + description: "Deep Dive Brewing Co QuickBooks Online integration.", + robots: { + index: false, + follow: false, + }, +}; + +export default function AdminQuickbooksRoute() { + return ( +
+ +
+ ); +} diff --git a/app/api/admin/quickbooks/callback/route.ts b/app/api/admin/quickbooks/callback/route.ts new file mode 100644 index 0000000..cef6bc5 --- /dev/null +++ b/app/api/admin/quickbooks/callback/route.ts @@ -0,0 +1,115 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getAdminUser } from "@/lib/admin-users"; +import { getRequestId, logError, logWarn } from "@/lib/log"; +import { QboError } from "@/lib/qbo-errors"; +import { + completeQboAuthorization, + consumeQboOAuthState, +} from "@/lib/qbo"; +import { QBO_OAUTH_COOKIE } from "@/lib/qbo-callback-cookie"; + +// Intuit OAuth redirect target (issue #161). This endpoint cannot require +// the admin bearer token — the browser arrives here from Intuit — so the +// trust boundary is the one-time OAuth state: it must match the httpOnly +// cookie set at connect time AND a persisted, unexpired, unconsumed record +// created by an authorized admin. A consumed state proves who initiated +// the flow — the initiating admin's record is re-checked below before any +// provider exchange, since they may have been disabled in between. +// +// The route never renders anything and never echoes provider data — it +// redirects back to the admin page with a coarse outcome parameter only. + +const ADMIN_PAGE = "/admin/integrations/quickbooks"; + +function redirectToAdmin(req: NextRequest, params: Record) { + const url = new URL(ADMIN_PAGE, req.url); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + const res = NextResponse.redirect(url); + res.cookies.delete(QBO_OAUTH_COOKIE); + return res; +} + +export async function GET(req: NextRequest) { + const requestId = getRequestId(req.headers); + const params = req.nextUrl.searchParams; + + const providerError = params.get("error"); + if (providerError) { + // Admin declined consent or Intuit refused the request. + logWarn("qbo.oauth.failed", { requestId, reason: "provider_denied" }); + return redirectToAdmin(req, { qbo: "error", qbo_reason: "denied" }); + } + + const code = params.get("code"); + const state = params.get("state"); + const realmId = params.get("realmId"); + const cookieState = req.cookies.get(QBO_OAUTH_COOKIE)?.value; + + if (!code || !state || !realmId) { + return redirectToAdmin(req, { + qbo: "error", + qbo_reason: "invalid_callback", + }); + } + + // The state must arrive via the same browser that started the flow — + // a state injected into someone else's callback fails here before any + // Firestore work happens. + if (!cookieState || cookieState !== state) { + logWarn("qbo.oauth.failed", { requestId, reason: "state_cookie_mismatch" }); + return redirectToAdmin(req, { + qbo: "error", + qbo_reason: "state_invalid", + }); + } + + try { + const consumed = await consumeQboOAuthState(state); + if (consumed.verdict !== "ok") { + logWarn("qbo.oauth.failed", { requestId, reason: consumed.verdict }); + return redirectToAdmin(req, { + qbo: "error", + qbo_reason: + consumed.verdict === "consumed" + ? "state_replayed" + : consumed.verdict === "expired" + ? "state_expired" + : "state_invalid", + }); + } + + // The consumed state identifies who started the flow; it does not + // prove they are still allowed to act. Re-verify the adminUsers + // record — an admin disabled or removed between connect and callback + // must not complete the connection. Both admin roles may connect, so + // any still-active record passes regardless of role. + const initiator = await getAdminUser(consumed.uid); + if (initiator?.status !== "active") { + logWarn("qbo.oauth.failed", { + requestId, + reason: "initiating_admin_inactive", + }); + return redirectToAdmin(req, { + qbo: "error", + qbo_reason: "connect_failed", + }); + } + + await completeQboAuthorization({ + code, + realmId, + uid: consumed.uid, + email: consumed.email, + }); + return redirectToAdmin(req, { qbo: "connected" }); + } catch (error) { + const reason = + error instanceof QboError && error.kind === "configuration" + ? "not_configured" + : "connect_failed"; + logError("qbo.oauth.failed", error, { requestId }); + return redirectToAdmin(req, { qbo: "error", qbo_reason: reason }); + } +} diff --git a/app/api/admin/quickbooks/connect/route.ts b/app/api/admin/quickbooks/connect/route.ts new file mode 100644 index 0000000..27b2d42 --- /dev/null +++ b/app/api/admin/quickbooks/connect/route.ts @@ -0,0 +1,44 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { createQboAuthorizationRequest } from "@/lib/qbo"; +import { QBO_OAUTH_COOKIE } from "@/lib/qbo-callback-cookie"; +import { QBO_OAUTH_STATE_TTL_MS } from "@/lib/qbo-protocol"; + +// Starts the QuickBooks OAuth flow (issue #161): the admin receives the +// Intuit authorization URL and a one-time state cookie. The cookie +// cross-checks the callback's `state` parameter against the persisted +// record so the flow is bound to this admin's browser. + +export async function POST(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + const actor = await requireAdminActor(idToken); + const { authorizationUrl, state } = + await createQboAuthorizationRequest(actor); + const res = NextResponse.json({ ok: true, authorizationUrl }); + // Lax so it rides along on Intuit's top-level redirect back to us; + // httpOnly so only the server sees it; Secure whenever this request + // itself arrived over https (every deployed environment — a plain + // http localhost dev flow keeps the cookie too). + res.cookies.set(QBO_OAUTH_COOKIE, state, { + httpOnly: true, + secure: req.nextUrl.protocol === "https:", + sameSite: "lax", + maxAge: Math.ceil(QBO_OAUTH_STATE_TTL_MS / 1000), + path: "/", + }); + return res; + } catch (error) { + return apiErrorResponse(error, { + fallback: "Could not start the QuickBooks connection.", + event: "qbo.connect_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/quickbooks/disconnect/route.ts b/app/api/admin/quickbooks/disconnect/route.ts new file mode 100644 index 0000000..b3a24de --- /dev/null +++ b/app/api/admin/quickbooks/disconnect/route.ts @@ -0,0 +1,46 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { + badRequestResponse, + getBearerToken, + unauthorizedResponse, +} from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { disconnectQbo, getQboAdminView } from "@/lib/qbo"; + +// Deliberate disconnect (issue #161): requires an explicit confirmation +// body, revokes the grant at Intuit, and destroys stored credentials while +// keeping the audit/history record. +export async function POST(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + let body: unknown; + try { + body = await req.json(); + } catch { + return badRequestResponse("Invalid JSON body."); + } + const confirm = + typeof body === "object" && body !== null + ? (body as Record).confirm + : undefined; + if (confirm !== true) { + return badRequestResponse("Disconnect requires explicit confirmation."); + } + + try { + const actor = await requireAdminActor(idToken); + await disconnectQbo(actor); + const connection = await getQboAdminView(); + return NextResponse.json({ ok: true, connection }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Could not disconnect QuickBooks.", + event: "qbo.disconnect_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/quickbooks/entities/route.ts b/app/api/admin/quickbooks/entities/route.ts new file mode 100644 index 0000000..a4208e1 --- /dev/null +++ b/app/api/admin/quickbooks/entities/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { + badRequestResponse, + getBearerToken, + unauthorizedResponse, +} from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { queryQboEntities } from "@/lib/qbo-api"; +import { getQboEnvironment } from "@/lib/qbo-config"; +import { getQuickBooksAccessToken } from "@/lib/qbo-tokens"; +import { isQboDiscoveryEntityType } from "@/lib/qbo-common"; + +// Read-only entity discovery for the accounting-mapping surface +// (issue #161). Returns canonical safe summaries — ids, names, types — +// never raw QBO records. +export async function GET(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + const type = req.nextUrl.searchParams.get("type"); + if (!isQboDiscoveryEntityType(type)) { + return badRequestResponse("Unknown entity type."); + } + + try { + await requireAdminActor(idToken); + const { accessToken, realmId } = await getQuickBooksAccessToken(); + const entities = await queryQboEntities({ + environment: getQboEnvironment(), + realmId, + accessToken, + type, + }); + return NextResponse.json({ ok: true, entities }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Could not load QuickBooks entities.", + event: "qbo.entities_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/quickbooks/mapping/route.ts b/app/api/admin/quickbooks/mapping/route.ts new file mode 100644 index 0000000..98bc178 --- /dev/null +++ b/app/api/admin/quickbooks/mapping/route.ts @@ -0,0 +1,56 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { + badRequestResponse, + getBearerToken, + unauthorizedResponse, +} from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { getQboMappingView, saveQboMapping } from "@/lib/qbo-mapping"; + +// Accounting-mapping configuration (issue #161): which connected-company +// QBO entities future Stripe/tour revenue should post against. Save +// validates every id against a live entity query. +export async function GET(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const mapping = await getQboMappingView(); + return NextResponse.json({ ok: true, mapping }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Could not load the QuickBooks mapping.", + event: "qbo.mapping_get_failed", + context: { requestId }, + }); + } +} + +export async function PUT(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + let body: unknown; + try { + body = await req.json(); + } catch { + return badRequestResponse("Invalid JSON body."); + } + + try { + const actor = await requireAdminActor(idToken); + const mapping = await saveQboMapping(actor, body); + return NextResponse.json({ ok: true, mapping }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Could not save the QuickBooks mapping.", + event: "qbo.mapping_save_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/quickbooks/status/route.ts b/app/api/admin/quickbooks/status/route.ts new file mode 100644 index 0000000..5e3b0ef --- /dev/null +++ b/app/api/admin/quickbooks/status/route.ts @@ -0,0 +1,31 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { getQboAdminView } from "@/lib/qbo"; +import { getQboMappingView } from "@/lib/qbo-mapping"; + +// Serialized QuickBooks connection view for the admin surface (issue #161). +// The view is metadata-only — token material is encrypted at rest and never +// serialized to the browser. +export async function GET(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + await requireAdminActor(idToken); + const [connection, mapping] = await Promise.all([ + getQboAdminView(), + getQboMappingView().catch(() => ({ configured: false })), + ]); + return NextResponse.json({ ok: true, connection, mapping }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "Could not load the QuickBooks connection status.", + event: "qbo.status_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/admin/quickbooks/test/route.ts b/app/api/admin/quickbooks/test/route.ts new file mode 100644 index 0000000..39cd66d --- /dev/null +++ b/app/api/admin/quickbooks/test/route.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireAdminActor } from "@/lib/admin-auth"; +import { getBearerToken, unauthorizedResponse } from "@/lib/api-auth"; +import { apiErrorResponse } from "@/lib/api-error"; +import { getRequestId } from "@/lib/log"; +import { runQboConnectionCheck } from "@/lib/qbo"; + +// Manual connection health check (issue #161): refreshes the token if +// needed and calls QBO CompanyInfo, then records the outcome. Returns the +// updated view — an unhealthy result is data, not a route failure. +export async function POST(req: NextRequest) { + const requestId = getRequestId(req.headers); + const idToken = getBearerToken(req); + if (!idToken) return unauthorizedResponse(); + + try { + const actor = await requireAdminActor(idToken); + const connection = await runQboConnectionCheck(actor); + return NextResponse.json({ ok: true, connection }); + } catch (error) { + return apiErrorResponse(error, { + fallback: "The QuickBooks connection check could not run.", + event: "qbo.check_failed", + context: { requestId }, + }); + } +} diff --git a/app/api/webhooks/quickbooks/route.ts b/app/api/webhooks/quickbooks/route.ts new file mode 100644 index 0000000..cd3ca00 --- /dev/null +++ b/app/api/webhooks/quickbooks/route.ts @@ -0,0 +1,68 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getRequestId, logError, logInfo, logWarn } from "@/lib/log"; +import { QboConfigError } from "@/lib/qbo-errors"; +import { + recordQboWebhookNotifications, + verifyQboWebhookRequest, +} from "@/lib/qbo-webhook"; + +// QuickBooks Online webhook receiver (issue #161). Not behind admin auth — +// the caller is Intuit — so the `intuit-signature` HMAC-SHA256 over the raw +// body (verified with the configured verifier token) is the trust boundary. +// Signature verification happens before any payload field is read. +// +// Failure contract (mirrors the Resend endpoint): +// 401 → invalid or unverifiable request (no retry desired) +// 200 → recorded, deduplicated, or safely ignored +// 500 → internal failure — Intuit may retry the delivery +export async function POST(req: NextRequest) { + const requestId = getRequestId(req.headers); + const rawPayload = await req.text(); + + let notifications; + try { + notifications = verifyQboWebhookRequest( + rawPayload, + req.headers.get("intuit-signature") + ); + } catch (error) { + // A missing verifier token is a deployment problem, not a forged + // request — 500 so Intuit keeps retrying rather than dropping + // notifications we could have verified once configured. + if (error instanceof QboConfigError) { + logError("qbo.webhook.config_failed", error, { requestId }); + return NextResponse.json( + { ok: false, error: "Webhook is not configured." }, + { status: 500 } + ); + } + logWarn("qbo.webhook.rejected", { requestId }); + return NextResponse.json( + { ok: false, error: "Invalid webhook." }, + { status: 401 } + ); + } + + try { + const outcome = await recordQboWebhookNotifications( + notifications, + requestId + ); + logInfo("qbo.webhook.processed", { + requestId, + received: outcome.received, + recorded: outcome.recorded, + duplicates: outcome.duplicates, + ignoredRealms: outcome.ignoredRealms, + }); + return NextResponse.json({ ok: true, ...outcome }); + } catch (error) { + // Persistence failure → 500 so Intuit retries rather than dropping a + // notification we never durably recorded. + logError("qbo.webhook.processing_failed", error, { requestId }); + return NextResponse.json( + { ok: false, error: "Webhook processing failed." }, + { status: 500 } + ); + } +} diff --git a/app/robots.ts b/app/robots.ts index 12cd9d6..ac164a2 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -13,6 +13,7 @@ export default function robots(): MetadataRoute.Robots { disallow: [ "/admin", "/admin-fixture", + "/admin-quickbooks-fixture", "/admin-trade-fixture", "/carousel-fixture", "/where-to-buy-fixture", diff --git a/components/admin-auth-gate.tsx b/components/admin-auth-gate.tsx index 59cc04e..609b32a 100644 --- a/components/admin-auth-gate.tsx +++ b/components/admin-auth-gate.tsx @@ -18,9 +18,13 @@ import { getFirebaseAuth } from "@/lib/firebase"; // without admin claims is pointed there rather than duplicating that flow. export function AdminAuthGate({ heading, + description, children, }: { heading: string; + // Surface-specific sign-in prompt — each admin sub-page names what the + // authorized account manages (trade leads, payments, QuickBooks, …). + description: string; children: (user: User) => ReactNode; }) { const [user, setUser] = useState(null); @@ -109,9 +113,7 @@ export function AdminAuthGate({ return (

{heading}

-

- Sign in with an authorized Google account to manage trade leads. -

+

{description}

diff --git a/components/admin-payments-page.tsx b/components/admin-payments-page.tsx index 27e1d93..456926c 100644 --- a/components/admin-payments-page.tsx +++ b/components/admin-payments-page.tsx @@ -5,7 +5,10 @@ import { AdminPaymentsWorkspace } from "@/components/admin-payments-workspace"; export function AdminPaymentsPage() { return ( - + {(user) => } ); diff --git a/components/admin-quickbooks-fixture.tsx b/components/admin-quickbooks-fixture.tsx new file mode 100644 index 0000000..e8cd4ca --- /dev/null +++ b/components/admin-quickbooks-fixture.tsx @@ -0,0 +1,16 @@ +"use client"; + +// Deterministic rendering of the QuickBooks integration surface for the +// Playwright suite — same pattern as admin-trade-fixture.tsx. Rendered only +// by /admin-quickbooks-fixture (gated server-side by ADMIN_A11Y_FIXTURE); +// it never touches Firebase, and /api/admin/quickbooks* calls are +// intercepted by Playwright route mocks. +import { AdminQuickbooksWorkspace } from "@/components/admin-quickbooks-workspace"; + +export function AdminQuickbooksFixture() { + return ( + "fixture-token" }} + /> + ); +} diff --git a/components/admin-quickbooks-mapping.tsx b/components/admin-quickbooks-mapping.tsx new file mode 100644 index 0000000..645fadc --- /dev/null +++ b/components/admin-quickbooks-mapping.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/button"; +import type { AdminPanelUser } from "@/components/admin-access"; +import type { + QboAccountingMapping, + QboAccountingMappingView, + QboDiscoveryEntityType, + QboEntitySummary, +} from "@/lib/qbo-common"; + +// Accounting-mapping configuration card (issue #161): the admin picks real +// entities from the connected QuickBooks company so future Stripe/tour +// revenue knows where to post. No bookkeeping happens here — this is +// configuration for the deferred sync work only. + +const FIELD_DEFS: Array<{ + key: keyof QboAccountingMapping; + label: string; + entityType: QboDiscoveryEntityType; +}> = [ + { + key: "stripeClearingAccountId", + label: "Stripe clearing / deposit account", + entityType: "account", + }, + { + key: "tourIncomeItemId", + label: "Brewery tour income item", + entityType: "item", + }, + { + key: "tastingIncomeItemId", + label: "Tasting income item", + entityType: "item", + }, + { + key: "otherIncomeItemId", + label: "Other income item", + entityType: "item", + }, + { + key: "fallbackCustomerId", + label: "Fallback customer", + entityType: "customer", + }, + { + key: "taxCodeId", + label: "Tax code (optional)", + entityType: "tax-code", + }, +]; + +const ENTITY_TYPES_NEEDED: QboDiscoveryEntityType[] = [ + "account", + "item", + "customer", + "tax-code", +]; + +const fieldClass = "w-full rounded-md border border-ink/50 px-3 py-2 text-sm"; + +interface EntitiesResponse { + ok?: boolean; + entities?: QboEntitySummary[]; + error?: string; +} + +interface MappingResponse { + ok?: boolean; + mapping?: QboAccountingMappingView; + error?: string; +} + +function entityOptionLabel(entity: QboEntitySummary): string { + const bits = [entity.name]; + if (entity.type) bits.push(`(${entity.type})`); + if (!entity.active) bits.push("(inactive)"); + return bits.join(" "); +} + +export function AdminQuickbooksMapping({ + user, + connected, + mapping, + onSaved, +}: { + user: AdminPanelUser; + connected: boolean; + mapping: QboAccountingMappingView | null; + onSaved: (mapping: QboAccountingMappingView) => void; +}) { + const [editing, setEditing] = useState(false); + const [entities, setEntities] = useState | null>(null); + const [draft, setDraft] = useState({}); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const apiFetch = useCallback( + async (path: string, init?: RequestInit): Promise => { + const idToken = await user.getIdToken(); + const res = await fetch(path, { + ...init, + headers: { + Authorization: `Bearer ${idToken}`, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + }, + }); + const data = (await res.json().catch(() => ({}))) as T & { + ok?: boolean; + error?: string; + }; + if (!res.ok || !data.ok) { + throw new Error(data.error ?? "Request failed."); + } + return data; + }, + [user] + ); + + async function startEditing() { + setError(""); + setMessage(""); + setBusy(true); + try { + const loaded: Record = {}; + await Promise.all( + ENTITY_TYPES_NEEDED.map(async (type) => { + const data = await apiFetch( + `/api/admin/quickbooks/entities?type=${type}` + ); + loaded[type] = data.entities ?? []; + }) + ); + setEntities(loaded); + setDraft(mapping?.mapping ?? {}); + setEditing(true); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Could not load QuickBooks entities." + ); + } finally { + setBusy(false); + } + } + + async function handleSave() { + setBusy(true); + setError(""); + setMessage(""); + try { + const data = await apiFetch( + "/api/admin/quickbooks/mapping", + { method: "PUT", body: JSON.stringify(draft) } + ); + if (data.mapping) onSaved(data.mapping); + setEditing(false); + setMessage("Mapping saved."); + } catch (err) { + setError( + err instanceof Error ? err.message : "Could not save the mapping." + ); + } finally { + setBusy(false); + } + } + + function nameFor(id: string | undefined): string { + if (!id) return "—"; + return mapping?.entityNames?.[id] ?? id; + } + + return ( +
+
+
+

Accounting mapping

+

+ {mapping?.configured + ? "Revenue postings will use these QuickBooks entities." + : "Accounting mapping not configured."} +

+
+ {connected && !editing && ( + + )} +
+ + {!connected && ( +

+ Connect QuickBooks to choose where revenue posts. +

+ )} + + {mapping?.configured && mapping.mapping && !editing && ( +
+ {FIELD_DEFS.map(({ key, label }) => + mapping.mapping?.[key] ? ( +
+
{label}
+
+ {nameFor(mapping.mapping[key])} +
+
+ ) : null + )} +
+ )} + + {editing && entities && ( +
+
+ {FIELD_DEFS.map(({ key, label, entityType }) => ( + + ))} +
+
+ + +
+
+ )} + + {message && ( +

+ {message} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/components/admin-quickbooks-page.tsx b/components/admin-quickbooks-page.tsx new file mode 100644 index 0000000..7f86ad7 --- /dev/null +++ b/components/admin-quickbooks-page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { AdminAuthGate } from "@/components/admin-auth-gate"; +import { AdminQuickbooksWorkspace } from "@/components/admin-quickbooks-workspace"; + +export function AdminQuickbooksPage() { + return ( + + {(user) => } + + ); +} diff --git a/components/admin-quickbooks-summary.tsx b/components/admin-quickbooks-summary.tsx new file mode 100644 index 0000000..9230331 --- /dev/null +++ b/components/admin-quickbooks-summary.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import type { AdminPanelUser } from "@/components/admin-access"; +import type { QboAdminView } from "@/lib/qbo-common"; + +// Compact entry point into /admin/integrations/quickbooks (issue #161) — +// same pattern as the trade-leads summary card: it loads the authorized +// status endpoint and a failed load still leaves a working link. +export function AdminQuickbooksSummary({ user }: { user: AdminPanelUser }) { + const [connection, setConnection] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const idToken = await user.getIdToken(); + const res = await fetch("/api/admin/quickbooks/status", { + headers: { Authorization: `Bearer ${idToken}` }, + }); + const data = (await res.json().catch(() => ({}))) as { + ok?: boolean; + connection?: QboAdminView; + }; + if (cancelled || !res.ok || !data.ok || !data.connection) return; + setConnection(data.connection); + } catch { + // Deliberately quiet — this card is a convenience entry point. + } + })(); + return () => { + cancelled = true; + }; + }, [user]); + + const summary = (() => { + if (!connection) return "QuickBooks Online integration."; + switch (connection.status) { + case "connected": + return connection.companyName + ? `Connected to ${connection.companyName} (${connection.environmentLabel}).` + : `Connected (${connection.environmentLabel}).`; + case "reauthorization_required": + return "QuickBooks needs to be reconnected."; + case "not_configured": + return "QuickBooks is not configured on this deployment."; + default: + return "QuickBooks is not connected."; + } + })(); + + return ( +
+
+

QuickBooks

+

{summary}

+
+ +
+ ); +} diff --git a/components/admin-quickbooks-workspace.tsx b/components/admin-quickbooks-workspace.tsx new file mode 100644 index 0000000..a7ec104 --- /dev/null +++ b/components/admin-quickbooks-workspace.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { formatAdminDateTime } from "@/lib/admin-format"; +import type { AdminPanelUser } from "@/components/admin-access"; +import { AdminQuickbooksMapping } from "@/components/admin-quickbooks-mapping"; +import type { + QboAccountingMappingView, + QboAdminView, +} from "@/lib/qbo-common"; + +// QuickBooks Online integration surface (issue #161) — a compact status +// card plus the accounting-mapping panel. Everything shown here is the +// serialized server view; tokens never reach the browser. + +interface StatusResponse { + ok?: boolean; + connection?: QboAdminView; + mapping?: QboAccountingMappingView; + error?: string; +} + +// Coarse, safe outcome codes from the OAuth callback redirect — provider +// detail never reaches this URL. +const CALLBACK_MESSAGES: Record = { + denied: "QuickBooks authorization was declined.", + invalid_callback: "The QuickBooks callback was incomplete — try connecting again.", + state_invalid: "The sign-in session expired — try connecting again.", + state_expired: "The sign-in session expired — try connecting again.", + state_replayed: "That QuickBooks sign-in link was already used — try connecting again.", + not_configured: "QuickBooks is not configured on this deployment.", + connect_failed: "The QuickBooks connection could not be completed.", +}; + +function healthLabel(view: QboAdminView): string { + switch (view.health) { + case "healthy": + return "Last check: healthy"; + case "needs_reauthorization": + return "Last check: reconnection required"; + case "provider_unavailable": + return "Last check: QuickBooks unavailable"; + default: + return "No connection check yet"; + } +} + +export function AdminQuickbooksWorkspace({ + user, +}: { + user: AdminPanelUser; +}) { + const [connection, setConnection] = useState(null); + const [mapping, setMapping] = useState( + null + ); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState<"connect" | "test" | "disconnect" | null>( + null + ); + const [statusMessage, setStatusMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); + + const apiFetch = useCallback( + async (path: string, init?: RequestInit): Promise => { + const idToken = await user.getIdToken(); + const res = await fetch(path, { + ...init, + headers: { + Authorization: `Bearer ${idToken}`, + ...(init?.body ? { "Content-Type": "application/json" } : {}), + }, + }); + const data = (await res.json().catch(() => ({}))) as StatusResponse; + if (!res.ok || !data.ok) { + throw new Error(data.error ?? "Request failed."); + } + return data; + }, + [user] + ); + + const load = useCallback(async () => { + setLoading(true); + try { + const data = await apiFetch("/api/admin/quickbooks/status"); + setConnection(data.connection ?? null); + setMapping(data.mapping ?? null); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Failed to load the QuickBooks status." + ); + } finally { + setLoading(false); + } + }, [apiFetch]); + + useEffect(() => { + // Surface the OAuth callback's outcome parameter, then strip it so a + // refresh doesn't replay the message. + const params = new URLSearchParams(window.location.search); + const qbo = params.get("qbo"); + if (qbo === "connected") { + setStatusMessage("QuickBooks connected successfully."); + } else if (qbo === "error") { + const reason = params.get("qbo_reason") ?? "connect_failed"; + setErrorMessage( + CALLBACK_MESSAGES[reason] ?? CALLBACK_MESSAGES.connect_failed + ); + } + if (qbo) { + window.history.replaceState(null, "", window.location.pathname); + } + void load(); + }, [load]); + + async function handleConnect() { + setBusy("connect"); + setErrorMessage(""); + setStatusMessage(""); + try { + const data = await apiFetch("/api/admin/quickbooks/connect", { + method: "POST", + }); + const url = (data as { authorizationUrl?: string }).authorizationUrl; + if (typeof url !== "string" || !url) { + throw new Error("No authorization URL returned."); + } + window.location.assign(url); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Could not start the QuickBooks connection." + ); + setBusy(null); + } + } + + async function handleTest() { + setBusy("test"); + setErrorMessage(""); + setStatusMessage(""); + try { + const data = await apiFetch("/api/admin/quickbooks/test", { + method: "POST", + }); + setConnection(data.connection ?? null); + setStatusMessage( + data.connection?.health === "healthy" + ? "Connection check succeeded." + : "Connection check completed — see status below." + ); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "The connection check could not run." + ); + } finally { + setBusy(null); + } + } + + async function handleDisconnect() { + setBusy("disconnect"); + setErrorMessage(""); + setStatusMessage(""); + try { + const data = await apiFetch("/api/admin/quickbooks/disconnect", { + method: "POST", + body: JSON.stringify({ confirm: true }), + }); + setConnection(data.connection ?? null); + setMapping(null); + setConfirmDisconnect(false); + setStatusMessage("QuickBooks disconnected."); + } catch (error) { + setErrorMessage( + error instanceof Error + ? error.message + : "Could not disconnect QuickBooks." + ); + } finally { + setBusy(null); + } + } + + const connected = connection?.status === "connected"; + const needsReconnect = connection?.status === "reauthorization_required"; + + return ( +
+
+
+
+ +
+ {connection && ( + + {connection.environmentLabel} + + )} +
+ + {loading ? ( +

+ Loading QuickBooks status… +

+ ) : !connection ? ( +

+ The QuickBooks status could not be loaded. +

+ ) : ( +
+
+ + {connection.status === "not_configured" + ? "Not configured" + : connected + ? "Connected" + : needsReconnect + ? "Reconnect required" + : "Not connected"} + +

+ {healthLabel(connection)} + {connection.lastCheckAt + ? ` · ${formatAdminDateTime(connection.lastCheckAt)}` + : ""} +

+
+ + {connection.status === "not_configured" && ( +

+ QuickBooks credentials are not set on this deployment. See the + operations guide for the required environment variables. +

+ )} + + {(connected || needsReconnect) && ( +
+
+
Company
+
+ {connection.companyName ?? "—"} +
+
+
+
Company ID
+
+ {connection.realmIdShort ?? "—"} +
+
+
+
Connected
+
+ {formatAdminDateTime(connection.connectedAt)} +
+
+
+
Connected by
+
+ {connection.connectedByEmail ?? "—"} +
+
+
+ )} + + {needsReconnect && ( +

+ QuickBooks no longer accepts the stored authorization. + Reconnect to restore the integration. +

+ )} + +
+ {(connection.status === "disconnected" || needsReconnect) && + connection.configured && ( + + )} + {needsReconnect && ( + + )} + {connected && ( + <> + + + + + )} +
+
+ )} + + {statusMessage && ( +

+ {statusMessage} +

+ )} + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ + { + setMapping(next); + setConnection((prev) => + prev ? { ...prev, mappingConfigured: next.configured } : prev + ); + }} + /> + + + + + Disconnect QuickBooks? + + + This revokes Deep Dive Brewing's access to the connected + QuickBooks company and removes the stored credentials. You can + reconnect at any time. + +
+ + +
+
+
+
+ ); +} diff --git a/components/admin-trade-page.tsx b/components/admin-trade-page.tsx index 54bfc66..6e86b39 100644 --- a/components/admin-trade-page.tsx +++ b/components/admin-trade-page.tsx @@ -5,7 +5,10 @@ import { AdminTradeWorkspace } from "@/components/admin-trade-workspace"; export function AdminTradePage() { return ( - + {(user) => } ); diff --git a/components/admin-workspace.tsx b/components/admin-workspace.tsx index 69b3169..ec49556 100644 --- a/components/admin-workspace.tsx +++ b/components/admin-workspace.tsx @@ -9,6 +9,7 @@ import { type AdminPanelUser, } from "@/components/admin-access"; import { AdminPaymentsSummary } from "@/components/admin-payments-summary"; +import { AdminQuickbooksSummary } from "@/components/admin-quickbooks-summary"; import { AdminTradeSummary } from "@/components/admin-trade-summary"; import type { Beer, Venue } from "@/lib/types"; import { VENUE_ISLAND_OPTIONS, type VenueIsland } from "@/lib/venue-islands"; @@ -202,6 +203,8 @@ export function AdminWorkspace({ + +
diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index ff318c5..cd6057b 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -69,6 +69,7 @@ Examples: - `TRADE_NOTIFICATION_EMAIL` — staff mailbox for trade alerts (legacy name `TRADE_INQUIRY_TO_EMAIL` still works as a fallback) - `TRADE_REPLY_DOMAIN` — optional; inbound domain for per-lead reply addresses (default `reply.deepdivebrewing.com`, requires Resend inbound/MX setup) - `RESEND_WEBHOOK_SECRET` — svix signing secret for `POST /api/webhooks/resend`; required for inbound email and delivery callbacks +- `QBO_CLIENT_ID`, `QBO_CLIENT_SECRET`, `QBO_ENVIRONMENT`, `QBO_REDIRECT_URI`, `QBO_WEBHOOK_VERIFIER_TOKEN`, `QBO_TOKEN_ENCRYPTION_KEY` — QuickBooks Online integration; Development credentials + sandbox belong to Preview, Production credentials belong to Production — see `docs/operations/quickbooks.md` - `VERCEL_DEPLOY_HOOK_URL` - `FIREBASE_ADMIN_PROJECT_ID` - `FIREBASE_ADMIN_CLIENT_EMAIL` diff --git a/docs/operations/quickbooks.md b/docs/operations/quickbooks.md new file mode 100644 index 0000000..381b20a --- /dev/null +++ b/docs/operations/quickbooks.md @@ -0,0 +1,236 @@ +# QuickBooks Online Integration + +Operational guide for the QuickBooks Online (QBO) connection foundation +(issue #161). This covers connecting the brewery's QuickBooks company, +keeping sandbox and production strictly separated, and operating the +integration day to day. + +**Scope:** connection foundation only — OAuth, token lifecycle, webhook +receipts, entity discovery, and accounting-mapping configuration. No +bookkeeping is posted yet (see +[What is deliberately not built](#what-is-deliberately-not-built)). + +## Architecture at a glance + +| Concern | Design | +| --- | --- | +| OAuth flow | Intuit authorization-code flow, `com.intuit.quickbooks.accounting` scope only. `POST /api/admin/quickbooks/connect` (admin-auth) issues the authorize URL and a one-time state; `GET /api/admin/quickbooks/callback` consumes it. | +| OAuth `state` | 256-bit random, persisted in `qboOauthStates/{state}`, 10-minute TTL, single-use (consumed inside a transaction), bound to the initiating admin uid and the configured environment, and cross-checked against an `httpOnly` cookie so the callback must arrive in the same browser. The callback also re-verifies that the initiating admin's `adminUsers` record is still active before exchanging the code — an admin disabled mid-flow cannot complete the connection. | +| Token storage | Access and refresh tokens are AES-256-GCM encrypted (`lib/qbo-crypto.ts`, key from `QBO_TOKEN_ENCRYPTION_KEY`) and stored only in `qboConnections/{environment}` — deny-all to Firebase clients. Tokens never appear in API responses, logs, or audit records. | +| Token refresh | `getQuickBooksAccessToken()` (`lib/qbo-tokens.ts`) is the single acquisition point. A short Firestore lease prevents concurrent refresh races; the rotated refresh token Intuit returns always replaces the stored one in the same transaction that clears the lease. An `invalid_grant` response flips the record to `reauthorization_required` — the remedy is reconnecting, not retrying. | +| Environment separation | `QBO_ENVIRONMENT` (`sandbox` \| `production`) is read server-side only and decides the Intuit API host, which credential pair is expected, and which `qboConnections` document is used. The doc id IS the environment, so a preview deployment can never address the production company record — and the stored record's `environment` field is re-verified on every read. | +| API boundary | `lib/qbo-api.ts` is the only module that talks to Intuit's v3 API (`/v3/company/{realmId}`, `minorversion=75`). Raw provider payloads never cross the boundary; callers get canonical shapes or a normalized `QboError`. The connected-company identity is the `realmId` from the OAuth callback — CompanyInfo is fetched inside that realm context as a health/identity confirmation and display-metadata source only. `CompanyInfo.Id` is provider metadata (a real sandbox Id differs from the realmId), never carried into the canonical shape, and can never replace the realm. | +| Webhooks | `POST /api/webhooks/quickbooks` verifies `intuit-signature` (HMAC-SHA256 over the raw body, keyed by `QBO_WEBHOOK_VERIFIER_TOKEN`) before parsing. Verified notifications are deduplicated by content hash into `qboWebhookReceipts`. No entity sync runs yet — receipts are the durable hook future work consumes. | +| Admin surface | `/admin/integrations/quickbooks` (linked from the admin dashboard QuickBooks card) shows environment, connection status, company name, abbreviated realm id, last health check, and the accounting-mapping panel. | +| Audit + logs | `qbo_connected`, `qbo_disconnected`, `qbo_connection_checked`, `qbo_mapping_updated` audit actions; structured `qbo.*` log events. Neither ever carries tokens, secrets, codes, or provider payloads. | + +## Environment separation + +Two independent configurations exist — they never share credentials, +redirect URIs, verifier tokens, or companies: + +| | Preview / Development | Production | +| --- | --- | --- | +| Intuit app keys | **Development** keys | **Production** keys | +| `QBO_ENVIRONMENT` | `sandbox` | `production` | +| QuickBooks company | Intuit sandbox company | Real DDB QuickBooks company | +| Vercel env scope | Preview (and Development for local) | Production | +| `QBO_REDIRECT_URI` | `https:///api/admin/quickbooks/callback` | `https://deepdivebrewing.com/api/admin/quickbooks/callback` | +| Webhook endpoint | `https:///api/webhooks/quickbooks` | `https://deepdivebrewing.com/api/webhooks/quickbooks` | + +`QBO_TOKEN_ENCRYPTION_KEY` may be the same generated value in both scopes +or different per scope — it encrypts this app's Firestore records only; +it is not an Intuit credential. + +Failing closed is intentional: an unknown `QBO_ENVIRONMENT`, missing +credentials, a non-https redirect URI outside localhost, or a missing +encryption key all abort the operation rather than guess. + +## Sandbox setup (Preview / Development) + +All Intuit-side steps happen in the +[Intuit Developer Portal](https://developer.intuit.com) against the app's +**Development** settings — never the Production settings. + +1. **Create or open the DDB app.** Sign in to the Intuit Developer Portal + → Dashboard → create an app (or open the existing Deep Dive Brewing + app). +2. **Enable the QuickBooks Online Accounting scope.** In the app's + settings/scopes, select `com.intuit.quickbooks.accounting`. That is + the only scope this integration requests. +3. **Create or open a sandbox company.** Dashboard → Sandbox → create a + sandbox QuickBooks Online company (a US or Canada sample company + works; the entity discovery reads whatever chart of accounts the + company ships with). +4. **Copy the Development Client ID and Client Secret.** Keys & OAuth → + Development keys. These go to Vercel, not into the repo. +5. **Register the Development redirect URI.** In the app's Development + redirect URIs, add the exact callback URL for the deployment, + e.g. `https:///api/admin/quickbooks/callback`. + Intuit matches redirect URIs exactly — scheme, host, and path must + all match `QBO_REDIRECT_URI`. For local development, + `http://localhost:3000/api/admin/quickbooks/callback` is also + accepted by Intuit. +6. **Add the Development webhook endpoint.** In the app's Development + webhooks section, subscribe to `https:///api/webhooks/quickbooks` + and select the entity events worth receiving (Customer, Item, + Account, Payment, SalesReceipt, Invoice are the relevant set for + future accounting work). +7. **Copy the Development webhook verifier token.** Shown on the same + webhooks settings page. Set it as `QBO_WEBHOOK_VERIFIER_TOKEN`. +8. **Set the Vercel environment variables** (Project → Settings → + Environment Variables), scoped to **Preview** (and **Development** if + you want local dev): + + | Variable | Value | + | --- | --- | + | `QBO_ENVIRONMENT` | `sandbox` | + | `QBO_CLIENT_ID` | Development client id | + | `QBO_CLIENT_SECRET` | Development client secret | + | `QBO_REDIRECT_URI` | The exact URI from step 5 | + | `QBO_WEBHOOK_VERIFIER_TOKEN` | The verifier token from step 7 | + | `QBO_TOKEN_ENCRYPTION_KEY` | `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` | + + For local dev, put the same values in `.env.local` (see + `.env.local.example`) with a localhost redirect URI. +9. **Vercel Deployment Protection.** If the project protects preview + deployments, add a narrow bypass for the OAuth callback and webhook + paths — Protection Bypass for Automation scoped to + `/api/admin/quickbooks/callback` and `/api/webhooks/quickbooks` (or + the smallest path/host rule Vercel offers). Do not disable preview + protection globally. The callback must be reachable by the admin's + browser after Intuit's redirect; the webhook must be reachable by + Intuit's servers unauthenticated — it authenticates via signature, + not Vercel auth. +10. **Connect the sandbox company.** Sign in to `/admin` on the preview + deployment → QuickBooks card → Connect QuickBooks → authorize the + **sandbox** company at Intuit. The page should return with + "QuickBooks connected" and show the sandbox company name. Use **Test + connection** to run a live CompanyInfo check. + +Verify the integration boundary while testing: + +- Browser dev tools → network: no response body may contain + `access_token`, `refresh_token`, `accessToken`, or `refreshToken` + values. +- Firestore console: `qboConnections/sandbox` contains only + `accessTokenEnc`/`refreshTokenEnc` envelopes (`v1.…`), never plaintext. + +### Verified against a real Intuit sandbox + +This flow has been exercised end to end on a preview deployment with +Intuit **Development** credentials and a US sandbox company: + +- OAuth consent completed and the callback returned `qbo=connected`; the + connected state, company name, and abbreviated realm persist and + display correctly. +- **Test connection** succeeded and health shows healthy. +- The accounting-mapping UI loaded real sandbox entities (accounts, + items, customers, payment methods, tax codes). +- A signed Intuit webhook delivery was accepted: signature verified, + connected realm recognized, receipt persisted + (`qbo.webhook.processed` — `received=1 recorded=1 duplicates=0 + ignoredRealms=0`). + +Duplicate webhook delivery is covered by automated tests; a real Intuit +resend was not performed manually. + +## Production setup + +Do this only when the integration is ready for the real company, and +always with **Production** app settings and **Production** Vercel scope. + +1. In the Intuit Developer Portal, complete whatever app profile + requirements Intuit lists for production access (app profile, terms, + host/verifier URLs — Intuit gates production keys on these). +2. Register the **Production** redirect URI: + `https://deepdivebrewing.com/api/admin/quickbooks/callback`. +3. Add the **Production** webhook endpoint: + `https://deepdivebrewing.com/api/webhooks/quickbooks` with the same + entity selections, and copy the **Production** verifier token. +4. Set the same six variables in Vercel scoped to **Production**, using + the **Production** client id/secret and + `QBO_ENVIRONMENT=production`. +5. Redeploy production (env changes never reach already-deployed + builds). +6. Connect the real DDB QuickBooks company from `/admin` on production + and confirm the company name shown is actually Deep Dive Brewing's — + the connection records which realm the OAuth flow selected, so a + wrong-company authorization is visible immediately. + +Never reuse Development credentials in Production scope or vice versa: +the realm that a sandbox credential can authorize cannot be the +production company, and vice versa — but the separation exists so a +misconfiguration fails closed instead of posting to the wrong books. + +## Day-to-day operations + +- **Test connection** calls QBO CompanyInfo through the stored + credentials (refreshing the access token first if needed) and records + the outcome as the connection's last health check. There is no + background polling. +- **Reconnect** re-runs the OAuth flow. Use it when the page shows + "Reconnect required" (Intuit revoked or expired the grant — e.g. the + refresh token aged out after ~100 days of disuse) or when the + authorized company must change. +- **Disconnect** requires confirmation, asks Intuit to revoke the grant, + and deletes all credential material locally. The connection record + keeps only historical metadata; webhook receipts and future sync + records are intentionally preserved. +- **Accounting mapping** selects real Account/Item/Customer/TaxCode + entities from the connected company. Selections are validated against + a live query at save time and bound to the environment and realm id — + a stale mapping from another company or environment is never applied. +- **Webhooks** require no admin interaction. Deliveries are deduplicated + by content identity; notifications for a realm other than the + connected company are recorded but flagged ignored. + +## What is deliberately not built + +No bookkeeping exists yet — by design. The following are deferred until +the accounting policy is chosen and PR #156 (the Stripe payment tool) is +merged: + +- Stripe → QBO Sales Receipt / Invoice + Payment / Deposit creation +- Journal entries, refunds, payout and bank-feed reconciliation +- Automated tax mapping beyond the optional tax-code selection +- Any entity writes to QuickBooks at all — every current API call is + read-only + +The seam for that work is `enqueueAccountingTransaction()` +(`lib/qbo-sync.ts`): a future producer hands it a +`QuickBooksSyncCandidate` (amount, currency, date, customer hints, +purpose, external refs). The sync record's document id is the +deterministic `sourceType:sourceId` identity, so retries and replays can +never post the same financial event twice — QBO entity ids recorded +later are correlation references, not the dedupe mechanism. + +## Troubleshooting + +### Intuit transaction IDs (`intuit_tid`) + +Every Intuit API and OAuth response carries an `intuit_tid` response +header — Intuit's per-request correlation id. The app captures it +server-side at the provider boundary (`lib/qbo-api.ts`, +`lib/qbo-tokens.ts`): + +- successful CompanyInfo and entity-discovery calls log it as + `correlationId` on `qbo.api.companyinfo` / `qbo.api.entities` + (one line per operation, not per query page); +- token refreshes log it on `qbo.token.refreshed`; +- any failed provider call keeps it on the normalized `QboError`, which + `logError`/`logWarn` emit as `error.correlationId` automatically. + +When Intuit support asks about a specific request, grep Vercel logs for +the `qbo.*` event and quote the `correlationId`. It is safe operational +metadata — a request correlation id, not authentication material — and +it is never sent to the browser. + +| Symptom | Likely cause | Check | +| --- | --- | --- | +| "Not configured" on the admin page | Missing `QBO_*` env vars in this deployment's scope | Vercel env scope (Preview vs Production); redeploy after changes | +| Connect redirects back with `state_expired`/`state_invalid` | OAuth flow took >10 min, or the callback hit a different browser/deployment than the connect request | Retry connect; verify `QBO_REDIRECT_URI` exactly matches the registered URI | +| "Reconnect required" | Grant revoked at Intuit, or refresh token expired from disuse | Reconnect; verify the authorized company on return | +| "Connection check completed" but health shows unavailable | Intuit outage or transient failure | Retry later; check Intuit status if persistent | +| Webhooks never arrive | Wrong endpoint registered, or Vercel protection blocking Intuit | Intuit app's webhook config for the matching environment; Deployment Protection bypass for `/api/webhooks/quickbooks` | +| Mapping save rejected with "does not exist" | Selected entity id isn't in the connected company | Re-load entity lists; the mapping validates against live QBO data | diff --git a/firestore.rules b/firestore.rules index 8dba3e7..d7ee437 100644 --- a/firestore.rules +++ b/firestore.rules @@ -100,6 +100,32 @@ service cloud.firestore { allow read, write: if false; } + // QuickBooks Online integration (#161): OAuth state, encrypted + // connection credentials, webhook dedupe receipts, accounting-mapping + // configuration, and future sync records. Everything here is + // credential/financial-adjacent and flows exclusively through the + // /api/admin/quickbooks* routes and /api/webhooks/quickbooks via the + // Admin SDK. No client may read or write any of it. + match /qboConnections/{docId} { + allow read, write: if false; + } + + match /qboOauthStates/{docId} { + allow read, write: if false; + } + + match /qboWebhookReceipts/{docId} { + allow read, write: if false; + } + + match /qboConfig/{docId} { + allow read, write: if false; + } + + match /qboSyncRecords/{docId} { + allow read, write: if false; + } + match /meta/{docId} { allow read, write: if hasActiveAdmin(); } diff --git a/lib/admin-types.ts b/lib/admin-types.ts index 36f751e..e331fef 100644 --- a/lib/admin-types.ts +++ b/lib/admin-types.ts @@ -39,7 +39,13 @@ export interface AdminAuditRecord { | "cancel_invitation" | "update_admin" | "revoke_admin" - | "refresh_claims"; + | "refresh_claims" + // QuickBooks Online integration lifecycle (issue #161). Metadata carries + // environment/outcome only — never token material or provider payloads. + | "qbo_connected" + | "qbo_disconnected" + | "qbo_connection_checked" + | "qbo_mapping_updated"; targetUid?: string; targetEmail?: string; oldRole?: AdminRole | null; diff --git a/lib/log.ts b/lib/log.ts index 752e871..5ccf3a2 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -41,6 +41,10 @@ export interface NormalizedError { name: string; message: string; code?: string; + /** Provider correlation id (e.g. Intuit's `intuit_tid`) carried by a + * normalized provider error — safe operational metadata for support + * triage, never a credential. */ + correlationId?: string; stack?: string; } @@ -48,7 +52,11 @@ export function normalizeError(error: unknown): NormalizedError { // Error instances and provider error objects (e.g. Resend's // { name, message, statusCode }) are normalized the same way. if (error instanceof Error || isErrorLike(error)) { - const err = error as Error & { code?: unknown; statusCode?: unknown }; + const err = error as Error & { + code?: unknown; + statusCode?: unknown; + correlationId?: unknown; + }; const code = err.code ?? err.statusCode; return { name: err.name || "Error", @@ -56,6 +64,9 @@ export function normalizeError(error: unknown): NormalizedError { ...(typeof code === "string" || typeof code === "number" ? { code: String(code) } : {}), + ...(typeof err.correlationId === "string" && err.correlationId + ? { correlationId: err.correlationId } + : {}), ...(err.stack ? { stack: err.stack } : {}), }; } diff --git a/lib/qbo-api.ts b/lib/qbo-api.ts new file mode 100644 index 0000000..d5aaf29 --- /dev/null +++ b/lib/qbo-api.ts @@ -0,0 +1,154 @@ +import "server-only"; +import { logInfo } from "@/lib/log"; +import { + buildQboQueryUrl, + canonicalizeCompanyInfo, + canonicalizeQboQueryEntities, + qboApiUrl, + qboEntityQueryStatement, + qboErrorForHttpStatus, + qboQueryRowCount, + QBO_QUERY_MAX_PAGES, + QBO_QUERY_PAGE_SIZE, + toQboError, +} from "@/lib/qbo-protocol"; +import type { + QboDiscoveryEntityType, + QboEntitySummary, + QboEnvironment, +} from "@/lib/qbo-common"; + +// Low-level QuickBooks API client boundary (issue #161). This is the only +// module that speaks to the Intuit v3 endpoints: callers pass a resolved +// access token (token lifecycle lives in lib/qbo.ts) and get back canonical +// shapes or a normalized QboError. Raw provider payloads never leave this +// module. + +interface QboApiFetchInput { + environment: QboEnvironment; + realmId: string; + accessToken: string; + /** Path under /v3/company/{realmId}, or a fully-built URL (query). */ + path?: string; + url?: string; + method?: "GET" | "POST"; + body?: unknown; +} + +interface QboApiFetchResult { + payload: unknown; + /** Intuit's per-request correlation id (`intuit_tid` response header). + * Safe operational metadata — it identifies the provider request for + * Intuit support without exposing any credential or payload. */ + correlationId?: string; +} + +async function qboApiFetch( + input: QboApiFetchInput +): Promise { + const url = + input.url ?? + qboApiUrl(input.environment, input.realmId, input.path ?? ""); + const headers: Record = { + Authorization: `Bearer ${input.accessToken}`, + Accept: "application/json", + }; + const init: RequestInit = { method: input.method ?? "GET", headers }; + if (input.body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(input.body); + } + + let res: Response; + try { + res = await fetch(url, init); + } catch (error) { + throw toQboError(error, "request"); + } + // Captured before status/body handling so error and parse-failure paths + // keep the provider transaction id. + const correlationId = res.headers.get("intuit_tid") ?? undefined; + if (!res.ok) { + throw qboErrorForHttpStatus(res.status, { correlationId }); + } + try { + return { payload: await res.json(), correlationId }; + } catch { + throw toQboError(new Error("non-JSON response"), "response parsing", { + correlationId, + }); + } +} + +export interface QboCompanyInfoResult { + companyName: string; + country?: string; +} + +// CompanyInfo is the canonical "does this connection work" probe — the +// request itself is scoped to the connected realm, and the response +// provides the display metadata (company name, country) the admin UI +// surfaces. The realm id is not derived from the response: CompanyInfo.Id +// is provider metadata, never the OAuth realm identity. +export async function fetchQboCompanyInfo(input: { + environment: QboEnvironment; + realmId: string; + accessToken: string; +}): Promise { + const { payload, correlationId } = await qboApiFetch({ + ...input, + path: `/companyinfo/${encodeURIComponent(input.realmId)}`, + }); + // CompanyInfo is the connect/health probe — the Intuit transaction id + // here is what an operator quotes when troubleshooting a connection. + logInfo("qbo.api.companyinfo", { + environment: input.environment, + correlationId, + }); + return canonicalizeCompanyInfo(payload); +} + +// Read-only entity discovery used by the future accounting-mapping +// configuration surface. Returns safe summaries only. Pages through the +// query endpoint — QBO defaults to 100 rows when no MAXRESULTS is given, +// so a real company's chart of accounts or customer list would silently +// truncate on a single bare select. +export async function queryQboEntities(input: { + environment: QboEnvironment; + realmId: string; + accessToken: string; + type: QboDiscoveryEntityType; +}): Promise { + const out: QboEntitySummary[] = []; + // Log once per discovery run, not per page — a paged query can make up + // to QBO_QUERY_MAX_PAGES requests and each page carries its own tid. + // The first page's tid identifies the operation for support purposes. + let correlationId: string | undefined; + let pages = 0; + for (let page = 0; page < QBO_QUERY_MAX_PAGES; page++) { + const result = await qboApiFetch({ + ...input, + url: buildQboQueryUrl( + input.environment, + input.realmId, + qboEntityQueryStatement( + input.type, + page * QBO_QUERY_PAGE_SIZE + 1, + QBO_QUERY_PAGE_SIZE + ) + ), + }); + pages++; + correlationId ??= result.correlationId; + out.push(...canonicalizeQboQueryEntities(input.type, result.payload)); + if (qboQueryRowCount(input.type, result.payload) < QBO_QUERY_PAGE_SIZE) + break; + } + logInfo("qbo.api.entities", { + environment: input.environment, + type: input.type, + pages, + correlationId, + }); + return out; +} diff --git a/lib/qbo-callback-cookie.ts b/lib/qbo-callback-cookie.ts new file mode 100644 index 0000000..fd9d8fa --- /dev/null +++ b/lib/qbo-callback-cookie.ts @@ -0,0 +1,4 @@ +// The httpOnly cookie that binds an in-flight QuickBooks OAuth flow to the +// initiating browser (issue #161). Shared between the connect route (which +// sets it) and the callback route (which cross-checks it). +export const QBO_OAUTH_COOKIE = "qbo_oauth_state"; diff --git a/lib/qbo-common.ts b/lib/qbo-common.ts new file mode 100644 index 0000000..32cb297 --- /dev/null +++ b/lib/qbo-common.ts @@ -0,0 +1,274 @@ +// Shared, dependency-free vocabulary for the QuickBooks Online (QBO) +// integration (issue #161). Firebase-free so admin client components may +// import the view/label types; all persistence and provider IO lives in the +// server-only modules (lib/qbo.ts, lib/qbo-api.ts, lib/qbo-webhook.ts, +// lib/qbo-sync.ts). + +// --- Environment --- + +export type QboEnvironment = "sandbox" | "production"; + +export const QBO_ENVIRONMENTS: readonly QboEnvironment[] = [ + "sandbox", + "production", +]; + +export function isQboEnvironment(value: unknown): value is QboEnvironment { + return value === "sandbox" || value === "production"; +} + +export function qboEnvironmentLabel(environment: QboEnvironment): string { + return environment === "production" ? "Production" : "Sandbox"; +} + +// --- Collection names (all deny-all to Firebase clients — firestore.rules) --- + +export const QBO_CONNECTIONS_COLLECTION = "qboConnections"; +export const QBO_OAUTH_STATES_COLLECTION = "qboOauthStates"; +export const QBO_WEBHOOK_RECEIPTS_COLLECTION = "qboWebhookReceipts"; +export const QBO_CONFIG_COLLECTION = "qboConfig"; +export const QBO_SYNC_RECORDS_COLLECTION = "qboSyncRecords"; + +export const QBO_ACCOUNTING_MAPPING_DOC = "accountingMapping"; + +// --- Connection state --- + +// `not_configured` — the deployment lacks the required QBO_* env vars. +// `disconnected` — configured but no live connection record. +// `connected` — a usable connection exists. +// `reauthorization_required` — Intuit revoked or rejected the stored grant; +// an admin must run the OAuth flow again. +export type QboConnectionStatus = + | "not_configured" + | "disconnected" + | "connected" + | "reauthorization_required"; + +// Health is the last observed outcome of an authenticated API probe, not a +// recomputed promise — the admin UI shows it as "last check" information. +export type QboHealthStatus = + | "unknown" + | "healthy" + | "needs_reauthorization" + | "provider_unavailable"; + +// Serialized admin-facing connection view. The server must never include +// access/refresh tokens or any credential material in this shape. +export interface QboAdminView { + configured: boolean; + environment: QboEnvironment; + environmentLabel: string; + status: QboConnectionStatus; + health: QboHealthStatus; + realmIdShort?: string; + companyName?: string; + companyCountry?: string; + connectedAt?: string; + connectedByEmail?: string; + lastCheckAt?: string; + mappingConfigured: boolean; +} + +// realmId is an opaque Intuit identifier; show only a short form so an +// admin can confirm "which company" without the full value traveling to the +// browser. +export function abbreviateRealmId(realmId: string): string { + const clean = realmId.trim(); + if (clean.length <= 8) return clean; + return `${clean.slice(0, 4)}…${clean.slice(-4)}`; +} + +// --- Accounting discovery entities --- + +// Read-only entity types queried for the future mapping surface. The keys +// are also the `type` query-parameter values accepted by the entities +// route — keep the vocabulary small and explicit. +export const QBO_DISCOVERY_ENTITY_TYPES = [ + "account", + "item", + "customer", + "payment-method", + "tax-code", +] as const; + +export type QboDiscoveryEntityType = + (typeof QBO_DISCOVERY_ENTITY_TYPES)[number]; + +export function isQboDiscoveryEntityType( + value: unknown +): value is QboDiscoveryEntityType { + return ( + typeof value === "string" && + (QBO_DISCOVERY_ENTITY_TYPES as readonly string[]).includes(value) + ); +} + +// Canonical safe shape for a QBO entity shown to admins. Raw QBO records +// never cross the provider boundary. +export interface QboEntitySummary { + id: string; + name: string; + /** e.g. AccountType ("Income", "Bank") or Item Type ("Service"). */ + type?: string; + /** e.g. AccountSubType when it disambiguates the pick. */ + detail?: string; + active: boolean; +} + +// --- Accounting mapping configuration --- + +// Where future Stripe/tour revenue posts in QBO. Every value is a QBO +// entity Id discovered from the *connected* company — never hard-coded. +export interface QboAccountingMapping { + /** Account Stripe/tour receipts clear through (e.g. a clearing or + * undeposited-funds style account). */ + stripeClearingAccountId?: string; + /** Item used for brewery-tour income lines. */ + tourIncomeItemId?: string; + /** Item used for tasting/flight income lines. */ + tastingIncomeItemId?: string; + /** Item used for other ad-hoc income lines. */ + otherIncomeItemId?: string; + /** Customer used when no better QBO customer match exists. */ + fallbackCustomerId?: string; + /** Optional tax code when the company's tax setup requires one. */ + taxCodeId?: string; +} + +export interface QboAccountingMappingView { + configured: boolean; + mapping?: QboAccountingMapping; + /** Display names resolved at save/read time when known. */ + entityNames?: Record; + updatedAt?: string; +} + +// --- Future accounting sync contract (issue #161 boundary for #156) --- + +// The domain facts a future producer (e.g. the Stripe payments module) +// supplies. The QBO side owns how these map onto accounting entities; the +// producer must not decide bookkeeping representation. +export interface QuickBooksSyncCandidate { + /** Stable source vocabulary, e.g. "stripe_payment". */ + sourceType: string; + /** The producer's durable internal id for the financial event. */ + sourceId: string; + amountMinorUnits: number; + /** Uppercase ISO-4217 code. */ + currency: string; + /** ISO date or datetime of the financial event. */ + transactionDate: string; + customerName?: string; + customerEmail?: string; + /** e.g. "brewery_tour", "tasting", "ad_hoc". */ + purpose?: string; + description?: string; + /** Opaque provider references (PaymentIntent/Charge/receipt ids). */ + externalRefs?: Record; + tourDate?: string; + attendeeCount?: number; +} + +export type QboSyncStatus = + | "pending" + | "syncing" + | "synced" + | "failed" + | "needs_attention"; + +// Deterministic internal sync-record key. The internal source identity is +// the idempotency anchor — a future QBO entity id is correlation only, so +// retries, webhook replays, and browser refreshes can never create two QBO +// transactions for the same source event. The environment is part of the +// identity: the same Firestore database may host a sandbox connection now +// and a production one later, and each must get its own record. +export function qboSyncIdFor( + environment: QboEnvironment, + sourceType: string, + sourceId: string +): string { + const type = sourceType.trim(); + const id = sourceId.trim(); + if (!type || !id) { + throw new Error("qboSyncIdFor requires a non-empty sourceType and sourceId."); + } + return `${environment}:${type}:${id}`; +} + +const SYNC_SOURCE_TYPE_PATTERN = /^[a-z0-9_]{1,40}$/; +const SYNC_CURRENCY_PATTERN = /^[A-Z]{3}$/; + +// Validates and normalizes a producer-supplied sync candidate. Throws a +// plain Error on malformed input — the caller (server route or internal +// producer) treats it as a validation failure. +export function normalizeQboSyncCandidate( + input: QuickBooksSyncCandidate +): QuickBooksSyncCandidate { + if (typeof input !== "object" || input === null) { + throw new Error("Sync candidate must be an object."); + } + const sourceType = (input.sourceType ?? "").trim(); + if (!SYNC_SOURCE_TYPE_PATTERN.test(sourceType)) { + throw new Error("Sync candidate sourceType is invalid."); + } + const sourceId = (input.sourceId ?? "").trim(); + if (!sourceId || sourceId.length > 128) { + throw new Error("Sync candidate sourceId is missing or too long."); + } + if (!Number.isInteger(input.amountMinorUnits)) { + throw new Error("Sync candidate amountMinorUnits must be an integer."); + } + const currency = (input.currency ?? "").trim().toUpperCase(); + if (!SYNC_CURRENCY_PATTERN.test(currency)) { + throw new Error("Sync candidate currency must be a 3-letter ISO code."); + } + const transactionDate = (input.transactionDate ?? "").trim(); + if (!transactionDate || Number.isNaN(new Date(transactionDate).getTime())) { + throw new Error("Sync candidate transactionDate is invalid."); + } + + const externalRefs: Record = {}; + if (input.externalRefs) { + for (const [key, value] of Object.entries(input.externalRefs)) { + if (Object.keys(externalRefs).length >= 20) break; + if (typeof value !== "string" || !value || value.length > 128) { + continue; + } + externalRefs[key.trim().slice(0, 64)] = value; + } + } + + const optionalString = (value: unknown, max: number): string | undefined => + typeof value === "string" && value.trim() ? value.trim().slice(0, max) : undefined; + + return { + sourceType, + sourceId, + amountMinorUnits: input.amountMinorUnits, + currency, + transactionDate, + customerName: optionalString(input.customerName, 200), + customerEmail: optionalString(input.customerEmail, 254), + purpose: optionalString(input.purpose, 60), + description: optionalString(input.description, 500), + externalRefs: Object.keys(externalRefs).length ? externalRefs : undefined, + tourDate: optionalString(input.tourDate, 40), + attendeeCount: + Number.isInteger(input.attendeeCount) && (input.attendeeCount ?? 0) > 0 + ? input.attendeeCount + : undefined, + }; +} + +// Firestore rejects `undefined` property values at write time, and the +// normalizer deliberately emits `undefined` for absent optional fields. +// Strip them before the candidate is persisted inside the sync record. +// Nested values are already concrete (`externalRefs` only ever holds +// validated strings), so a shallow pass is sufficient. +export function persistableQboSyncCandidate( + candidate: QuickBooksSyncCandidate +): QuickBooksSyncCandidate { + return Object.fromEntries( + Object.entries(candidate).filter(([, value]) => value !== undefined) + ) as QuickBooksSyncCandidate; +} diff --git a/lib/qbo-config.ts b/lib/qbo-config.ts new file mode 100644 index 0000000..f52c82b --- /dev/null +++ b/lib/qbo-config.ts @@ -0,0 +1,128 @@ +// Pure configuration lookup for the QuickBooks Online integration +// (issue #161). Kept server-import-only in practice (it reads secrets) but +// dependency-free like lib/resend-config.ts so the validation is +// unit-testable. +// +// Environment separation is explicit: QBO_ENVIRONMENT decides which Intuit +// credential pair and which QBO company universe this deployment talks to. +// Sandbox and production never share a base URL, and an unrecognized value +// fails closed — the app refuses to guess where financial data would go. +import { isQboEnvironment, type QboEnvironment } from "@/lib/qbo-common"; +import { parseQboEncryptionKey } from "@/lib/qbo-crypto"; +import { QboConfigError } from "@/lib/qbo-errors"; + +// Intuit OAuth 2.0 endpoints — identical for development and production +// credentials (per the Intuit OpenID discovery document). +export const QBO_AUTHORIZE_URL = + "https://appcenter.intuit.com/connect/oauth2"; +export const QBO_TOKEN_URL = + "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"; +export const QBO_REVOKE_URL = + "https://developer.api.intuit.com/v2/oauth2/tokens/revoke"; + +export const QBO_ACCOUNTING_SCOPE = "com.intuit.quickbooks.accounting"; + +// Intuit deprecated minor versions 1–74 on 2025-08-01; requests below 75 are +// silently upgraded, so pin the current floor explicitly. +export const QBO_MINOR_VERSION = "75"; + +const QBO_API_BASES: Record = { + sandbox: "https://sandbox-quickbooks.api.intuit.com", + production: "https://quickbooks.api.intuit.com", +}; + +// The QuickBooks API host is a function of the *configured* environment +// only — never derived from request input. +export function qboApiBaseUrl(environment: QboEnvironment): string { + return QBO_API_BASES[environment]; +} + +export function getQboEnvironment( + raw: string | undefined = process.env.QBO_ENVIRONMENT +): QboEnvironment { + const value = (raw ?? "").trim().toLowerCase(); + if (isQboEnvironment(value)) return value; + if (!value) { + throw new QboConfigError( + 'QBO_ENVIRONMENT is not configured (expected "sandbox" or "production").' + ); + } + throw new QboConfigError( + 'QBO_ENVIRONMENT has an unsupported value — expected "sandbox" or "production".' + ); +} + +export interface QboAppConfig { + environment: QboEnvironment; + clientId: string; + clientSecret: string; + redirectUri: string; +} + +// Non-throwing probe for the admin status surface: a deployment without the +// integration configured should render "not configured", not an error. The +// encryption key is validated too — without it the OAuth callback would fail +// only after the admin had already completed Intuit consent. +export function isQboConfigured(): boolean { + try { + getQboEnvironment(); + parseQboEncryptionKey(process.env.QBO_TOKEN_ENCRYPTION_KEY); + } catch { + return false; + } + return Boolean( + process.env.QBO_CLIENT_ID?.trim() && + process.env.QBO_CLIENT_SECRET?.trim() && + process.env.QBO_REDIRECT_URI?.trim() + ); +} + +function getQboRedirectUri(): string { + const raw = process.env.QBO_REDIRECT_URI?.trim(); + if (!raw) { + throw new QboConfigError("QBO_REDIRECT_URI is not configured."); + } + let url: URL; + try { + url = new URL(raw); + } catch { + throw new QboConfigError("QBO_REDIRECT_URI is not a valid URL."); + } + // OAuth redirect targets must be HTTPS outside local development — + // Intuit enforces the same rule when the URI is registered. + const isLocalhost = + url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (url.protocol !== "https:" && !(isLocalhost && url.protocol === "http:")) { + throw new QboConfigError( + "QBO_REDIRECT_URI must be an https URL (http is allowed for localhost only)." + ); + } + return raw; +} + +// Loads and validates the OAuth application config. Missing or invalid +// values throw QboConfigError — the integration fails closed rather than +// issuing a request with partial credentials. +export function loadQboConfig(): QboAppConfig { + const clientId = process.env.QBO_CLIENT_ID?.trim(); + if (!clientId) { + throw new QboConfigError("QBO_CLIENT_ID is not configured."); + } + const clientSecret = process.env.QBO_CLIENT_SECRET?.trim(); + if (!clientSecret) { + throw new QboConfigError("QBO_CLIENT_SECRET is not configured."); + } + return { + environment: getQboEnvironment(), + clientId, + clientSecret, + redirectUri: getQboRedirectUri(), + }; +} + +// Verifier token for POST /api/webhooks/quickbooks — the `intuit-signature` +// HMAC key. Unset means the endpoint is not configured and must refuse +// work rather than process unverified events. +export function getQboWebhookVerifierToken(): string | undefined { + return process.env.QBO_WEBHOOK_VERIFIER_TOKEN?.trim() || undefined; +} diff --git a/lib/qbo-crypto.ts b/lib/qbo-crypto.ts new file mode 100644 index 0000000..6bd4a9c --- /dev/null +++ b/lib/qbo-crypto.ts @@ -0,0 +1,94 @@ +// Application-level encryption for QuickBooks OAuth token material at rest +// (issue #161). Bearer and refresh tokens are credentials; they are stored +// in Firestore only inside an authenticated-encryption envelope, never as +// plaintext fields. +// +// Envelope: `v1...` — +// AES-256-GCM with a random 12-byte nonce per encryption. The `v1` prefix +// versions the envelope so a future key/algorithm rotation can read old +// records deliberately rather than guessing. +// +// The key arrives via QBO_TOKEN_ENCRYPTION_KEY as 64 hex chars or a +// base64-encoded 32-byte value. Missing or malformed keys fail closed: +// encrypting nothing is always better than storing tokens weakly. +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { QboConfigError, QboError } from "@/lib/qbo-errors"; + +const ENVELOPE_VERSION = "v1"; +const IV_BYTES = 12; +const KEY_BYTES = 32; + +export function parseQboEncryptionKey(raw: string | undefined): Buffer { + const value = (raw ?? "").trim(); + if (!value) { + throw new QboConfigError("QBO_TOKEN_ENCRYPTION_KEY is not configured."); + } + let key: Buffer | null = null; + if (/^[0-9a-fA-F]{64}$/.test(value)) { + key = Buffer.from(value, "hex"); + } else { + try { + const decoded = Buffer.from(value, "base64"); + if (decoded.length === KEY_BYTES) key = decoded; + } catch { + key = null; + } + } + if (!key || key.length !== KEY_BYTES) { + throw new QboConfigError( + "QBO_TOKEN_ENCRYPTION_KEY must be 64 hex characters or a base64-encoded 32-byte key." + ); + } + return key; +} + +export function encryptQboSecret(plaintext: string, key: Buffer): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return [ + ENVELOPE_VERSION, + iv.toString("base64"), + tag.toString("base64"), + ciphertext.toString("base64"), + ].join("."); +} + +// Decrypts an envelope produced by encryptQboSecret. Any malformed shape or +// authentication failure is a single opaque error — no partial plaintext +// and no detail an attacker could use ever escapes. +export function decryptQboSecret(envelope: string, key: Buffer): string { + const parts = envelope.split("."); + if (parts.length !== 4 || parts[0] !== ENVELOPE_VERSION) { + throw new QboError( + "Stored QuickBooks credential is not in a recognized format.", + "unexpected" + ); + } + try { + const iv = Buffer.from(parts[1], "base64"); + const tag = Buffer.from(parts[2], "base64"); + const ciphertext = Buffer.from(parts[3], "base64"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString("utf8"); + } catch { + throw new QboError( + "Stored QuickBooks credential could not be decrypted.", + "unexpected" + ); + } +} + +// The deployment's configured encryption key — resolves lazily so builds +// and unconfigured environments never touch it. +export function getQboEncryptionKey(): Buffer { + return parseQboEncryptionKey(process.env.QBO_TOKEN_ENCRYPTION_KEY); +} diff --git a/lib/qbo-errors.ts b/lib/qbo-errors.ts new file mode 100644 index 0000000..106e8f2 --- /dev/null +++ b/lib/qbo-errors.ts @@ -0,0 +1,60 @@ +// Normalized error taxonomy for the QuickBooks Online provider boundary +// (issue #161). Provider responses, OAuth failures, and transport errors +// never cross this boundary raw — they can echo request internals and +// token material. Callers see only the kind, a safe message, an HTTP-ish +// status for the admin API, and a correlation id when Intuit supplies one +// (`intuit_tid`). + +export type QboErrorKind = + // The integration is not correctly configured on this deployment + // (missing env, bad encryption key, unsupported environment). + | "configuration" + // Intuit rejected the grant — refresh token revoked/expired or the access + // token is no longer honored. The remedy is reauthorization. + | "authorization_expired" + // A refresh attempt failed for a non-authorization reason. + | "refresh_failed" + // The grant is valid but cannot perform the operation (HTTP 403). + | "permission_denied" + // The provider rejected the request payload (HTTP 400-family). + | "validation" + // Intuit throttled the request (HTTP 429). + | "rate_limited" + // Transport failure or provider 5xx — retryable once idempotent. + | "unavailable" + // Anything that doesn't fit the above — treated as provider-side. + | "unexpected"; + +const DEFAULT_STATUS: Record = { + configuration: 500, + authorization_expired: 409, + refresh_failed: 502, + permission_denied: 403, + validation: 400, + rate_limited: 429, + unavailable: 502, + unexpected: 502, +}; + +export class QboError extends Error { + // Safe to surface `message`/`status` to admin callers — see + // lib/api-error.ts. Messages must never embed provider bodies or tokens. + public readonly clientSafe = true; + + constructor( + message: string, + public readonly kind: QboErrorKind, + public status: number = DEFAULT_STATUS[kind], + public readonly correlationId?: string + ) { + super(message); + this.name = "QboError"; + } +} + +export class QboConfigError extends QboError { + constructor(message: string) { + super(message, "configuration"); + this.name = "QboConfigError"; + } +} diff --git a/lib/qbo-mapping.ts b/lib/qbo-mapping.ts new file mode 100644 index 0000000..1920857 --- /dev/null +++ b/lib/qbo-mapping.ts @@ -0,0 +1,197 @@ +import "server-only"; +import { Timestamp } from "firebase-admin/firestore"; +import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; +import { logAdminAudit } from "@/lib/admin-audit"; +import type { AdminActor } from "@/lib/admin-auth"; +import { toIsoString } from "@/lib/admin-serializers"; +import { getQuickBooksAccessToken } from "@/lib/qbo-tokens"; +import { queryQboEntities } from "@/lib/qbo-api"; +import { getQboEnvironment } from "@/lib/qbo-config"; +import { QboError } from "@/lib/qbo-errors"; +import { logWarn } from "@/lib/log"; +import { + QBO_ACCOUNTING_MAPPING_DOC, + QBO_CONFIG_COLLECTION, + QBO_CONNECTIONS_COLLECTION, + type QboAccountingMapping, + type QboAccountingMappingView, + type QboDiscoveryEntityType, + type QboEnvironment, +} from "@/lib/qbo-common"; + +// Future accounting-mapping configuration (issue #161). The mapping records +// which QBO entities (Account/Item/Customer/TaxCode) later Stripe/tour +// revenue should post against — selected from live entities of the +// *connected* company, never hard-coded. Until configured, the admin +// surface reports "Accounting mapping not configured". +// +// The document is bound to an environment AND a realmId: a stale mapping +// from a different company or a different QBO_ENVIRONMENT is never applied. + +interface QboMappingDoc extends QboAccountingMapping { + environment: QboEnvironment; + realmId: string; + entityNames?: Record; + updatedAt?: Timestamp; + updatedByUid?: string; +} + +function mappingRef() { + return getFirebaseAdminDb() + .collection(QBO_CONFIG_COLLECTION) + .doc(QBO_ACCOUNTING_MAPPING_DOC); +} + +async function connectedRealmId( + environment: QboEnvironment +): Promise { + const snap = await getFirebaseAdminDb() + .collection(QBO_CONNECTIONS_COLLECTION) + .doc(environment) + .get(); + const data = snap.data(); + if ( + !data || + data.environment !== environment || + data.status !== "connected" || + typeof data.realmId !== "string" || + !data.realmId + ) { + return null; + } + return data.realmId; +} + +// Whether a usable mapping exists for the live connection — used by the +// admin status view. Reads the connection doc itself so this module has no +// import dependency on lib/qbo.ts view code. +export async function getQboMappingConfigured( + environment: QboEnvironment, + realmId?: string +): Promise { + const snap = await mappingRef().get(); + const data = snap.data() as QboMappingDoc | undefined; + if (!data || data.environment !== environment) return false; + return Boolean(realmId && data.realmId === realmId); +} + +export async function getQboMappingView(): Promise { + const environment = getQboEnvironment(); + const realmId = await connectedRealmId(environment); + const snap = await mappingRef().get(); + const data = snap.data() as QboMappingDoc | undefined; + if (!data || data.environment !== environment || !realmId || data.realmId !== realmId) { + return { configured: false }; + } + const mapping: QboAccountingMapping = {}; + for (const { key } of MAPPING_FIELDS) { + const value = data[key]; + if (value) mapping[key] = value; + } + return { + configured: true, + mapping, + entityNames: data.entityNames, + updatedAt: toIsoString(data.updatedAt), + }; +} + +const MAPPING_FIELDS: Array<{ + key: keyof QboAccountingMapping; + entityType: QboDiscoveryEntityType; +}> = [ + { key: "stripeClearingAccountId", entityType: "account" }, + { key: "tourIncomeItemId", entityType: "item" }, + { key: "tastingIncomeItemId", entityType: "item" }, + { key: "otherIncomeItemId", entityType: "item" }, + { key: "fallbackCustomerId", entityType: "customer" }, + { key: "taxCodeId", entityType: "tax-code" }, +]; + +const QBO_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +// Saves the admin's entity selections after validating every id against a +// live entity query — a typo or stale id can never be persisted as the +// accounting target. Display names resolved from the same query are stored +// so the admin view doesn't need a second lookup. +export async function saveQboMapping( + actor: AdminActor, + input: unknown +): Promise { + const environment = getQboEnvironment(); + const realmId = await connectedRealmId(environment); + if (!realmId) { + throw new QboError( + "QuickBooks must be connected before accounting mapping can be configured.", + "validation", + 409 + ); + } + + if (typeof input !== "object" || input === null) { + throw new QboError("Invalid mapping payload.", "validation", 400); + } + const raw = input as Record; + const mapping: QboAccountingMapping = {}; + for (const { key } of MAPPING_FIELDS) { + const value = raw[key]; + if (value === undefined || value === null || value === "") continue; + if (typeof value !== "string" || !QBO_ID_PATTERN.test(value)) { + throw new QboError(`Invalid QuickBooks id for ${key}.`, "validation", 400); + } + mapping[key] = value; + } + + // Resolve every referenced entity type once, then validate membership. + const { accessToken } = await getQuickBooksAccessToken(); + const typesNeeded = new Set( + MAPPING_FIELDS.filter(({ key }) => mapping[key]).map( + ({ entityType }) => entityType + ) + ); + const entityNames: Record = {}; + for (const type of typesNeeded) { + const entities = await queryQboEntities({ + environment, + realmId, + accessToken, + type, + }); + const byId = new Map(entities.map((e) => [e.id, e.name])); + for (const { key, entityType } of MAPPING_FIELDS) { + const id = mapping[key]; + if (entityType !== type || !id) continue; + const name = byId.get(id); + if (name === undefined) { + throw new QboError( + `The selected ${key} does not exist in the connected QuickBooks company.`, + "validation", + 400 + ); + } + entityNames[id] = name; + } + } + + await mappingRef().set({ + ...mapping, + environment, + realmId, + entityNames, + updatedAt: Timestamp.now(), + updatedByUid: actor.token.uid, + } satisfies QboMappingDoc); + + try { + await logAdminAudit({ + action: "qbo_mapping_updated", + actingUid: actor.token.uid, + actingEmail: actor.token.email ?? actor.record.email, + metadata: { environment, fields: Object.keys(mapping) }, + }); + } catch { + logWarn("qbo.audit_failed", { action: "qbo_mapping_updated" }); + } + + return getQboMappingView(); +} diff --git a/lib/qbo-protocol.ts b/lib/qbo-protocol.ts new file mode 100644 index 0000000..b548c57 --- /dev/null +++ b/lib/qbo-protocol.ts @@ -0,0 +1,565 @@ +// Pure protocol helpers for the QuickBooks Online integration (issue #161): +// OAuth 2.0 request construction, token-response parsing, error +// normalization, webhook signature verification, and payload +// canonicalization. Everything here is dependency-free and unit-testable; +// the server-only modules (lib/qbo.ts, lib/qbo-api.ts, lib/qbo-webhook.ts) +// wire in fetch and Firestore. +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { QboError, type QboErrorKind } from "@/lib/qbo-errors"; +import { + QBO_ACCOUNTING_SCOPE, + QBO_AUTHORIZE_URL, + QBO_MINOR_VERSION, + QBO_REVOKE_URL, + QBO_TOKEN_URL, + qboApiBaseUrl, +} from "@/lib/qbo-config"; +import type { + QboDiscoveryEntityType, + QboEntitySummary, + QboEnvironment, +} from "@/lib/qbo-common"; + +// --- OAuth state --- + +export const QBO_OAUTH_STATE_BYTES = 32; +// Authorization-code state lives just long enough for a human to click +// through Intuit's consent screens. +export const QBO_OAUTH_STATE_TTL_MS = 10 * 60 * 1000; + +export function generateQboOAuthState(): string { + return randomBytes(QBO_OAUTH_STATE_BYTES).toString("base64url"); +} + +export type QboOAuthStateVerdict = "ok" | "expired" | "consumed"; + +export interface QboOAuthStateRecordLike { + /** Epoch ms when the state stops being valid. */ + expiresAtMs: number; + /** Epoch ms when the state was consumed, if it has been. */ + consumedAtMs?: number | null; +} + +// Single-use, short-lived state: a consumed or expired record is a +// rejection, and an absent record is rejected by the caller before this is +// consulted. +export function classifyQboOAuthState( + record: QboOAuthStateRecordLike, + nowMs: number +): QboOAuthStateVerdict { + if (record.consumedAtMs != null) return "consumed"; + if (record.expiresAtMs <= nowMs) return "expired"; + return "ok"; +} + +// --- Authorization URL --- + +export function buildQboAuthorizationUrl(input: { + clientId: string; + redirectUri: string; + state: string; +}): string { + const url = new URL(QBO_AUTHORIZE_URL); + url.searchParams.set("client_id", input.clientId); + url.searchParams.set("scope", QBO_ACCOUNTING_SCOPE); + url.searchParams.set("redirect_uri", input.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("state", input.state); + return url.toString(); +} + +// --- Token endpoint requests --- + +export interface QboTokenEndpointRequest { + url: string; + headers: Record; + body: string; +} + +function basicAuthHeader(clientId: string, clientSecret: string): string { + return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`; +} + +export function buildQboTokenExchangeRequest(input: { + clientId: string; + clientSecret: string; + code: string; + redirectUri: string; +}): QboTokenEndpointRequest { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: input.code, + redirect_uri: input.redirectUri, + }); + return { + url: QBO_TOKEN_URL, + headers: { + Authorization: basicAuthHeader(input.clientId, input.clientSecret), + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }; +} + +export function buildQboTokenRefreshRequest(input: { + clientId: string; + clientSecret: string; + refreshToken: string; +}): QboTokenEndpointRequest { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: input.refreshToken, + }); + return { + url: QBO_TOKEN_URL, + headers: { + Authorization: basicAuthHeader(input.clientId, input.clientSecret), + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }; +} + +// Intuit revokes via JSON on a separate endpoint; either token type may be +// passed (we revoke the refresh token — killing it also kills the ability +// to mint new access tokens). +export function buildQboRevokeRequest(input: { + clientId: string; + clientSecret: string; + refreshToken: string; +}): { url: string; headers: Record; body: string } { + return { + url: QBO_REVOKE_URL, + headers: { + Authorization: basicAuthHeader(input.clientId, input.clientSecret), + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: input.refreshToken }), + }; +} + +// --- Token response --- + +export interface QboTokenSet { + accessToken: string; + refreshToken: string; + /** Seconds until the access token expires (Intuit: 3600). */ + accessTokenExpiresInSec: number; + /** Seconds until the refresh token expires (`x_refresh_token_expires_in`, + * ~100 days). Optional — Intuit may omit it. */ + refreshTokenExpiresInSec?: number; +} + +export function parseQboTokenResponse(payload: unknown): QboTokenSet { + if (typeof payload !== "object" || payload === null) { + throw new QboError("QuickBooks returned a malformed token response.", "unexpected"); + } + const raw = payload as Record; + const accessToken = raw.access_token; + const refreshToken = raw.refresh_token; + const expiresIn = raw.expires_in; + const refreshExpiresIn = raw.x_refresh_token_expires_in; + if ( + typeof accessToken !== "string" || + !accessToken || + typeof refreshToken !== "string" || + !refreshToken || + typeof expiresIn !== "number" || + !(expiresIn > 0) + ) { + throw new QboError("QuickBooks returned a malformed token response.", "unexpected"); + } + return { + accessToken, + refreshToken, + accessTokenExpiresInSec: expiresIn, + refreshTokenExpiresInSec: + typeof refreshExpiresIn === "number" && refreshExpiresIn > 0 + ? refreshExpiresIn + : undefined, + }; +} + +// Intuit signals an invalid/revoked grant with `error=invalid_grant` in the +// token endpoint's JSON error body — the trigger for +// reauthorization_required, distinct from a transient failure. +export function qboTokenResponseIsInvalidGrant(payload: unknown): boolean { + if (typeof payload !== "object" || payload === null) return false; + const raw = payload as Record; + return raw.error === "invalid_grant"; +} + +// --- Access-token lifecycle decision --- + +// Refresh ahead of the real expiry so an in-flight request never carries a +// token that dies mid-flight. +export const QBO_ACCESS_TOKEN_SKEW_MS = 60_000; +// How long one caller may hold the refresh lease before another may try. +// A crashed refresher blocks refresh for at most this long. +export const QBO_REFRESH_LEASE_MS = 30_000; +// How many times a waiter re-reads the connection record (with a short +// delay) while another caller holds the lease before giving up. +export const QBO_REFRESH_WAIT_ATTEMPTS = 3; +export const QBO_REFRESH_WAIT_DELAY_MS = 750; + +// "reauthorize" is not a decision here — it is a persisted connection +// status checked before this runs and surfaced as authorization_expired. +export type QboTokenAction = "use" | "wait" | "refresh"; + +export interface QboTokenStateLike { + /** Epoch ms the stored access token expires, or null when absent. */ + accessTokenExpiresAtMs?: number | null; + /** Epoch ms the refresh lease is held until, or null when free. */ + refreshLeaseUntilMs?: number | null; +} + +export function decideQboTokenAction( + record: QboTokenStateLike, + nowMs: number +): QboTokenAction { + const expiresAt = record.accessTokenExpiresAtMs; + if ( + typeof expiresAt === "number" && + expiresAt - QBO_ACCESS_TOKEN_SKEW_MS > nowMs + ) { + return "use"; + } + const leaseUntil = record.refreshLeaseUntilMs; + if (typeof leaseUntil === "number" && leaseUntil > nowMs) { + return "wait"; + } + return "refresh"; +} + +// --- Error normalization --- + +// Maps an Intuit HTTP outcome to the internal taxonomy. `intuit_tid` is the +// provider's correlation id — safe to log, useful for Intuit support. +export function qboErrorForHttpStatus( + status: number, + opts: { correlationId?: string; invalidGrant?: boolean } = {} +): QboError { + const cid = opts.correlationId; + if (status === 401 || opts.invalidGrant) { + return new QboError( + "QuickBooks authorization is no longer valid — reconnect the integration.", + "authorization_expired", + 409, + cid + ); + } + if (status === 403) { + return new QboError( + "QuickBooks denied permission for this operation.", + "permission_denied", + 403, + cid + ); + } + if (status === 429) { + return new QboError( + "QuickBooks is rate limiting requests — try again shortly.", + "rate_limited", + 429, + cid + ); + } + if (status === 400 || status === 422) { + return new QboError( + "QuickBooks rejected the request.", + "validation", + 400, + cid + ); + } + if (status >= 500) { + return new QboError( + "QuickBooks is temporarily unavailable.", + "unavailable", + 502, + cid + ); + } + return new QboError( + "QuickBooks returned an unexpected response.", + "unexpected", + 502, + cid + ); +} + +// Normalizes any failure crossing the provider boundary. Raw response +// bodies are deliberately discarded — they can echo request internals. +// `correlationId` carries Intuit's `intuit_tid` (safe operational +// metadata) so troubleshooting logs keep it even when the failure was a +// transport or parse problem rather than an HTTP status. +export function toQboError( + error: unknown, + operation: string, + opts: { correlationId?: string } = {} +): QboError { + if (error instanceof QboError) return error; + const kind: QboErrorKind = "unavailable"; + return new QboError( + `QuickBooks ${operation} failed unexpectedly — try again shortly.`, + kind, + 502, + opts.correlationId + ); +} + +// --- Authenticated request helpers --- + +export function qboApiUrl( + environment: QboEnvironment, + realmId: string, + path: string +): string { + const base = qboApiBaseUrl(environment); + const normalized = path.startsWith("/") ? path : `/${path}`; + const url = new URL(`${base}/v3/company/${encodeURIComponent(realmId)}${normalized}`); + url.searchParams.set("minorversion", QBO_MINOR_VERSION); + return url.toString(); +} + +// QBO's SQL-flavoured query endpoint (GET with the statement encoded). +export function buildQboQueryUrl( + environment: QboEnvironment, + realmId: string, + query: string +): string { + const url = new URL(qboApiUrl(environment, realmId, "/query")); + url.searchParams.set("query", query); + return url.toString(); +} + +// --- Webhooks --- + +// Intuit signs the raw request body with HMAC-SHA256 keyed by the webhook +// verifier token and sends the base64 digest in `intuit-signature`. +export function verifyQboWebhookSignature( + rawBody: string, + signatureHeader: string | null | undefined, + verifierToken: string +): boolean { + if (!signatureHeader) return false; + const expected = createHmac("sha256", verifierToken) + .update(rawBody, "utf8") + .digest(); + let provided: Buffer; + try { + provided = Buffer.from(signatureHeader, "base64"); + } catch { + return false; + } + return provided.length === expected.length && timingSafeEqual(provided, expected); +} + +// Classic QBO payload (the portal's default format — the opt-in CloudEvents +// format is intentionally not enabled; see docs/operations/quickbooks.md): +// { "eventNotifications": [ +// { "realmId": "…", "dataChangeEvent": { "entities": [ +// { "name": "Customer", "id": "1", "operation": "Create", +// "lastUpdated": "…" } ] } } ] } +export interface QboEntityNotification { + realmId: string; + entityName: string; + entityId: string; + operation: string; + lastUpdated: string; +} + +export function parseQboWebhookNotifications( + rawBody: string +): QboEntityNotification[] { + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + return []; + } + if (typeof payload !== "object" || payload === null) return []; + const notifications = (payload as Record).eventNotifications; + if (!Array.isArray(notifications)) return []; + + const out: QboEntityNotification[] = []; + for (const notification of notifications) { + if (typeof notification !== "object" || notification === null) continue; + const n = notification as Record; + const realmId = typeof n.realmId === "string" ? n.realmId : ""; + const dataChangeEvent = n.dataChangeEvent; + const entities = + typeof dataChangeEvent === "object" && dataChangeEvent !== null + ? (dataChangeEvent as Record).entities + : undefined; + if (!realmId || !Array.isArray(entities)) continue; + for (const entity of entities) { + if (typeof entity !== "object" || entity === null) continue; + const e = entity as Record; + const entityName = typeof e.name === "string" ? e.name : ""; + const entityId = + typeof e.id === "string" || typeof e.id === "number" + ? String(e.id) + : ""; + const operation = typeof e.operation === "string" ? e.operation : ""; + const lastUpdated = + typeof e.lastUpdated === "string" ? e.lastUpdated : ""; + if (!entityName || !entityId) continue; + out.push({ realmId, entityName, entityId, operation, lastUpdated }); + } + } + return out; +} + +// Durable dedupe key for one delivered notification. QBO deliveries carry +// no event id, so the content identity (realm + entity + operation + +// timestamp) is the dedupe anchor — a repeated POST of the same +// notification hashes identically. +export function qboWebhookDedupeKey(n: QboEntityNotification): string { + return createHash("sha256") + .update( + [ + n.realmId, + n.entityName, + n.entityId, + n.operation, + n.lastUpdated, + ].join("|") + ) + .digest("hex"); +} + +// --- Provider payload canonicalization --- + +export interface QboCompanyInfo { + companyName: string; + country?: string; +} + +// GET companyinfo — proves the grant works inside the requested realm and +// reports display metadata (name, country) for the admin UI and the +// connection record. CompanyInfo.Id is a provider entity identifier, NOT +// the OAuth realmId the callback delivered — real sandbox responses carry +// an Id that differs from the realm — so it is deliberately not carried +// into the canonical shape: the connected-company identity comes from the +// OAuth flow, never from this payload, and metadata can never replace it. +// A response without a usable CompanyName is malformed and rejected. +export function canonicalizeCompanyInfo(payload: unknown): QboCompanyInfo { + const info = + typeof payload === "object" && payload !== null + ? (payload as Record).CompanyInfo + : undefined; + const record = + typeof info === "object" && info !== null + ? (info as Record) + : {}; + const companyName = + typeof record.CompanyName === "string" ? record.CompanyName : ""; + if (!companyName) { + throw new QboError( + "QuickBooks returned a malformed company response.", + "unexpected" + ); + } + const country = + typeof record.Country === "string" && record.Country + ? record.Country + : undefined; + return { companyName, country }; +} + +const QBO_ENTITY_QUERIES: Record< + QboDiscoveryEntityType, + { statement: string; responseKey: string } +> = { + account: { statement: "select * from Account", responseKey: "Account" }, + item: { statement: "select * from Item", responseKey: "Item" }, + customer: { statement: "select * from Customer", responseKey: "Customer" }, + "payment-method": { + statement: "select * from PaymentMethod", + responseKey: "PaymentMethod", + }, + "tax-code": { statement: "select * from TaxCode", responseKey: "TaxCode" }, +}; + +// QBO queries default to 100 rows and cap at 1000 — discovery paginates. +export const QBO_QUERY_PAGE_SIZE = 1000; +// Safety bound so a misbehaving provider can never page forever. +export const QBO_QUERY_MAX_PAGES = 10; + +export function qboEntityQueryStatement( + type: QboDiscoveryEntityType, + startPosition?: number, + maxResults?: number +): string { + const base = QBO_ENTITY_QUERIES[type].statement; + if (startPosition === undefined && maxResults === undefined) return base; + return `${base} startposition ${startPosition ?? 1} maxresults ${maxResults ?? QBO_QUERY_PAGE_SIZE}`; +} + +// Raw row count in a QueryResponse page — the pagination signal. Kept +// separate from canonicalization, which may legitimately drop malformed +// rows; a full raw page means another page may exist. +export function qboQueryRowCount( + type: QboDiscoveryEntityType, + payload: unknown +): number { + const response = + typeof payload === "object" && payload !== null + ? (payload as Record).QueryResponse + : undefined; + if (typeof response !== "object" || response === null) return 0; + const rows = (response as Record)[ + QBO_ENTITY_QUERIES[type].responseKey + ]; + return Array.isArray(rows) ? rows.length : 0; +} + +// Reduces a QueryResponse page to safe display rows. Anything unexpected is +// dropped rather than passed through. +export function canonicalizeQboQueryEntities( + type: QboDiscoveryEntityType, + payload: unknown +): QboEntitySummary[] { + const response = + typeof payload === "object" && payload !== null + ? (payload as Record).QueryResponse + : undefined; + if (typeof response !== "object" || response === null) return []; + const key = QBO_ENTITY_QUERIES[type].responseKey; + const rows = (response as Record)[key]; + if (!Array.isArray(rows)) return []; + + const out: QboEntitySummary[] = []; + for (const row of rows) { + if (typeof row !== "object" || row === null) continue; + const r = row as Record; + const id = + typeof r.Id === "string" || typeof r.Id === "number" ? String(r.Id) : ""; + if (!id) continue; + const name = + (typeof r.Name === "string" && r.Name) || + (typeof r.DisplayName === "string" && r.DisplayName) || + (typeof r.FullyQualifiedName === "string" && r.FullyQualifiedName) || + ""; + const entityType = + (typeof r.AccountType === "string" && r.AccountType) || + (typeof r.Type === "string" && r.Type) || + undefined; + const detail = + typeof r.AccountSubType === "string" && r.AccountSubType + ? r.AccountSubType + : undefined; + out.push({ + id, + name: name || `(${key} ${id})`, + type: entityType, + detail, + active: r.Active !== false, + }); + } + return out; +} diff --git a/lib/qbo-sync.ts b/lib/qbo-sync.ts new file mode 100644 index 0000000..bfd78bf --- /dev/null +++ b/lib/qbo-sync.ts @@ -0,0 +1,82 @@ +import "server-only"; +import { FieldValue } from "firebase-admin/firestore"; +import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; +import { getQboEnvironment } from "@/lib/qbo-config"; +import { logInfo } from "@/lib/log"; +import { + normalizeQboSyncCandidate, + persistableQboSyncCandidate, + qboSyncIdFor, + QBO_SYNC_RECORDS_COLLECTION, + type QuickBooksSyncCandidate, +} from "@/lib/qbo-common"; + +// Forward accounting-export contract (issue #161). This is the seam a +// future producer — the Stripe payments module once #156 merges — calls to +// hand a financial event to the QuickBooks integration. The producer +// supplies safe domain facts; the QBO side owns every bookkeeping decision. +// +// Idempotency design: the sync record's document id is the deterministic +// internal source identity (`environment:sourceType:sourceId`). A durable +// record exists BEFORE any QBO write is considered, so retries, webhook +// replays, job retries, or browser refreshes can never create two QBO +// transactions for the same source event — and a later switch from a +// sandbox to a production connection gets its own record rather than +// colliding with the sandbox one. QBO entity ids stored later are +// correlation references only — they are never the dedupe mechanism. +// +// No bookkeeping is performed here: enqueuing creates a `pending` record +// and nothing more. The worker that maps candidates onto Sales Receipts, +// Invoices, or summarized journals is deliberately deferred — see +// docs/operations/quickbooks.md. + +export type QboEnqueueResult = + | { syncId: string; outcome: "pending" } + | { syncId: string; outcome: "duplicate" }; + +// Enqueues a financial event for future QBO export. Idempotent: replaying +// the same source identity returns `duplicate` and leaves the existing +// record (including its current status) untouched. +export async function enqueueAccountingTransaction( + candidate: QuickBooksSyncCandidate +): Promise { + const normalized = normalizeQboSyncCandidate(candidate); + const environment = getQboEnvironment(); + const syncId = qboSyncIdFor( + environment, + normalized.sourceType, + normalized.sourceId + ); + const ref = getFirebaseAdminDb() + .collection(QBO_SYNC_RECORDS_COLLECTION) + .doc(syncId); + + const outcome = await getFirebaseAdminDb().runTransaction(async (tx) => { + const existing = await tx.get(ref); + if (existing.exists) return "duplicate" as const; + tx.create(ref, { + syncId, + sourceType: normalized.sourceType, + sourceId: normalized.sourceId, + status: "pending", + environment, + realmId: null, + qboEntityType: null, + qboEntityId: null, + attempts: 0, + lastAttemptAt: null, + lastErrorCode: null, + lastErrorMessage: null, + idempotencyKey: syncId, + candidate: persistableQboSyncCandidate(normalized), + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }); + return "pending" as const; + }); + + if (outcome === "pending") { + logInfo("qbo.sync.enqueued", { environment, sourceType: normalized.sourceType }); + } + return { syncId, outcome }; +} diff --git a/lib/qbo-tokens.ts b/lib/qbo-tokens.ts new file mode 100644 index 0000000..c3ec2fa --- /dev/null +++ b/lib/qbo-tokens.ts @@ -0,0 +1,303 @@ +import "server-only"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; +import { logError, logInfo, logWarn } from "@/lib/log"; +import { + decryptQboSecret, + encryptQboSecret, + getQboEncryptionKey, +} from "@/lib/qbo-crypto"; +import { loadQboConfig } from "@/lib/qbo-config"; +import { QboError } from "@/lib/qbo-errors"; +import { + buildQboTokenRefreshRequest, + decideQboTokenAction, + parseQboTokenResponse, + qboErrorForHttpStatus, + qboTokenResponseIsInvalidGrant, + QBO_REFRESH_LEASE_MS, + QBO_REFRESH_WAIT_ATTEMPTS, + QBO_REFRESH_WAIT_DELAY_MS, + type QboTokenEndpointRequest, + type QboTokenSet, +} from "@/lib/qbo-protocol"; +import { + QBO_CONNECTIONS_COLLECTION, + type QboEnvironment, + type QboHealthStatus, +} from "@/lib/qbo-common"; + +// QuickBooks connection record + access-token lifecycle (issue #161), +// split from lib/qbo.ts so the mapping module (lib/qbo-mapping.ts) can +// acquire tokens without an import cycle. +// +// Token material is AES-256-GCM encrypted (lib/qbo-crypto.ts) and never +// leaves the server. + +export interface QboConnectionDoc { + environment: QboEnvironment; + status: "connected" | "reauthorization_required" | "disconnected"; + realmId?: string; + companyName?: string; + companyCountry?: string; + accessTokenEnc?: string; + refreshTokenEnc?: string; + accessTokenExpiresAt?: Timestamp; + refreshTokenExpiresAt?: Timestamp; + refreshLeaseUntil?: Timestamp; + connectedAt?: Timestamp; + connectedByUid?: string; + connectedByEmail?: string; + lastCheckAt?: Timestamp; + lastCheckStatus?: QboHealthStatus; + disconnectedAt?: Timestamp; + disconnectedByUid?: string; + updatedAt?: Timestamp; +} + +export function qboConnectionRef(environment: QboEnvironment) { + return getFirebaseAdminDb() + .collection(QBO_CONNECTIONS_COLLECTION) + .doc(environment); +} + +// Defense-in-depth: the document id is the environment, but a hand-edited +// or migrated record claiming a different environment must never be used — +// a preview deployment must be incapable of touching the production realm. +export function usableQboConnection( + data: FirebaseFirestore.DocumentData | undefined, + environment: QboEnvironment +): QboConnectionDoc | null { + if (!data) return null; + if (data.environment && data.environment !== environment) { + logWarn("qbo.connection_environment_mismatch", { environment }); + return null; + } + return data as QboConnectionDoc; +} + +// Calls Intuit's token endpoint. 4xx/5xx and invalid_grant bodies are +// normalized into QboError; the raw body never propagates. The response's +// `intuit_tid` is returned alongside the token set so callers can log the +// provider correlation id on success as well as failure. +export async function requestQboTokens( + request: QboTokenEndpointRequest, + operation: string +): Promise<{ tokens: QboTokenSet; correlationId?: string }> { + let res: Response; + try { + res = await fetch(request.url, { + method: "POST", + headers: request.headers, + body: request.body, + }); + } catch { + throw new QboError( + `QuickBooks ${operation} could not be reached.`, + "unavailable" + ); + } + + const correlationId = res.headers.get("intuit_tid") ?? undefined; + let payload: unknown = null; + try { + payload = await res.json(); + } catch { + // Non-JSON body — only meaningful through the status code. + } + if (!res.ok) { + throw qboErrorForHttpStatus(res.status, { + correlationId, + invalidGrant: qboTokenResponseIsInvalidGrant(payload), + }); + } + return { tokens: parseQboTokenResponse(payload), correlationId }; +} + +type TokenDecision = + | { action: "use"; accessToken: string; realmId: string } + | { action: "wait" } + | { + action: "refresh"; + refreshToken: string; + realmId: string; + leaseUntilMs: number; + }; + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Canonical token-acquisition helper — the only place an access token +// should be obtained. Concurrent refresh attempts converge on a short +// Firestore lease: the lease holder refreshes and persists the rotated +// refresh token; waiters re-read until the new token lands. QuickBooks +// rotates the refresh token on every refresh — the newest response always +// wins and the older token is never reused. +export async function getQuickBooksAccessToken(): Promise<{ + accessToken: string; + realmId: string; +}> { + const config = loadQboConfig(); + const key = getQboEncryptionKey(); + const db = getFirebaseAdminDb(); + const ref = qboConnectionRef(config.environment); + + for (let attempt = 0; attempt <= QBO_REFRESH_WAIT_ATTEMPTS; attempt++) { + const decision = await db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + const record = usableQboConnection(snap.data(), config.environment); + if (!record || record.status === "disconnected" || !record.refreshTokenEnc) { + throw new QboError("QuickBooks is not connected.", "validation", 409); + } + if (record.status === "reauthorization_required") { + throw new QboError( + "QuickBooks authorization needs to be renewed — reconnect the integration.", + "authorization_expired" + ); + } + const action = decideQboTokenAction( + { + accessTokenExpiresAtMs: + record.accessTokenExpiresAt?.toMillis() ?? null, + refreshLeaseUntilMs: record.refreshLeaseUntil?.toMillis() ?? null, + }, + Date.now() + ); + const realmId = record.realmId ?? ""; + if (action === "use") { + return { + action, + accessToken: decryptQboSecret(record.accessTokenEnc ?? "", key), + realmId, + }; + } + if (action === "wait") return { action }; + const leaseUntil = Timestamp.fromMillis( + Date.now() + QBO_REFRESH_LEASE_MS + ); + tx.update(ref, { refreshLeaseUntil: leaseUntil }); + return { + action, + refreshToken: decryptQboSecret(record.refreshTokenEnc, key), + realmId, + leaseUntilMs: leaseUntil.toMillis(), + }; + }); + + if (decision.action === "use") { + return { + accessToken: decision.accessToken, + realmId: decision.realmId, + }; + } + if (decision.action === "wait") { + await delay(QBO_REFRESH_WAIT_DELAY_MS); + continue; + } + + // Lease holder: refresh outside the transaction (no provider IO inside + // Firestore transactions), then persist the rotated tokens atomically. + try { + const { tokens, correlationId } = await requestQboTokens( + buildQboTokenRefreshRequest({ + clientId: config.clientId, + clientSecret: config.clientSecret, + refreshToken: decision.refreshToken, + }), + "token refresh" + ); + const nowMs = Date.now(); + await db.runTransaction(async (tx) => { + // Re-verify inside the commit: a disconnect or a reconnect during + // the Intuit round-trip must not be overwritten, and only the + // current lease holder may commit. The stored lease must still be + // the exact lease this refresh acquired — an expired lease that + // was re-acquired by another caller, or one deleted by a + // disconnect/reconnect/commit, no longer matches. The rotated + // tokens are then dropped — the stored grant stays usable per + // Intuit's post-rotation grace. + const snap = await tx.get(ref); + const current = usableQboConnection(snap.data(), config.environment); + if ( + !current || + current.status !== "connected" || + current.realmId !== decision.realmId || + !current.refreshTokenEnc || + current.refreshLeaseUntil?.toMillis() !== decision.leaseUntilMs + ) { + throw new QboError( + "The QuickBooks connection changed during token refresh — try again.", + "unavailable" + ); + } + tx.update(ref, { + accessTokenEnc: encryptQboSecret(tokens.accessToken, key), + refreshTokenEnc: encryptQboSecret(tokens.refreshToken, key), + accessTokenExpiresAt: Timestamp.fromMillis( + nowMs + tokens.accessTokenExpiresInSec * 1000 + ), + refreshTokenExpiresAt: tokens.refreshTokenExpiresInSec + ? Timestamp.fromMillis( + nowMs + tokens.refreshTokenExpiresInSec * 1000 + ) + : FieldValue.delete(), + refreshLeaseUntil: FieldValue.delete(), + status: "connected", + updatedAt: Timestamp.fromMillis(nowMs), + }); + }); + logInfo("qbo.token.refreshed", { + environment: config.environment, + correlationId, + }); + return { accessToken: tokens.accessToken, realmId: decision.realmId }; + } catch (error) { + const isRevoked = + error instanceof QboError && error.kind === "authorization_expired"; + // Best-effort cleanup under the same identity check as the commit: + // release the lease (and flag a revoked grant) only while the record + // still belongs to this refresh — never stomp a disconnect, a + // reconnect, or a successor lease holder's work. + await db + .runTransaction(async (tx) => { + const snap = await tx.get(ref); + const current = usableQboConnection( + snap.data(), + config.environment + ); + if ( + !current || + current.status !== "connected" || + current.realmId !== decision.realmId || + current.refreshLeaseUntil?.toMillis() !== decision.leaseUntilMs + ) { + return; + } + const cleanup: Record = { + refreshLeaseUntil: FieldValue.delete(), + updatedAt: Timestamp.now(), + }; + if (isRevoked) cleanup.status = "reauthorization_required"; + tx.update(ref, cleanup); + }) + .catch(() => {}); + if (isRevoked) { + logWarn("qbo.token_refresh_failed", { + environment: config.environment, + reason: "authorization_expired", + }); + } else { + logError("qbo.token_refresh_failed", error, { + environment: config.environment, + }); + } + throw error; + } + } + + throw new QboError( + "QuickBooks token refresh is in progress — try again shortly.", + "unavailable" + ); +} diff --git a/lib/qbo-webhook.ts b/lib/qbo-webhook.ts new file mode 100644 index 0000000..746f5f8 --- /dev/null +++ b/lib/qbo-webhook.ts @@ -0,0 +1,127 @@ +import "server-only"; +import { FieldValue } from "firebase-admin/firestore"; +import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; +import { getQboEnvironment, getQboWebhookVerifierToken } from "@/lib/qbo-config"; +import { QboConfigError, QboError } from "@/lib/qbo-errors"; +import { + parseQboWebhookNotifications, + qboWebhookDedupeKey, + verifyQboWebhookSignature, +} from "@/lib/qbo-protocol"; +import { + QBO_CONNECTIONS_COLLECTION, + QBO_WEBHOOK_RECEIPTS_COLLECTION, +} from "@/lib/qbo-common"; + +// QuickBooks webhook receiver internals (issue #161). The route is +// unauthenticated in the HTTP sense — the caller is Intuit — so the +// `intuit-signature` HMAC-SHA256 over the raw body is the entire trust +// boundary, verified before any payload field is read. +// +// This is the foundation only: verified notifications are recorded as +// durable dedupe receipts. No entity synchronization runs yet — the future +// accounting sync consumes these (or re-queries QBO) once the bookkeeping +// policy is chosen. + +export interface QboWebhookOutcome { + /** Notifications parsed from the body. */ + received: number; + /** Notifications persisted as new receipts. */ + recorded: number; + /** Notifications that replayed an existing receipt. */ + duplicates: number; + /** Receipts for a realm other than the connected company. */ + ignoredRealms: number; +} + +// Verifies the signature and parses notifications. Throws client-safe +// QboError for an unconfigured secret or a bad signature — the route maps +// both to refusals without touching state. +export function verifyQboWebhookRequest( + rawBody: string, + signatureHeader: string | null +): ReturnType { + const verifier = getQboWebhookVerifierToken(); + if (!verifier) { + throw new QboConfigError( + "QBO_WEBHOOK_VERIFIER_TOKEN is not configured." + ); + } + if (!verifyQboWebhookSignature(rawBody, signatureHeader, verifier)) { + throw new QboError("Invalid webhook signature.", "validation", 401); + } + return parseQboWebhookNotifications(rawBody); +} + +// Persists each notification exactly once. The dedupe key is a hash of the +// notification's content identity (realm + entity + operation + timestamp) +// — a replayed delivery hashes identically and lands on the existing +// receipt inside the same transaction semantics that guard the trade-lead +// and webhook code paths. +export async function recordQboWebhookNotifications( + notifications: ReturnType, + requestId: string +): Promise { + const outcome: QboWebhookOutcome = { + received: notifications.length, + recorded: 0, + duplicates: 0, + ignoredRealms: 0, + }; + if (notifications.length === 0) return outcome; + + // Realm recognition: notifications for a company we are not connected to + // are recorded (for dedupe) but flagged ignored — they must never trigger + // future sync work. Only an actively connected record makes a realm + // known; a disconnected or reauthorization-pending record's stored realm + // does not count (same test as connectedRealmId in lib/qbo-mapping.ts). + // A lookup failure propagates (route answers 500 and Intuit retries): + // swallowing it would stamp valid notifications realmKnown:false, which + // dedupe would then protect forever. + let connectedRealmId: string | null = null; + const environment = getQboEnvironment(); + const snap = await getFirebaseAdminDb() + .collection(QBO_CONNECTIONS_COLLECTION) + .doc(environment) + .get(); + const data = snap.data(); + if ( + data && + data.environment === environment && + data.status === "connected" && + typeof data.realmId === "string" && + data.realmId + ) { + connectedRealmId = data.realmId; + } + + const db = getFirebaseAdminDb(); + const receipts = db.collection(QBO_WEBHOOK_RECEIPTS_COLLECTION); + for (const notification of notifications) { + const realmKnown = + connectedRealmId !== null && notification.realmId === connectedRealmId; + const receiptRef = receipts.doc(qboWebhookDedupeKey(notification)); + const result = await db.runTransaction(async (tx) => { + const existing = await tx.get(receiptRef); + if (existing.exists) return "duplicate" as const; + tx.create(receiptRef, { + realmId: notification.realmId, + entityName: notification.entityName, + entityId: notification.entityId, + operation: notification.operation, + lastUpdated: notification.lastUpdated, + realmKnown, + receivedAt: FieldValue.serverTimestamp(), + requestId, + }); + return "recorded" as const; + }); + if (result === "duplicate") { + outcome.duplicates++; + continue; + } + outcome.recorded++; + if (!realmKnown) outcome.ignoredRealms++; + } + return outcome; +} diff --git a/lib/qbo.ts b/lib/qbo.ts new file mode 100644 index 0000000..8b0c61b --- /dev/null +++ b/lib/qbo.ts @@ -0,0 +1,456 @@ +import "server-only"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; +import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; +import { logAdminAudit } from "@/lib/admin-audit"; +import type { AdminActor } from "@/lib/admin-auth"; +import type { AdminAuditRecord } from "@/lib/admin-types"; +import { logError, logInfo, logWarn } from "@/lib/log"; +import { toIsoString } from "@/lib/admin-serializers"; +import { fetchQboCompanyInfo } from "@/lib/qbo-api"; +import { getQboMappingConfigured } from "@/lib/qbo-mapping"; +import { + decryptQboSecret, + encryptQboSecret, + getQboEncryptionKey, +} from "@/lib/qbo-crypto"; +import { + isQboConfigured, + getQboEnvironment, + loadQboConfig, +} from "@/lib/qbo-config"; +import { QboError } from "@/lib/qbo-errors"; +import { + abbreviateRealmId, + qboEnvironmentLabel, + QBO_OAUTH_STATES_COLLECTION, + type QboAdminView, + type QboEnvironment, + type QboHealthStatus, +} from "@/lib/qbo-common"; +import { + buildQboAuthorizationUrl, + buildQboRevokeRequest, + buildQboTokenExchangeRequest, + classifyQboOAuthState, + generateQboOAuthState, + QBO_OAUTH_STATE_TTL_MS, +} from "@/lib/qbo-protocol"; +import { + getQuickBooksAccessToken, + qboConnectionRef, + requestQboTokens, + usableQboConnection, +} from "@/lib/qbo-tokens"; + +// QuickBooks Online connection lifecycle (issue #161) — the server-only +// orchestration behind /api/admin/quickbooks/* and the QBO API client. +// +// Storage model (every collection deny-all to Firebase clients): +// qboConnections/{environment} — the live connection for the configured +// QBO_ENVIRONMENT (sandbox and production can never collide even +// though preview and production share this Firestore project). +// qboOauthStates/{state} — one-time authorization-request state. + +interface QboOAuthStateDoc { + uid: string; + email?: string; + environment: QboEnvironment; + createdAt: Timestamp; + expiresAt: Timestamp; + consumedAt?: Timestamp; +} + +async function auditQbo( + action: AdminAuditRecord["action"], + actor: { uid: string; email?: string }, + metadata?: Record +): Promise { + try { + await logAdminAudit({ + action, + actingUid: actor.uid, + actingEmail: actor.email, + metadata, + }); + } catch { + // Audit must never break the operation it describes. + logWarn("qbo.audit_failed", { action }); + } +} + +// --- OAuth authorization request (connect / reconnect) --- + +// Best-effort cleanup of states past their TTL. Bounded and non-blocking — +// correctness never depends on it since expiry is enforced at consume time. +async function cleanupExpiredOAuthStates(): Promise { + const db = getFirebaseAdminDb(); + const stale = await db + .collection(QBO_OAUTH_STATES_COLLECTION) + .where("expiresAt", "<", Timestamp.now()) + .limit(25) + .get(); + if (stale.empty) return; + const batch = db.batch(); + for (const docSnap of stale.docs) batch.delete(docSnap.ref); + await batch.commit(); +} + +export interface QboAuthorizationRequest { + authorizationUrl: string; + state: string; +} + +// Starts the OAuth flow for an authorized admin: generates a +// cryptographically-random state, persists it bound to the initiator and +// the configured environment, and returns the Intuit authorization URL. +// The state value itself doubles as the session cookie the callback +// cross-checks. +export async function createQboAuthorizationRequest( + actor: AdminActor +): Promise { + const config = loadQboConfig(); + // Fail before issuing any state if the token envelope can't be written — + // the callback would otherwise surface the same error only after the + // admin completed Intuit consent. + getQboEncryptionKey(); + const state = generateQboOAuthState(); + const now = Date.now(); + + await getFirebaseAdminDb() + .collection(QBO_OAUTH_STATES_COLLECTION) + .doc(state) + .set({ + uid: actor.token.uid, + email: actor.token.email ?? actor.record.email, + environment: config.environment, + createdAt: Timestamp.fromMillis(now), + expiresAt: Timestamp.fromMillis(now + QBO_OAUTH_STATE_TTL_MS), + } satisfies QboOAuthStateDoc); + + void cleanupExpiredOAuthStates().catch(() => {}); + + logInfo("qbo.oauth.started", { environment: config.environment }); + return { + authorizationUrl: buildQboAuthorizationUrl({ + clientId: config.clientId, + redirectUri: config.redirectUri, + state, + }), + state, + }; +} + +// --- OAuth callback --- + +export type QboStateConsumption = + | { verdict: "ok"; uid: string; email?: string } + | { verdict: "missing" | "expired" | "consumed" | "environment_mismatch" }; + +// Consumes an authorization state atomically: it must exist, be unexpired, +// be unconsumed, and belong to the configured environment. The consumed +// marker stays so a replayed state reads as "consumed" rather than +// "missing" — the distinction matters for logs. +export async function consumeQboOAuthState( + state: string +): Promise { + const config = loadQboConfig(); + const ref = getFirebaseAdminDb() + .collection(QBO_OAUTH_STATES_COLLECTION) + .doc(state); + + return getFirebaseAdminDb().runTransaction(async (tx) => { + const snap = await tx.get(ref); + if (!snap.exists) return { verdict: "missing" as const }; + const data = snap.data() as QboOAuthStateDoc; + const verdict = classifyQboOAuthState( + { + expiresAtMs: data.expiresAt.toMillis(), + consumedAtMs: data.consumedAt?.toMillis() ?? null, + }, + Date.now() + ); + if (verdict !== "ok") return { verdict }; + if (data.environment !== config.environment) { + return { verdict: "environment_mismatch" as const }; + } + tx.update(ref, { consumedAt: Timestamp.now() }); + return { verdict: "ok" as const, uid: data.uid, email: data.email }; + }); +} + +// Completes the OAuth callback after the state has been consumed: exchanges +// the authorization code, proves the connection by reading CompanyInfo, and +// persists the encrypted connection. `realmId` comes from Intuit's redirect +// and identifies the company the admin selected. +export async function completeQboAuthorization(input: { + code: string; + realmId: string; + uid: string; + email?: string; +}): Promise<{ companyName: string }> { + const config = loadQboConfig(); + const { tokens } = await requestQboTokens( + buildQboTokenExchangeRequest({ + clientId: config.clientId, + clientSecret: config.clientSecret, + code: input.code, + redirectUri: config.redirectUri, + }), + "token exchange" + ); + + // Prove the grant works inside the authorized realm before recording + // anything. The realm id Intuit sent on the OAuth callback is the + // connected-company identity — CompanyInfo only confirms the read and + // supplies display metadata, it can never re-assign the realm. + const company = await fetchQboCompanyInfo({ + environment: config.environment, + realmId: input.realmId, + accessToken: tokens.accessToken, + }); + + const key = getQboEncryptionKey(); + const nowMs = Date.now(); + const now = Timestamp.fromMillis(nowMs); + await qboConnectionRef(config.environment).set( + { + environment: config.environment, + status: "connected", + realmId: input.realmId, + companyName: company.companyName, + ...(company.country ? { companyCountry: company.country } : {}), + accessTokenEnc: encryptQboSecret(tokens.accessToken, key), + refreshTokenEnc: encryptQboSecret(tokens.refreshToken, key), + accessTokenExpiresAt: Timestamp.fromMillis( + nowMs + tokens.accessTokenExpiresInSec * 1000 + ), + ...(tokens.refreshTokenExpiresInSec + ? { + refreshTokenExpiresAt: Timestamp.fromMillis( + nowMs + tokens.refreshTokenExpiresInSec * 1000 + ), + } + : {}), + connectedAt: now, + connectedByUid: input.uid, + ...(input.email ? { connectedByEmail: input.email } : {}), + lastCheckAt: now, + lastCheckStatus: "healthy", + updatedAt: now, + disconnectedAt: FieldValue.delete(), + disconnectedByUid: FieldValue.delete(), + refreshLeaseUntil: FieldValue.delete(), + }, + { merge: true } + ); + + await auditQbo("qbo_connected", input, { + environment: config.environment, + companyName: company.companyName, + realmIdShort: abbreviateRealmId(input.realmId), + }); + logInfo("qbo.oauth.connected", { + environment: config.environment, + realmIdShort: abbreviateRealmId(input.realmId), + }); + return { companyName: company.companyName }; +} + +// --- Admin status view --- + +// Serialized connection view for the admin surface — metadata only, never +// token material. +export async function getQboAdminView(): Promise { + let environment: QboEnvironment = "sandbox"; + try { + environment = getQboEnvironment(); + } catch { + // Unconfigured/invalid environment: report "not_configured" below. + } + const base = { + environment, + environmentLabel: qboEnvironmentLabel(environment), + }; + if (!isQboConfigured()) { + return { + ...base, + configured: false, + status: "not_configured", + health: "unknown", + mappingConfigured: false, + }; + } + + const snap = await qboConnectionRef(environment).get(); + const record = usableQboConnection(snap.data(), environment); + if ( + !record || + record.status === "disconnected" || + !record.refreshTokenEnc + ) { + return { + ...base, + configured: true, + status: "disconnected", + health: "unknown", + mappingConfigured: false, + }; + } + + const mappingConfigured = await getQboMappingConfigured( + environment, + record.realmId + ); + + return { + ...base, + configured: true, + status: + record.status === "reauthorization_required" + ? "reauthorization_required" + : "connected", + health: record.lastCheckStatus ?? "unknown", + realmIdShort: record.realmId + ? abbreviateRealmId(record.realmId) + : undefined, + companyName: record.companyName, + companyCountry: record.companyCountry, + connectedAt: toIsoString(record.connectedAt), + connectedByEmail: record.connectedByEmail, + lastCheckAt: toIsoString(record.lastCheckAt), + mappingConfigured, + }; +} + +// --- Connection health check --- + +// Explicit admin-triggered health probe: refreshes the token if needed, +// calls CompanyInfo, and records the outcome. Returns the updated view — +// the check reports status rather than throwing, since an unhealthy result +// is the information the admin asked for. +export async function runQboConnectionCheck( + actor: AdminActor +): Promise { + const environment = getQboEnvironment(); + const view = await getQboAdminView(); + if (view.status !== "connected") { + throw new QboError("QuickBooks is not connected.", "validation", 409); + } + + const ref = qboConnectionRef(environment); + try { + const { accessToken, realmId } = await getQuickBooksAccessToken(); + const company = await fetchQboCompanyInfo({ + environment, + realmId, + accessToken, + }); + await ref.update({ + lastCheckAt: Timestamp.now(), + lastCheckStatus: "healthy", + companyName: company.companyName, + ...(company.country ? { companyCountry: company.country } : {}), + updatedAt: Timestamp.now(), + }); + await auditQbo("qbo_connection_checked", actor.token, { + environment, + outcome: "healthy", + }); + logInfo("qbo.connection.checked", { + environment, + outcome: "healthy", + }); + } catch (error) { + const expired = + error instanceof QboError && error.kind === "authorization_expired"; + const outcome: QboHealthStatus = expired + ? "needs_reauthorization" + : "provider_unavailable"; + await ref + .update({ + lastCheckAt: Timestamp.now(), + lastCheckStatus: outcome, + updatedAt: Timestamp.now(), + }) + .catch(() => {}); + await auditQbo("qbo_connection_checked", actor.token, { + environment, + outcome, + }); + if (expired) { + logWarn("qbo.connection.checked", { environment, outcome }); + } else { + logError("qbo.connection.checked", error, { environment }); + } + } + return getQboAdminView(); +} + +// --- Disconnect --- + +// Deliberate teardown: best-effort revoke of the refresh token at Intuit, +// then removal of all credential material. The realm/company identity and +// timestamps stay on the record as history — the audit log records the act. +// Historical sync/webhook records are never touched by a disconnect. +export async function disconnectQbo(actor: AdminActor): Promise { + const config = loadQboConfig(); + const ref = qboConnectionRef(config.environment); + const snap = await ref.get(); + const record = usableQboConnection(snap.data(), config.environment); + if ( + !record || + record.status === "disconnected" || + !record.refreshTokenEnc + ) { + throw new QboError("QuickBooks is not connected.", "validation", 409); + } + + try { + const refreshToken = decryptQboSecret( + record.refreshTokenEnc, + getQboEncryptionKey() + ); + const request = buildQboRevokeRequest({ + clientId: config.clientId, + clientSecret: config.clientSecret, + refreshToken, + }); + const res = await fetch(request.url, { + method: "POST", + headers: request.headers, + body: request.body, + }); + if (!res.ok) { + logWarn("qbo.revoke_failed", { + environment: config.environment, + status: res.status, + correlationId: res.headers.get("intuit_tid") ?? undefined, + }); + } + } catch { + // Revocation is best-effort — credentials are destroyed locally either + // way, and an expired/revoked grant can't be revoked again. + logWarn("qbo.revoke_failed", { environment: config.environment }); + } + + await ref.update({ + status: "disconnected", + disconnectedAt: Timestamp.now(), + disconnectedByUid: actor.token.uid, + accessTokenEnc: FieldValue.delete(), + refreshTokenEnc: FieldValue.delete(), + accessTokenExpiresAt: FieldValue.delete(), + refreshTokenExpiresAt: FieldValue.delete(), + refreshLeaseUntil: FieldValue.delete(), + lastCheckAt: FieldValue.delete(), + lastCheckStatus: FieldValue.delete(), + updatedAt: Timestamp.now(), + }); + await auditQbo("qbo_disconnected", actor.token, { + environment: config.environment, + realmIdShort: record.realmId + ? abbreviateRealmId(record.realmId) + : undefined, + }); + logInfo("qbo.disconnected", { environment: config.environment }); +} diff --git a/package.json b/package.json index 98a3054..abc8e17 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "start": "next start", "lint": "eslint", "check:admin-import": "node scripts/check-admin-import.mjs", - "test": "npm run check:admin-import && node --import tsx --test \"tests/**/*.test.ts\"", + "test": "npm run check:admin-import && node --experimental-test-module-mocks --import tsx --test \"tests/**/*.test.ts\"", "test:rules": "firebase emulators:exec --only firestore,storage --project demo-deepdivebrewing-web \"node --import tsx --test rules-tests/*.test.ts\"", "test:smoke": "npm run build && playwright test", "check:md-links": "node scripts/check-md-links.mjs", diff --git a/rules-tests/firestore.rules.test.ts b/rules-tests/firestore.rules.test.ts index 1b20c5e..45e5371 100644 --- a/rules-tests/firestore.rules.test.ts +++ b/rules-tests/firestore.rules.test.ts @@ -504,3 +504,36 @@ describe("payments deny all client access (Issue #155)", () => { await assertFails(deleteDoc(doc(db, "stripeEvents", "evt_1"))); }); }); + +describe("QuickBooks collections deny all client access (issue #161)", () => { + const collections = [ + "qboConnections", + "qboOauthStates", + "qboWebhookReceipts", + "qboConfig", + "qboSyncRecords", + ]; + + it("denies unauthenticated and non-admin reads and writes", async () => { + const anonDb = testEnv.unauthenticatedContext().firestore(); + const userDb = testEnv.authenticatedContext("visitor1").firestore(); + for (const name of collections) { + await assertFails(getDoc(doc(anonDb, name, "doc1"))); + await assertFails(getDocs(collection(anonDb, name))); + await assertFails(setDoc(doc(anonDb, name, "doc1"), { x: 1 })); + await assertFails(getDoc(doc(userDb, name, "doc1"))); + await assertFails(setDoc(doc(userDb, name, "doc1"), { x: 1 })); + } + }); + + it("denies even active admins — these collections are server-only", async () => { + await seedAdminRecord("admin1", "admin", "active"); + const db = adminContext("admin1", "admin").firestore(); + for (const name of collections) { + await assertFails(getDoc(doc(db, name, "doc1"))); + await assertFails(setDoc(doc(db, name, "doc1"), { x: 1 })); + await assertFails(updateDoc(doc(db, name, "doc1"), { x: 2 })); + await assertFails(deleteDoc(doc(db, name, "doc1"))); + } + }); +}); diff --git a/smoke-tests/admin-quickbooks.spec.ts b/smoke-tests/admin-quickbooks.spec.ts new file mode 100644 index 0000000..e9dcae5 --- /dev/null +++ b/smoke-tests/admin-quickbooks.spec.ts @@ -0,0 +1,227 @@ +import { test, expect } from "./fixtures"; +import { scanAxe, formatBlocking, waitForAnimations } from "./axe-helpers"; + +// QuickBooks Online admin surface (Issue #161), rendered via +// /admin-quickbooks-fixture — a route that only exists when the server was +// started with ADMIN_A11Y_FIXTURE=1 (the Playwright webServer sets it; +// normal deployments return 404). No real authentication, Firebase data, +// or Intuit traffic is involved: /api/admin/quickbooks/* calls are +// intercepted by the route mocks below (registered after the default +// not-configured stub in fixtures.ts, so they win). + +const FIXTURE = "/admin-quickbooks-fixture"; + +const CONNECTED = { + ok: true, + connection: { + configured: true, + environment: "sandbox", + environmentLabel: "Sandbox", + status: "connected", + health: "healthy", + realmIdShort: "1234…7890", + companyName: "Deep Dive Brewing Sandbox", + connectedAt: "2026-10-01T12:00:00.000Z", + connectedByEmail: "admin@example.com", + lastCheckAt: "2026-10-02T12:00:00.000Z", + mappingConfigured: false, + }, + mapping: { configured: false }, +}; + +const REAUTHORIZE = { + ok: true, + connection: { + configured: true, + environment: "sandbox", + environmentLabel: "Sandbox", + status: "reauthorization_required", + health: "needs_reauthorization", + realmIdShort: "1234…7890", + companyName: "Deep Dive Brewing Sandbox", + connectedAt: "2026-10-01T12:00:00.000Z", + connectedByEmail: "admin@example.com", + mappingConfigured: false, + }, + mapping: { configured: false }, +}; + +const DISCONNECTED = { + ok: true, + connection: { + configured: true, + environment: "sandbox", + environmentLabel: "Sandbox", + status: "disconnected", + health: "unknown", + mappingConfigured: false, + }, + mapping: { configured: false }, +}; + +function mockQboApi( + page: import("playwright/test").Page, + statusBody: unknown, + overrides: { + disconnectBody?: unknown; + testBody?: unknown; + connectBody?: unknown; + } = {} +) { + return page.route(/\/api\/admin\/quickbooks\//, (route) => { + const url = route.request().url(); + const method = route.request().method(); + const json = (body: unknown, status = 200) => + route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body), + }); + + if (method === "GET" && url.endsWith("/api/admin/quickbooks/status")) { + return json(statusBody); + } + if (method === "POST" && url.endsWith("/api/admin/quickbooks/test")) { + return json(overrides.testBody ?? statusBody); + } + if ( + method === "POST" && + url.endsWith("/api/admin/quickbooks/disconnect") + ) { + return json(overrides.disconnectBody ?? DISCONNECTED); + } + if (method === "POST" && url.endsWith("/api/admin/quickbooks/connect")) { + return json( + overrides.connectBody ?? { + ok: true, + authorizationUrl: "https://appcenter.intuit.com/connect/oauth2", + } + ); + } + return json({ ok: false, error: "Unexpected fixture request" }); + }); +} + +test.beforeAll(async ({ request }) => { + const res = await request.get(FIXTURE); + if (res.status() === 404) { + throw new Error( + `${FIXTURE} returned 404 — the suite requires the Playwright-managed ` + + "server (started with ADMIN_A11Y_FIXTURE=1 via playwright.config.ts). " + + "Stop any other server on the configured port and rerun." + ); + } +}); + +test("not-configured state explains setup without offering connect", async ({ + page, +}) => { + // The default fixture stub returns not_configured — no route override. + await page.goto(FIXTURE); + await expect( + page.getByRole("heading", { name: "QuickBooks Online" }) + ).toBeVisible(); + await expect( + page.getByText("Not configured", { exact: true }) + ).toBeVisible(); + await expect( + page.getByText(/credentials are not set on this deployment/) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: /Connect QuickBooks/ }) + ).toHaveCount(0); +}); + +test("disconnected state offers connect and shows the sandbox badge", async ({ + page, +}) => { + await mockQboApi(page, DISCONNECTED); + await page.goto(FIXTURE); + await expect( + page.getByText("Not connected", { exact: true }) + ).toBeVisible(); + await expect(page.getByText("Sandbox", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Connect QuickBooks" }) + ).toBeVisible(); + await expect( + page.getByText("Accounting mapping not configured.") + ).toBeVisible(); +}); + +test("connected state shows company identity and control set", async ({ + page, +}) => { + await mockQboApi(page, CONNECTED); + await page.goto(FIXTURE); + // "Connected" appears as both the status badge and the
label for + // the connected-at timestamp — assert on the badge text's first match. + await expect(page.getByText("Connected").first()).toBeVisible(); + await expect(page.getByText("Deep Dive Brewing Sandbox")).toBeVisible(); + await expect(page.getByText("1234…7890")).toBeVisible(); + await expect(page.getByText("admin@example.com")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Test connection" }) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Reconnect" }) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Disconnect" }) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Configure mapping" }) + ).toBeVisible(); +}); + +test("test connection reports the refreshed status", async ({ page }) => { + await mockQboApi(page, CONNECTED); + await page.goto(FIXTURE); + await page.getByRole("button", { name: "Test connection" }).press("Enter"); + await expect( + page.getByRole("status").filter({ hasText: "Connection check succeeded." }) + ).toBeVisible(); +}); + +test("disconnect requires the confirmation dialog", async ({ page }) => { + await mockQboApi(page, CONNECTED); + await page.goto(FIXTURE); + await page.getByRole("button", { name: "Disconnect" }).press("Enter"); + await expect( + page.getByRole("heading", { name: "Disconnect QuickBooks?" }) + ).toBeVisible(); + await page + .getByRole("button", { name: "Disconnect", exact: true }) + .last() + .press("Enter"); + await expect( + page.getByRole("status").filter({ hasText: "QuickBooks disconnected." }) + ).toBeVisible(); +}); + +test("reauthorization state shows reconnect guidance", async ({ page }) => { + await mockQboApi(page, REAUTHORIZE); + await page.goto(FIXTURE); + await expect( + page.getByText("Reconnect required", { exact: true }) + ).toBeVisible(); + await expect( + page.getByText(/no longer accepts the stored authorization/) + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Reconnect QuickBooks" }) + ).toBeVisible(); +}); + +test("axe: quickbooks workspace has no serious/critical violations", async ({ + page, +}) => { + await mockQboApi(page, CONNECTED); + await page.goto(FIXTURE); + await waitForAnimations(page); + await expect(page.getByText("Deep Dive Brewing Sandbox")).toBeVisible(); + const blocking = await scanAxe(page, `${FIXTURE} (connected)`); + expect(blocking, formatBlocking(`${FIXTURE} (connected)`, blocking)).toEqual( + [] + ); +}); diff --git a/smoke-tests/fixtures.ts b/smoke-tests/fixtures.ts index 59fbd15..e86fd4e 100644 --- a/smoke-tests/fixtures.ts +++ b/smoke-tests/fixtures.ts @@ -91,6 +91,27 @@ export const test = base.extend({ }) ); + // Same for the QuickBooks summary card (#161): default to a + // not-configured status so unrelated specs stay hermetic. + await page.route(/\/api\/admin\/quickbooks\//, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + ok: true, + connection: { + configured: false, + environment: "sandbox", + environmentLabel: "Sandbox", + status: "not_configured", + health: "unknown", + mappingConfigured: false, + }, + mapping: { configured: false }, + }), + }) + ); + page.on("pageerror", (err) => problems.push(`pageerror: ${err.message}`)); page.on("console", (msg) => { if ( diff --git a/tests/api/admin-quickbooks-callback.test.ts b/tests/api/admin-quickbooks-callback.test.ts new file mode 100644 index 0000000..96561e2 --- /dev/null +++ b/tests/api/admin-quickbooks-callback.test.ts @@ -0,0 +1,235 @@ +// Route-level unit test for the QuickBooks OAuth callback +// (app/api/admin/quickbooks/callback/route.ts). The trust boundary is the +// one-time OAuth state plus a re-check that the initiating admin still has +// an active adminUsers record — an admin disabled between connect and +// callback must not complete the connection. The server-only dependencies +// (@/lib/qbo, @/lib/admin-users, @/lib/log) are module-mocked so the test +// drives the handler directly with a real NextRequest and inspects the +// redirect it returns. +import { describe, it, beforeEach, mock } from "node:test"; +import assert from "node:assert"; +import { NextRequest, type NextResponse } from "next/server"; +import { Timestamp } from "firebase-admin/firestore"; +import { QBO_OAUTH_COOKIE } from "@/lib/qbo-callback-cookie"; +import type { AdminUserRecord } from "@/lib/admin-types"; +import type { QboStateConsumption } from "@/lib/qbo"; + +interface CompleteCall { + code: string; + realmId: string; + uid: string; + email?: string; +} + +interface LogLine { + level: "info" | "warn" | "error"; + event: string; + context?: Record; +} + +let consumeResult: QboStateConsumption = { verdict: "missing" }; +let adminRecord: AdminUserRecord | null = null; +let consumeCalls = 0; +const completed: CompleteCall[] = []; +const logs: LogLine[] = []; + +mock.module("server-only", { namedExports: {} }); +mock.module("@/lib/qbo", { + namedExports: { + consumeQboOAuthState: () => { + consumeCalls += 1; + return Promise.resolve(consumeResult); + }, + completeQboAuthorization: (input: CompleteCall) => { + completed.push(input); + return Promise.resolve({ companyName: "Deep Dive Brewing Sandbox" }); + }, + }, +}); +mock.module("@/lib/admin-users", { + namedExports: { + getAdminUser: () => Promise.resolve(adminRecord), + }, +}); +mock.module("@/lib/log", { + namedExports: { + getRequestId: () => "req-test", + logInfo: (event: string, context?: Record) => + logs.push({ level: "info", event, context }), + logWarn: (event: string, context?: Record) => + logs.push({ level: "warn", event, context }), + logError: ( + event: string, + _error?: unknown, + context?: Record + ) => logs.push({ level: "error", event, context }), + }, +}); + +const CALLBACK_URL = + "https://app.example.com/api/admin/quickbooks/callback"; + +function callbackRequest( + params: Record, + cookieState?: string +): NextRequest { + const url = new URL(CALLBACK_URL); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + const req = new NextRequest(url); + if (cookieState !== undefined) { + req.cookies.set(QBO_OAUTH_COOKIE, cookieState); + } + return req; +} + +function adminUser( + overrides: Partial = {} +): AdminUserRecord { + return { + uid: "uid-1", + email: "admin@example.com", + role: "admin", + status: "active", + createdAt: Timestamp.now(), + ...overrides, + }; +} + +function validParams(): Record { + return { code: "authcode-1", state: "state-1", realmId: "realm-1" }; +} + +async function callRoute(req: NextRequest): Promise { + const { GET } = await import("@/app/api/admin/quickbooks/callback/route"); + return GET(req); +} + +function redirectParams(res: NextResponse): URLSearchParams { + const location = res.headers.get("location"); + assert.ok(location, "expected a redirect response"); + const url = new URL(location); + assert.strictEqual(url.pathname, "/admin/integrations/quickbooks"); + return url.searchParams; +} + +beforeEach(() => { + consumeResult = { verdict: "ok", uid: "uid-1", email: "admin@example.com" }; + adminRecord = adminUser(); + consumeCalls = 0; + completed.length = 0; + logs.length = 0; +}); + +describe("quickbooks oauth callback", () => { + it("completes the connection for a still-active initiating admin", async () => { + const res = await callRoute(callbackRequest(validParams(), "state-1")); + assert.strictEqual(redirectParams(res).get("qbo"), "connected"); + assert.deepStrictEqual(completed, [ + { + code: "authcode-1", + realmId: "realm-1", + uid: "uid-1", + email: "admin@example.com", + }, + ]); + // The httpOnly state cookie is always cleared on the way out. + assert.match(res.headers.get("set-cookie") ?? "", /qbo_oauth_state=/); + }); + + it("still permits an active initiator under the other admin role", async () => { + // Either admin role may run this flow — a role change between connect + // and callback does not block completion while the record stays active. + adminRecord = adminUser({ role: "superadmin" }); + const res = await callRoute(callbackRequest(validParams(), "state-1")); + assert.strictEqual(redirectParams(res).get("qbo"), "connected"); + assert.strictEqual(completed.length, 1); + }); + + it("refuses before provider exchange when the initiator record is gone", async () => { + adminRecord = null; + const res = await callRoute(callbackRequest(validParams(), "state-1")); + const params = redirectParams(res); + assert.strictEqual(params.get("qbo"), "error"); + assert.strictEqual(params.get("qbo_reason"), "connect_failed"); + assert.strictEqual(completed.length, 0); + assert.ok( + logs.some( + (line) => + line.event === "qbo.oauth.failed" && + line.context?.reason === "initiating_admin_inactive" + ), + "expected the initiating_admin_inactive failure to be logged" + ); + // The redirect carries the coarse outcome only — no admin details. + const location = res.headers.get("location") ?? ""; + assert.ok(!location.includes("uid-1")); + assert.ok(!location.includes("admin%40example.com")); + assert.deepStrictEqual([...params.keys()].sort(), [ + "qbo", + "qbo_reason", + ]); + }); + + it("refuses when the initiator was disabled mid-flow", async () => { + adminRecord = adminUser({ status: "disabled" }); + const res = await callRoute(callbackRequest(validParams(), "state-1")); + assert.strictEqual( + redirectParams(res).get("qbo_reason"), + "connect_failed" + ); + assert.strictEqual(completed.length, 0); + }); + + it("rejects when the cookie state does not match the callback state", async () => { + const res = await callRoute( + callbackRequest(validParams(), "other-state") + ); + assert.strictEqual( + redirectParams(res).get("qbo_reason"), + "state_invalid" + ); + assert.strictEqual(consumeCalls, 0); + assert.strictEqual(completed.length, 0); + }); + + it("rejects callbacks missing required parameters", async () => { + const res = await callRoute( + callbackRequest({ state: "state-1" }, "state-1") + ); + assert.strictEqual( + redirectParams(res).get("qbo_reason"), + "invalid_callback" + ); + assert.strictEqual(consumeCalls, 0); + }); + + it("maps non-ok state verdicts to coarse redirect reasons", async () => { + const cases: Array<[QboStateConsumption, string]> = [ + [{ verdict: "missing" }, "state_invalid"], + [{ verdict: "expired" }, "state_expired"], + [{ verdict: "consumed" }, "state_replayed"], + [{ verdict: "environment_mismatch" }, "state_invalid"], + ]; + for (const [consumption, reason] of cases) { + consumeResult = consumption; + const res = await callRoute(callbackRequest(validParams(), "state-1")); + assert.strictEqual( + redirectParams(res).get("qbo_reason"), + reason, + `verdict ${consumption.verdict}` + ); + } + assert.strictEqual(completed.length, 0); + }); + + it("reports a declined provider consent without touching state", async () => { + const res = await callRoute( + callbackRequest({ error: "access_denied" }, "state-1") + ); + assert.strictEqual(redirectParams(res).get("qbo_reason"), "denied"); + assert.strictEqual(consumeCalls, 0); + assert.strictEqual(completed.length, 0); + }); +}); diff --git a/tests/lib/admin-auth-gate-copy.test.ts b/tests/lib/admin-auth-gate-copy.test.ts new file mode 100644 index 0000000..e5b2765 --- /dev/null +++ b/tests/lib/admin-auth-gate-copy.test.ts @@ -0,0 +1,47 @@ +// AdminAuthGate is a client component requiring Firebase Auth, so its copy +// contract is asserted on the source — the same pattern as the trade-inquiry +// route wiring tests in tests/lib/trade-leads.test.ts. Regression guard for +// the shared sign-in prompt: the gate renders a caller-supplied description +// so each admin surface names what it manages instead of one hard-coded +// surface leaking onto the others. +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const read = (path: string) => + readFileSync(join(process.cwd(), path), "utf8"); + +describe("AdminAuthGate sign-in copy", () => { + const gate = read("components/admin-auth-gate.tsx"); + + it("renders a caller-supplied description, not a hard-coded surface", () => { + assert.ok(gate.includes("{description}")); + assert.ok(!gate.includes("manage trade leads")); + assert.ok(!gate.includes("QuickBooks integration")); + }); + + it("gives each admin surface its own prompt", () => { + const cases: Array<[string, string]> = [ + [ + "components/admin-trade-page.tsx", + "manage trade leads.", + ], + [ + "components/admin-payments-page.tsx", + "manage payments.", + ], + [ + "components/admin-quickbooks-page.tsx", + "manage the QuickBooks integration.", + ], + ]; + for (const [path, prompt] of cases) { + const source = read(path); + assert.ok( + source.includes(`description="Sign in with an authorized Google account to ${prompt}"`), + `${path} must pass its own sign-in description` + ); + } + }); +}); diff --git a/tests/lib/qbo-api.test.ts b/tests/lib/qbo-api.test.ts new file mode 100644 index 0000000..445d1ec --- /dev/null +++ b/tests/lib/qbo-api.test.ts @@ -0,0 +1,223 @@ +// Unit tests for lib/qbo-api.ts — the low-level Intuit provider boundary — +// focused on `intuit_tid` correlation capture: the header is read on every +// response so success paths can log it and failure/parse-error paths keep it +// on the normalized QboError. +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; +import { QboError } from "@/lib/qbo-errors"; +import { installQboDbMock } from "./qbo-test-helpers"; + +installQboDbMock(); // registers the "server-only" stub + +const TID = "1a2b3c4d-0000-4d00-8e00-000000000000"; + +function stubFetch(respond: (url: string) => Response) { + const original = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL) => + Promise.resolve(respond(String(input)))) as typeof fetch; + return () => { + globalThis.fetch = original; + }; +} + +// Captures the JSON lines logInfo writes so tests can assert on the +// structured context (correlation id, event name). +function captureInfoLogs() { + const lines: Record[] = []; + const spy = mock.method(console, "info", (line: unknown) => { + if (typeof line === "string") { + try { + lines.push(JSON.parse(line) as Record); + } catch { + lines.push({ raw: line }); + } + } + }); + return { + lines, + restore: () => spy.mock.restore(), + }; +} + +const INPUT = { + environment: "sandbox" as const, + realmId: "realm-1", + accessToken: "at-1", +}; + +describe("intuit_tid capture", () => { + it("logs the provider correlation id on a successful CompanyInfo call", async () => { + const restoreFetch = stubFetch(() => + new Response( + JSON.stringify({ CompanyInfo: { Id: "1", CompanyName: "Co" } }), + { + status: 200, + headers: { + "content-type": "application/json", + intuit_tid: TID, + }, + } + ) + ); + const logs = captureInfoLogs(); + try { + const { fetchQboCompanyInfo } = await import("@/lib/qbo-api"); + const info = await fetchQboCompanyInfo(INPUT); + assert.strictEqual(info.companyName, "Co"); + const line = logs.lines.find((l) => l.event === "qbo.api.companyinfo"); + assert.ok(line, "companyinfo log line missing"); + assert.strictEqual(line.correlationId, TID); + assert.strictEqual(line.environment, "sandbox"); + } finally { + logs.restore(); + restoreFetch(); + } + }); + + it("keeps the correlation id on a failed provider response", async () => { + const restoreFetch = stubFetch( + () => + new Response("provider exploded internally", { + status: 500, + headers: { intuit_tid: TID }, + }) + ); + try { + const { fetchQboCompanyInfo } = await import("@/lib/qbo-api"); + await assert.rejects(fetchQboCompanyInfo(INPUT), (error: unknown) => { + assert.ok(error instanceof QboError); + assert.strictEqual(error.correlationId, TID); + assert.strictEqual(error.kind, "unavailable"); + // The raw provider body never leaks into the surfaced error. + assert.ok(!error.message.includes("exploded")); + return true; + }); + } finally { + restoreFetch(); + } + }); + + it("preserves the correlation id when the body is malformed JSON", async () => { + const restoreFetch = stubFetch( + () => + new Response("not json", { + status: 200, + headers: { intuit_tid: TID }, + }) + ); + try { + const { fetchQboCompanyInfo } = await import("@/lib/qbo-api"); + await assert.rejects(fetchQboCompanyInfo(INPUT), (error: unknown) => { + assert.ok(error instanceof QboError); + assert.strictEqual(error.correlationId, TID); + assert.ok(!error.message.includes("")); + return true; + }); + } finally { + restoreFetch(); + } + }); + + it("tolerates a missing intuit_tid header", async () => { + const restoreFetch = stubFetch( + () => + new Response( + JSON.stringify({ CompanyInfo: { CompanyName: "Co" } }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + const logs = captureInfoLogs(); + try { + const { fetchQboCompanyInfo } = await import("@/lib/qbo-api"); + const info = await fetchQboCompanyInfo(INPUT); + assert.strictEqual(info.companyName, "Co"); + const line = logs.lines.find((l) => l.event === "qbo.api.companyinfo"); + assert.ok(line); + assert.strictEqual(line.correlationId, undefined); + } finally { + logs.restore(); + restoreFetch(); + } + }); + + it("logs one correlation line per discovery run, not per page", async () => { + let calls = 0; + const restoreFetch = stubFetch(() => { + calls++; + // Two full pages then a partial page → three requests, one log line. + const count = calls <= 2 ? 1000 : 3; + const rows = Array.from({ length: count }, (_, i) => ({ + Id: `${calls}-${i}`, + Name: `Item ${i}`, + })); + return new Response( + JSON.stringify({ QueryResponse: { Item: rows } }), + { + status: 200, + headers: { + "content-type": "application/json", + intuit_tid: `tid-page-${calls}`, + }, + } + ); + }); + const logs = captureInfoLogs(); + try { + const { queryQboEntities } = await import("@/lib/qbo-api"); + const entities = await queryQboEntities({ ...INPUT, type: "item" }); + assert.strictEqual(entities.length, 2003); + const entityLogs = logs.lines.filter( + (l) => l.event === "qbo.api.entities" + ); + assert.strictEqual(entityLogs.length, 1); + assert.strictEqual(entityLogs[0]?.correlationId, "tid-page-1"); + assert.strictEqual(entityLogs[0]?.pages, 3); + assert.strictEqual(entityLogs[0]?.type, "item"); + } finally { + logs.restore(); + restoreFetch(); + } + }); +}); + +describe("correlation id in error logs", () => { + // QboError.correlationId reaching normalizeError is what puts the tid + // into every logError/logWarn line (and monitoring) without per-call-site + // plumbing. + it("normalizeError surfaces a provider correlationId", async () => { + const { normalizeError } = await import("@/lib/log"); + const normalized = normalizeError( + new QboError("safe message", "unavailable", 502, TID) + ); + assert.strictEqual(normalized.correlationId, TID); + assert.strictEqual(normalizeError(new Error("x")).correlationId, undefined); + }); + + it("logError emits the correlation id in the structured line", async () => { + const lines: Record[] = []; + const spy = mock.method(console, "error", (line: unknown) => { + if (typeof line === "string") { + try { + lines.push(JSON.parse(line) as Record); + } catch { + /* ignore */ + } + } + }); + try { + const { logError } = await import("@/lib/log"); + logError( + "qbo.test_event", + new QboError("safe message", "unavailable", 502, TID) + ); + const line = lines.find((l) => l.event === "qbo.test_event"); + assert.ok(line); + assert.strictEqual( + (line.error as Record).correlationId, + TID + ); + } finally { + spy.mock.restore(); + } + }); +}); diff --git a/tests/lib/qbo-common.test.ts b/tests/lib/qbo-common.test.ts new file mode 100644 index 0000000..6c876ae --- /dev/null +++ b/tests/lib/qbo-common.test.ts @@ -0,0 +1,128 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + abbreviateRealmId, + isQboDiscoveryEntityType, + isQboEnvironment, + normalizeQboSyncCandidate, + qboEnvironmentLabel, + qboSyncIdFor, +} from "@/lib/qbo-common"; + +describe("abbreviateRealmId", () => { + it("abbreviates long realm ids and keeps short ones", () => { + assert.strictEqual( + abbreviateRealmId("4620816365507846020"), + "4620…6020" + ); + assert.strictEqual(abbreviateRealmId("1234"), "1234"); + }); +}); + +describe("isQboEnvironment / label", () => { + it("recognizes the two environments only", () => { + assert.strictEqual(isQboEnvironment("sandbox"), true); + assert.strictEqual(isQboEnvironment("production"), true); + assert.strictEqual(isQboEnvironment("staging"), false); + assert.strictEqual(qboEnvironmentLabel("sandbox"), "Sandbox"); + assert.strictEqual(qboEnvironmentLabel("production"), "Production"); + }); +}); + +describe("isQboDiscoveryEntityType", () => { + it("accepts the supported entity types", () => { + for (const type of ["account", "item", "customer", "payment-method", "tax-code"]) { + assert.strictEqual(isQboDiscoveryEntityType(type), true, type); + } + for (const bad of ["invoice", "ACCOUNT", "", undefined]) { + assert.strictEqual(isQboDiscoveryEntityType(bad), false, String(bad)); + } + }); +}); + +describe("qboSyncIdFor", () => { + it("builds a deterministic, environment-scoped key from the source identity", () => { + assert.strictEqual( + qboSyncIdFor("sandbox", "stripe_payment", "pay_123"), + "sandbox:stripe_payment:pay_123" + ); + assert.strictEqual( + qboSyncIdFor("production", " stripe_payment ", " pay_123 "), + "production:stripe_payment:pay_123" + ); + // Sandbox and production identities for the same event never collide. + assert.notStrictEqual( + qboSyncIdFor("sandbox", "stripe_payment", "pay_123"), + qboSyncIdFor("production", "stripe_payment", "pay_123") + ); + }); + + it("refuses empty identities", () => { + assert.throws(() => qboSyncIdFor("sandbox", "", "x")); + assert.throws(() => qboSyncIdFor("sandbox", "t", " ")); + }); +}); + +describe("normalizeQboSyncCandidate", () => { + const valid = { + sourceType: "stripe_payment", + sourceId: "pay_abc123", + amountMinorUnits: 4500, + currency: "usd", + transactionDate: "2026-01-05T12:00:00Z", + customerName: " Jane Diver ", + customerEmail: "jane@example.com", + purpose: "brewery_tour", + description: "Tour for 4", + externalRefs: { paymentIntent: "pi_1", charge: "ch_1" }, + tourDate: "2026-02-01", + attendeeCount: 4, + }; + + it("normalizes a valid candidate", () => { + const out = normalizeQboSyncCandidate(valid); + assert.strictEqual(out.currency, "USD"); + assert.strictEqual(out.customerName, "Jane Diver"); + assert.deepStrictEqual(out.externalRefs, { + paymentIntent: "pi_1", + charge: "ch_1", + }); + assert.strictEqual(out.attendeeCount, 4); + }); + + it("rejects malformed required fields", () => { + const bad = [ + { ...valid, sourceType: "Stripe Payment" }, + { ...valid, sourceType: "" }, + { ...valid, sourceId: "" }, + { ...valid, sourceId: "x".repeat(200) }, + { ...valid, amountMinorUnits: 1.5 }, + { ...valid, amountMinorUnits: "45" }, + { ...valid, currency: "us" }, + { ...valid, currency: "US DOLLARS" }, + { ...valid, transactionDate: "not a date" }, + { ...valid, transactionDate: "" }, + ]; + for (const candidate of bad) { + assert.throws( + () => normalizeQboSyncCandidate(candidate as never), + Error, + JSON.stringify(candidate).slice(0, 80) + ); + } + }); + + it("drops unusable optional values rather than failing", () => { + const out = normalizeQboSyncCandidate({ + ...valid, + attendeeCount: 0, + tourDate: " ", + externalRefs: undefined, + description: undefined, + }); + assert.strictEqual(out.attendeeCount, undefined); + assert.strictEqual(out.tourDate, undefined); + assert.strictEqual(out.externalRefs, undefined); + assert.strictEqual(out.description, undefined); + }); +}); diff --git a/tests/lib/qbo-config.test.ts b/tests/lib/qbo-config.test.ts new file mode 100644 index 0000000..555a4e4 --- /dev/null +++ b/tests/lib/qbo-config.test.ts @@ -0,0 +1,173 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + getQboEnvironment, + getQboWebhookVerifierToken, + isQboConfigured, + loadQboConfig, + qboApiBaseUrl, + QBO_ACCOUNTING_SCOPE, + QBO_MINOR_VERSION, +} from "@/lib/qbo-config"; +import { QboConfigError } from "@/lib/qbo-errors"; + +function withEnv( + vars: Record, + fn: () => void +) { + const originals = new Map(); + for (const [name, value] of Object.entries(vars)) { + originals.set(name, process.env[name]); + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + try { + fn(); + } finally { + for (const [name, original] of originals) { + if (original === undefined) { + delete process.env[name]; + } else { + process.env[name] = original; + } + } + } +} + +const FULL_ENV = { + QBO_ENVIRONMENT: "sandbox", + QBO_CLIENT_ID: "client-id", + QBO_CLIENT_SECRET: "client-secret", + QBO_REDIRECT_URI: "https://app.example.com/api/admin/quickbooks/callback", + QBO_WEBHOOK_VERIFIER_TOKEN: "verifier", + QBO_TOKEN_ENCRYPTION_KEY: "0".repeat(64), +}; + +describe("getQboEnvironment", () => { + it("parses sandbox and production", () => { + assert.strictEqual(getQboEnvironment("sandbox"), "sandbox"); + assert.strictEqual(getQboEnvironment("production"), "production"); + assert.strictEqual(getQboEnvironment(" Sandbox "), "sandbox"); + }); + + it("fails closed on a missing value", () => { + assert.throws(() => getQboEnvironment(undefined), QboConfigError); + assert.throws(() => getQboEnvironment(""), QboConfigError); + }); + + it("fails closed on an unknown value", () => { + assert.throws(() => getQboEnvironment("staging"), QboConfigError); + assert.throws(() => getQboEnvironment("dev"), QboConfigError); + }); +}); + +describe("qboApiBaseUrl", () => { + it("uses the Intuit sandbox host for sandbox", () => { + assert.strictEqual( + qboApiBaseUrl("sandbox"), + "https://sandbox-quickbooks.api.intuit.com" + ); + }); + + it("uses the Intuit production host for production", () => { + assert.strictEqual( + qboApiBaseUrl("production"), + "https://quickbooks.api.intuit.com" + ); + }); +}); + +describe("loadQboConfig", () => { + it("returns the full config when all variables are set", () => { + withEnv(FULL_ENV, () => { + const config = loadQboConfig(); + assert.strictEqual(config.environment, "sandbox"); + assert.strictEqual(config.clientId, "client-id"); + assert.strictEqual(config.clientSecret, "client-secret"); + assert.strictEqual(config.redirectUri, FULL_ENV.QBO_REDIRECT_URI); + }); + }); + + it("throws a config error naming each missing variable", () => { + for (const key of ["QBO_CLIENT_ID", "QBO_CLIENT_SECRET", "QBO_REDIRECT_URI", "QBO_ENVIRONMENT"]) { + withEnv({ ...FULL_ENV, [key]: undefined }, () => { + assert.throws(() => loadQboConfig(), QboConfigError, key); + }); + } + }); + + it("rejects a non-URL redirect URI", () => { + withEnv({ ...FULL_ENV, QBO_REDIRECT_URI: "not-a-url" }, () => { + assert.throws(() => loadQboConfig(), QboConfigError); + }); + }); + + it("rejects a non-https redirect URI outside localhost", () => { + withEnv( + { ...FULL_ENV, QBO_REDIRECT_URI: "http://app.example.com/cb" }, + () => { + assert.throws(() => loadQboConfig(), QboConfigError); + } + ); + }); + + it("allows http redirect URIs on localhost for development", () => { + withEnv( + { ...FULL_ENV, QBO_REDIRECT_URI: "http://localhost:3000/api/admin/quickbooks/callback" }, + () => { + const config = loadQboConfig(); + assert.ok(config.redirectUri.startsWith("http://localhost")); + } + ); + }); +}); + +describe("isQboConfigured", () => { + it("is true only when environment plus credentials are all present", () => { + withEnv(FULL_ENV, () => { + assert.strictEqual(isQboConfigured(), true); + }); + withEnv({ ...FULL_ENV, QBO_CLIENT_SECRET: undefined }, () => { + assert.strictEqual(isQboConfigured(), false); + }); + withEnv({ ...FULL_ENV, QBO_ENVIRONMENT: "bogus" }, () => { + assert.strictEqual(isQboConfigured(), false); + }); + withEnv({ ...FULL_ENV, QBO_ENVIRONMENT: undefined }, () => { + assert.strictEqual(isQboConfigured(), false); + }); + // The token-encryption key is part of "configured" — without it the + // OAuth callback would only fail after consent completed. + withEnv({ ...FULL_ENV, QBO_TOKEN_ENCRYPTION_KEY: undefined }, () => { + assert.strictEqual(isQboConfigured(), false); + }); + withEnv({ ...FULL_ENV, QBO_TOKEN_ENCRYPTION_KEY: "short" }, () => { + assert.strictEqual(isQboConfigured(), false); + }); + }); +}); + +describe("getQboWebhookVerifierToken", () => { + it("returns the token or undefined", () => { + withEnv(FULL_ENV, () => { + assert.strictEqual(getQboWebhookVerifierToken(), "verifier"); + }); + withEnv({ ...FULL_ENV, QBO_WEBHOOK_VERIFIER_TOKEN: undefined }, () => { + assert.strictEqual(getQboWebhookVerifierToken(), undefined); + }); + }); +}); + +describe("constants", () => { + it("requests the accounting scope and a supported minor version", () => { + assert.strictEqual( + QBO_ACCOUNTING_SCOPE, + "com.intuit.quickbooks.accounting" + ); + // Intuit deprecated minor versions below 75 on 2025-08-01. + assert.ok(Number(QBO_MINOR_VERSION) >= 75); + }); +}); diff --git a/tests/lib/qbo-crypto.test.ts b/tests/lib/qbo-crypto.test.ts new file mode 100644 index 0000000..24060b5 --- /dev/null +++ b/tests/lib/qbo-crypto.test.ts @@ -0,0 +1,84 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { randomBytes } from "node:crypto"; +import { + decryptQboSecret, + encryptQboSecret, + parseQboEncryptionKey, +} from "@/lib/qbo-crypto"; +import { QboConfigError, QboError } from "@/lib/qbo-errors"; + +const HEX_KEY = randomBytes(32).toString("hex"); +const BASE64_KEY = randomBytes(32).toString("base64"); + +describe("parseQboEncryptionKey", () => { + it("accepts a 64-char hex key", () => { + const key = parseQboEncryptionKey(HEX_KEY); + assert.strictEqual(key.length, 32); + }); + + it("accepts a base64-encoded 32-byte key", () => { + const key = parseQboEncryptionKey(BASE64_KEY); + assert.strictEqual(key.length, 32); + }); + + it("fails closed when the key is missing", () => { + assert.throws(() => parseQboEncryptionKey(undefined), QboConfigError); + assert.throws(() => parseQboEncryptionKey(""), QboConfigError); + }); + + it("rejects malformed and wrong-length keys", () => { + assert.throws(() => parseQboEncryptionKey("tooshort"), QboConfigError); + assert.throws( + () => parseQboEncryptionKey(randomBytes(16).toString("hex")), + QboConfigError + ); + assert.throws( + () => parseQboEncryptionKey("z".repeat(64)), + QboConfigError + ); + }); +}); + +describe("encrypt/decrypt roundtrip", () => { + it("encrypts and decrypts a secret", () => { + const key = parseQboEncryptionKey(HEX_KEY); + const secret = "refresh-token-value-with-symbols_AB123=="; + const envelope = encryptQboSecret(secret, key); + assert.notStrictEqual(envelope, secret); + assert.ok(envelope.startsWith("v1.")); + assert.strictEqual(decryptQboSecret(envelope, key), secret); + }); + + it("produces a fresh nonce per encryption", () => { + const key = parseQboEncryptionKey(HEX_KEY); + const a = encryptQboSecret("same", key); + const b = encryptQboSecret("same", key); + assert.notStrictEqual(a, b); + }); + + it("refuses to decrypt with the wrong key", () => { + const key = parseQboEncryptionKey(HEX_KEY); + const other = parseQboEncryptionKey(randomBytes(32).toString("hex")); + const envelope = encryptQboSecret("secret", key); + assert.throws(() => decryptQboSecret(envelope, other), QboError); + }); + + it("refuses tampered ciphertext", () => { + const key = parseQboEncryptionKey(HEX_KEY); + const envelope = encryptQboSecret("secret", key); + const parts = envelope.split("."); + // Flip a byte in the ciphertext segment. + const ct = Buffer.from(parts[3], "base64"); + ct[0] = ct[0] ^ 0xff; + parts[3] = ct.toString("base64"); + assert.throws(() => decryptQboSecret(parts.join("."), key), QboError); + }); + + it("refuses malformed envelopes", () => { + const key = parseQboEncryptionKey(HEX_KEY); + for (const bad of ["", "abc", "v2.a.b.c", "v1.a.b", "v1.a.b.c.d"]) { + assert.throws(() => decryptQboSecret(bad, key), QboError); + } + }); +}); diff --git a/tests/lib/qbo-protocol.test.ts b/tests/lib/qbo-protocol.test.ts new file mode 100644 index 0000000..93db42c --- /dev/null +++ b/tests/lib/qbo-protocol.test.ts @@ -0,0 +1,587 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { createHmac } from "node:crypto"; +import { + buildQboAuthorizationUrl, + buildQboQueryUrl, + buildQboRevokeRequest, + buildQboTokenExchangeRequest, + buildQboTokenRefreshRequest, + canonicalizeCompanyInfo, + canonicalizeQboQueryEntities, + classifyQboOAuthState, + decideQboTokenAction, + generateQboOAuthState, + parseQboTokenResponse, + parseQboWebhookNotifications, + qboEntityQueryStatement, + qboErrorForHttpStatus, + qboQueryRowCount, + qboTokenResponseIsInvalidGrant, + qboWebhookDedupeKey, + toQboError, + verifyQboWebhookSignature, + QBO_ACCESS_TOKEN_SKEW_MS, +} from "@/lib/qbo-protocol"; +import { QboError } from "@/lib/qbo-errors"; + +describe("generateQboOAuthState", () => { + it("produces strong, unique, url-safe states", () => { + const a = generateQboOAuthState(); + const b = generateQboOAuthState(); + assert.notStrictEqual(a, b); + assert.ok(a.length >= 40); + assert.match(a, /^[A-Za-z0-9_-]+$/); + }); +}); + +describe("classifyQboOAuthState", () => { + const now = 1_000_000; + it("accepts an unconsumed, unexpired state", () => { + assert.strictEqual( + classifyQboOAuthState({ expiresAtMs: now + 1 }, now), + "ok" + ); + }); + it("rejects expired and consumed states", () => { + assert.strictEqual( + classifyQboOAuthState({ expiresAtMs: now }, now), + "expired" + ); + assert.strictEqual( + classifyQboOAuthState( + { expiresAtMs: now + 1000, consumedAtMs: now - 1 }, + now + ), + "consumed" + ); + }); +}); + +describe("buildQboAuthorizationUrl", () => { + it("targets Intuit with the accounting scope and the given state", () => { + const url = new URL( + buildQboAuthorizationUrl({ + clientId: "cid", + redirectUri: "https://app.example.com/cb", + state: "state-123", + }) + ); + assert.strictEqual(url.origin, "https://appcenter.intuit.com"); + assert.strictEqual(url.pathname, "/connect/oauth2"); + assert.strictEqual( + url.searchParams.get("scope"), + "com.intuit.quickbooks.accounting" + ); + assert.strictEqual(url.searchParams.get("response_type"), "code"); + assert.strictEqual(url.searchParams.get("state"), "state-123"); + assert.strictEqual( + url.searchParams.get("redirect_uri"), + "https://app.example.com/cb" + ); + assert.strictEqual(url.searchParams.get("client_id"), "cid"); + }); +}); + +describe("token endpoint requests", () => { + it("builds an authorization_code exchange with Basic auth", () => { + const req = buildQboTokenExchangeRequest({ + clientId: "cid", + clientSecret: "sec", + code: "authcode", + redirectUri: "https://app.example.com/cb", + }); + assert.strictEqual( + req.url, + "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer" + ); + assert.strictEqual( + req.headers.Authorization, + `Basic ${Buffer.from("cid:sec").toString("base64")}` + ); + const body = new URLSearchParams(req.body); + assert.strictEqual(body.get("grant_type"), "authorization_code"); + assert.strictEqual(body.get("code"), "authcode"); + assert.strictEqual(body.get("redirect_uri"), "https://app.example.com/cb"); + }); + + it("builds a refresh_token request", () => { + const req = buildQboTokenRefreshRequest({ + clientId: "cid", + clientSecret: "sec", + refreshToken: "rt", + }); + const body = new URLSearchParams(req.body); + assert.strictEqual(body.get("grant_type"), "refresh_token"); + assert.strictEqual(body.get("refresh_token"), "rt"); + }); + + it("builds a JSON revoke request carrying the refresh token", () => { + const req = buildQboRevokeRequest({ + clientId: "cid", + clientSecret: "sec", + refreshToken: "rt", + }); + assert.strictEqual( + req.url, + "https://developer.api.intuit.com/v2/oauth2/tokens/revoke" + ); + assert.deepStrictEqual(JSON.parse(req.body), { token: "rt" }); + }); +}); + +describe("parseQboTokenResponse", () => { + it("parses a full Intuit token response", () => { + const tokens = parseQboTokenResponse({ + token_type: "bearer", + access_token: "at", + refresh_token: "rt", + expires_in: 3600, + x_refresh_token_expires_in: 8726400, + }); + assert.deepStrictEqual(tokens, { + accessToken: "at", + refreshToken: "rt", + accessTokenExpiresInSec: 3600, + refreshTokenExpiresInSec: 8726400, + }); + }); + + it("tolerates a missing refresh-token expiry", () => { + const tokens = parseQboTokenResponse({ + access_token: "at", + refresh_token: "rt", + expires_in: 3600, + }); + assert.strictEqual(tokens.refreshTokenExpiresInSec, undefined); + }); + + it("rejects malformed responses", () => { + for (const bad of [ + null, + "x", + {}, + { access_token: "at", expires_in: 3600 }, + { access_token: "", refresh_token: "rt", expires_in: 3600 }, + { access_token: "at", refresh_token: "rt", expires_in: 0 }, + ]) { + assert.throws(() => parseQboTokenResponse(bad), QboError); + } + }); +}); + +describe("qboTokenResponseIsInvalidGrant", () => { + it("detects invalid_grant bodies only", () => { + assert.strictEqual( + qboTokenResponseIsInvalidGrant({ error: "invalid_grant" }), + true + ); + assert.strictEqual( + qboTokenResponseIsInvalidGrant({ error: "temporarily_unavailable" }), + false + ); + assert.strictEqual(qboTokenResponseIsInvalidGrant(null), false); + }); +}); + +describe("decideQboTokenAction", () => { + const now = 1_000_000; + it("uses a live access token", () => { + assert.strictEqual( + decideQboTokenAction( + { accessTokenExpiresAtMs: now + QBO_ACCESS_TOKEN_SKEW_MS + 1000 }, + now + ), + "use" + ); + }); + it("refreshes when the token is inside the safety skew", () => { + assert.strictEqual( + decideQboTokenAction( + { accessTokenExpiresAtMs: now + QBO_ACCESS_TOKEN_SKEW_MS - 1 }, + now + ), + "refresh" + ); + }); + it("waits while another caller holds the refresh lease", () => { + assert.strictEqual( + decideQboTokenAction( + { + accessTokenExpiresAtMs: now - 1000, + refreshLeaseUntilMs: now + 5000, + }, + now + ), + "wait" + ); + }); + it("refreshes once the lease has expired", () => { + assert.strictEqual( + decideQboTokenAction( + { + accessTokenExpiresAtMs: now - 1000, + refreshLeaseUntilMs: now - 1, + }, + now + ), + "refresh" + ); + }); +}); + +describe("qboErrorForHttpStatus", () => { + it("maps statuses to the internal taxonomy", () => { + const cases: Array<[number, string]> = [ + [401, "authorization_expired"], + [403, "permission_denied"], + [429, "rate_limited"], + [400, "validation"], + [422, "validation"], + [500, "unavailable"], + [503, "unavailable"], + [418, "unexpected"], + ]; + for (const [status, kind] of cases) { + const err = qboErrorForHttpStatus(status); + assert.strictEqual(err.kind, kind, `status ${status}`); + assert.ok(err instanceof QboError); + assert.strictEqual(err.clientSafe, true); + } + }); + + it("treats invalid_grant as authorization_expired regardless of status", () => { + const err = qboErrorForHttpStatus(400, { invalidGrant: true }); + assert.strictEqual(err.kind, "authorization_expired"); + }); + + it("retains the Intuit correlation id without leaking bodies", () => { + const err = qboErrorForHttpStatus(500, { correlationId: "tid-123" }); + assert.strictEqual(err.correlationId, "tid-123"); + assert.ok(!err.message.includes("{")); + }); +}); + +describe("toQboError", () => { + it("passes QboError through and wraps unknowns as unavailable", () => { + const original = new QboError("x", "rate_limited"); + assert.strictEqual(toQboError(original, "op"), original); + const wrapped = toQboError(new Error("boom"), "op"); + assert.strictEqual(wrapped.kind, "unavailable"); + assert.ok(!wrapped.message.includes("boom")); + }); +}); + +describe("buildQboQueryUrl", () => { + it("encodes the query and pins the minor version", () => { + const url = new URL( + buildQboQueryUrl("sandbox", "realm-1", "select * from Account") + ); + assert.strictEqual( + url.origin, + "https://sandbox-quickbooks.api.intuit.com" + ); + assert.strictEqual( + url.pathname, + "/v3/company/realm-1/query" + ); + assert.strictEqual(url.searchParams.get("minorversion"), "75"); + assert.strictEqual( + url.searchParams.get("query"), + "select * from Account" + ); + }); + + it("never allows a non-Intuit host", () => { + const url = buildQboQueryUrl("production", "realm-1", "select 1"); + assert.ok(url.startsWith("https://quickbooks.api.intuit.com/")); + }); +}); + +describe("verifyQboWebhookSignature", () => { + const verifier = "verifier-token"; + const body = JSON.stringify({ eventNotifications: [] }); + + function sign(payload: string): string { + return createHmac("sha256", verifier).update(payload, "utf8").digest("base64"); + } + + it("accepts a valid signature over the raw body", () => { + assert.strictEqual( + verifyQboWebhookSignature(body, sign(body), verifier), + true + ); + }); + + it("rejects invalid, missing, and malformed signatures", () => { + assert.strictEqual( + verifyQboWebhookSignature(body, sign("other"), verifier), + false + ); + assert.strictEqual(verifyQboWebhookSignature(body, null, verifier), false); + assert.strictEqual(verifyQboWebhookSignature(body, "", verifier), false); + assert.strictEqual( + verifyQboWebhookSignature(body, "not-base64!!", verifier), + false + ); + assert.strictEqual( + verifyQboWebhookSignature(body, sign(body), "wrong-verifier"), + false + ); + }); + + it("rejects a signature computed over a transformed body", () => { + const pretty = JSON.stringify(JSON.parse(body), null, 2); + assert.strictEqual( + verifyQboWebhookSignature(pretty, sign(body), verifier), + false + ); + }); +}); + +describe("parseQboWebhookNotifications", () => { + it("parses batched notifications and entities", () => { + const payload = JSON.stringify({ + eventNotifications: [ + { + realmId: "r1", + dataChangeEvent: { + entities: [ + { + name: "Customer", + id: "42", + operation: "Create", + lastUpdated: "2026-01-01T00:00:00Z", + }, + { + name: "Invoice", + id: 7, + operation: "Update", + lastUpdated: "2026-01-01T00:01:00Z", + }, + ], + }, + }, + { + realmId: "r2", + dataChangeEvent: { + entities: [ + { + name: "Item", + id: "3", + operation: "Delete", + lastUpdated: "2026-01-01T00:02:00Z", + }, + ], + }, + }, + ], + }); + const notifications = parseQboWebhookNotifications(payload); + assert.strictEqual(notifications.length, 3); + assert.deepStrictEqual(notifications[0], { + realmId: "r1", + entityName: "Customer", + entityId: "42", + operation: "Create", + lastUpdated: "2026-01-01T00:00:00Z", + }); + assert.strictEqual(notifications[1].entityId, "7"); + assert.strictEqual(notifications[2].realmId, "r2"); + }); + + it("drops malformed payloads and entities defensively", () => { + assert.deepStrictEqual(parseQboWebhookNotifications("not json"), []); + assert.deepStrictEqual(parseQboWebhookNotifications("{}"), []); + assert.deepStrictEqual( + parseQboWebhookNotifications('{"eventNotifications":"x"}'), + [] + ); + const partial = JSON.stringify({ + eventNotifications: [ + { realmId: "", dataChangeEvent: { entities: [{ name: "X", id: "1" }] } }, + { realmId: "r", dataChangeEvent: { entities: [{ id: "1" }] } }, + { realmId: "r", dataChangeEvent: { entities: [{ name: "X" }] } }, + { realmId: "r", dataChangeEvent: { entities: [{ name: "X", id: "9" }] } }, + ], + }); + const notifications = parseQboWebhookNotifications(partial); + assert.strictEqual(notifications.length, 1); + assert.strictEqual(notifications[0].entityId, "9"); + }); +}); + +describe("qboWebhookDedupeKey", () => { + it("is stable for identical notifications and differs otherwise", () => { + const base = { + realmId: "r", + entityName: "Customer", + entityId: "1", + operation: "Create", + lastUpdated: "t", + }; + assert.strictEqual(qboWebhookDedupeKey(base), qboWebhookDedupeKey({ ...base })); + assert.notStrictEqual( + qboWebhookDedupeKey(base), + qboWebhookDedupeKey({ ...base, operation: "Update" }) + ); + assert.match(qboWebhookDedupeKey(base), /^[0-9a-f]{64}$/); + }); +}); + +describe("canonicalizeCompanyInfo", () => { + it("extracts the safe company metadata", () => { + const info = canonicalizeCompanyInfo({ + CompanyInfo: { + // Real sandbox responses carry an entity Id unrelated to the OAuth + // realmId — it is provider metadata, never the connection realm. + Id: "1", + CompanyName: "Deep Dive Brewing Sandbox", + Country: "US", + Email: { Address: "internal@example.com" }, + }, + }); + assert.deepStrictEqual(info, { + companyName: "Deep Dive Brewing Sandbox", + country: "US", + }); + // Provider fields — including Id — never leak into the canonical shape. + assert.ok(!("realmId" in info)); + assert.ok(!("Id" in info)); + }); + + it("accepts a valid payload whatever the Id value, or none at all", () => { + for (const payload of [ + { CompanyInfo: { Id: "1", CompanyName: "Co" } }, + { CompanyInfo: { Id: "9341454755", CompanyName: "Co" } }, + { CompanyInfo: { CompanyName: "Co" } }, + ]) { + assert.deepStrictEqual(canonicalizeCompanyInfo(payload), { + companyName: "Co", + country: undefined, + }); + } + }); + + it("rejects malformed and missing CompanyInfo payloads", () => { + const badPayloads: unknown[] = [ + null, + {}, + "x", + { CompanyInfo: null }, + { CompanyInfo: {} }, + { CompanyInfo: { Id: "1" } }, + { CompanyInfo: { CompanyName: "" } }, + { CompanyInfo: { CompanyName: 42 } }, + ]; + for (const payload of badPayloads) { + try { + canonicalizeCompanyInfo(payload); + assert.fail(`expected rejection for ${JSON.stringify(payload)}`); + } catch (error) { + assert.ok(error instanceof QboError); + assert.strictEqual(error.kind, "unexpected"); + assert.strictEqual(error.clientSafe, true); + } + } + }); + + it("does not echo provider data in the rejection", () => { + try { + canonicalizeCompanyInfo({ + CompanyInfo: { Id: "secret-realm", Notes: "leak" }, + }); + assert.fail("expected rejection"); + } catch (error) { + assert.ok(error instanceof QboError); + assert.ok(!error.message.includes("secret-realm")); + assert.ok(!error.message.includes("leak")); + } + }); +}); + +describe("canonicalizeQboQueryEntities", () => { + it("reduces accounts to safe summaries", () => { + const rows = canonicalizeQboQueryEntities("account", { + QueryResponse: { + Account: [ + { Id: "1", Name: "Stripe Clearing", AccountType: "Bank", AccountSubType: "Checking", Active: true, SecretField: "x" }, + { Id: "2", Name: "Old", AccountType: "Income", Active: false }, + { Name: "no id" }, + ], + }, + }); + assert.strictEqual(rows.length, 2); + assert.deepStrictEqual(rows[0], { + id: "1", + name: "Stripe Clearing", + type: "Bank", + detail: "Checking", + active: true, + }); + assert.strictEqual(rows[1].active, false); + assert.ok(!("SecretField" in rows[0])); + }); + + it("handles customers and empty responses", () => { + const rows = canonicalizeQboQueryEntities("customer", { + QueryResponse: { + Customer: [{ Id: "9", DisplayName: "Walk-in Customer" }], + }, + }); + assert.deepStrictEqual(rows, [ + { id: "9", name: "Walk-in Customer", type: undefined, detail: undefined, active: true }, + ]); + assert.deepStrictEqual(canonicalizeQboQueryEntities("item", {}), []); + assert.deepStrictEqual(canonicalizeQboQueryEntities("item", null), []); + }); +}); + +describe("qboEntityQueryStatement", () => { + it("maps every discovery type to a select query", () => { + const expected: Record = { + account: "select * from Account", + item: "select * from Item", + customer: "select * from Customer", + "payment-method": "select * from PaymentMethod", + "tax-code": "select * from TaxCode", + }; + for (const [type, statement] of Object.entries(expected)) { + assert.strictEqual( + qboEntityQueryStatement(type as never), + statement + ); + } + }); + + it("appends pagination bounds when requested", () => { + assert.strictEqual( + qboEntityQueryStatement("customer", 1001, 1000), + "select * from Customer startposition 1001 maxresults 1000" + ); + assert.strictEqual( + qboEntityQueryStatement("account", 1), + "select * from Account startposition 1 maxresults 1000" + ); + }); +}); + +describe("qboQueryRowCount", () => { + it("counts raw rows per entity type for pagination", () => { + assert.strictEqual( + qboQueryRowCount("item", { + QueryResponse: { Item: [{ Id: "1" }, { Id: "2" }] }, + }), + 2 + ); + assert.strictEqual(qboQueryRowCount("item", { QueryResponse: {} }), 0); + assert.strictEqual(qboQueryRowCount("item", null), 0); + // The count is of the raw page, not the canonicalized rows. + assert.strictEqual( + qboQueryRowCount("account", { + QueryResponse: { Account: [{ noId: true }] }, + }), + 1 + ); + }); +}); diff --git a/tests/lib/qbo-sync.test.ts b/tests/lib/qbo-sync.test.ts new file mode 100644 index 0000000..f687644 --- /dev/null +++ b/tests/lib/qbo-sync.test.ts @@ -0,0 +1,103 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import type { QuickBooksSyncCandidate } from "@/lib/qbo-common"; +import { installQboDbMock, withEnv } from "./qbo-test-helpers"; + +const firestore = installQboDbMock(); +const ENV = { QBO_ENVIRONMENT: "sandbox" }; +const RECORDS = "qboSyncRecords"; + +async function enqueue(candidate: QuickBooksSyncCandidate) { + const { enqueueAccountingTransaction } = await import("@/lib/qbo-sync"); + return withEnv(ENV, () => enqueueAccountingTransaction(candidate)); +} + +describe("enqueueAccountingTransaction", () => { + it("persists a candidate whose omitted optional fields are stripped — the write survives Firestore serialization", async () => { + firestore.reset(); + // Only the required fields — the normalizer leaves every optional key + // as `undefined`, which the real Firestore serializer rejects. + const result = await enqueue({ + sourceType: "stripe_payment", + sourceId: "pay_1", + amountMinorUnits: 4500, + currency: "usd", + transactionDate: "2026-01-05T12:00:00Z", + }); + + assert.strictEqual(result.outcome, "pending"); + assert.strictEqual(result.syncId, "sandbox:stripe_payment:pay_1"); + const doc = firestore.docs.get( + `${RECORDS}/sandbox:stripe_payment:pay_1` + ); + assert.ok(doc, "sync record was not created"); + // The persisted candidate contains only concrete values — no key is + // present with an undefined value. + assert.deepStrictEqual(doc.candidate, { + sourceType: "stripe_payment", + sourceId: "pay_1", + amountMinorUnits: 4500, + currency: "USD", + transactionDate: "2026-01-05T12:00:00Z", + }); + }); + + it("keeps every defined optional field in the persisted candidate", async () => { + firestore.reset(); + const result = await enqueue({ + sourceType: "stripe_payment", + sourceId: "pay_2", + amountMinorUnits: 1200, + currency: "usd", + transactionDate: "2026-01-05T12:00:00Z", + customerName: "Jane Diver", + purpose: "brewery_tour", + externalRefs: { paymentIntent: "pi_1" }, + attendeeCount: 4, + }); + + assert.strictEqual(result.outcome, "pending"); + const doc = firestore.docs.get( + `${RECORDS}/sandbox:stripe_payment:pay_2` + ); + assert.deepStrictEqual(doc?.candidate, { + sourceType: "stripe_payment", + sourceId: "pay_2", + amountMinorUnits: 1200, + currency: "USD", + transactionDate: "2026-01-05T12:00:00Z", + customerName: "Jane Diver", + purpose: "brewery_tour", + externalRefs: { paymentIntent: "pi_1" }, + attendeeCount: 4, + }); + }); + + it("returns duplicate for a replayed source identity and leaves the record untouched", async () => { + firestore.reset({ + [`${RECORDS}/sandbox:stripe_payment:pay_1`]: { + syncId: "sandbox:stripe_payment:pay_1", + status: "synced", + qboEntityId: "77", + }, + }); + const result = await enqueue({ + sourceType: "stripe_payment", + sourceId: "pay_1", + amountMinorUnits: 4500, + currency: "usd", + transactionDate: "2026-01-05T12:00:00Z", + }); + + assert.strictEqual(result.outcome, "duplicate"); + assert.strictEqual( + firestore.docs.get(`${RECORDS}/sandbox:stripe_payment:pay_1`)?.status, + "synced" + ); + assert.strictEqual( + firestore.docs.get(`${RECORDS}/sandbox:stripe_payment:pay_1`) + ?.qboEntityId, + "77" + ); + }); +}); diff --git a/tests/lib/qbo-test-helpers.ts b/tests/lib/qbo-test-helpers.ts new file mode 100644 index 0000000..9051ceb --- /dev/null +++ b/tests/lib/qbo-test-helpers.ts @@ -0,0 +1,191 @@ +// Shared scaffolding for unit tests of the QBO server modules +// (lib/qbo-sync.ts, lib/qbo-tokens.ts, lib/qbo-webhook.ts). Those modules +// carry `import "server-only"` (a throw-only marker the Next.js bundler +// gates on) and talk to Firestore through lib/firebase-admin-db.ts, so +// tests substitute both with module mocks and drive an in-memory +// Firestore stand-in. +// +// The fake models just enough of the Admin SDK surface — collection() +// .doc(), ref.get/update/set, tx.get/create/update inside runTransaction — +// and reproduces the two real-client behaviors the tests rely on: +// - `undefined` values anywhere in a write are rejected at +// serialization time (ignoreUndefinedProperties defaults to false) +// - FieldValue.delete() removes a field; serverTimestamp() stores a +// marker +import { mock } from "node:test"; + +type Doc = Record; + +interface FakeSnapshot { + exists: boolean; + data(): Doc | undefined; +} + +export interface FakeDocRef { + path: string; + get(): Promise; + update(data: Doc): Promise; + set(data: Doc): Promise; +} + +interface FakeTx { + get(ref: FakeDocRef): Promise; + create(ref: FakeDocRef, data: Doc): void; + update(ref: FakeDocRef, data: Doc): void; +} + +export interface FakeFirestore { + db: { + collection(name: string): { doc(id: string): FakeDocRef }; + runTransaction(fn: (tx: FakeTx) => T | Promise): Promise; + }; + /** Live document contents keyed by "collection/docId". Tests may read + * or mutate between calls — e.g. to simulate a disconnect landing + * while a token refresh is in flight. */ + docs: Map; + /** Paths whose non-transactional ref.get() should reject — used to + * prove lookup failures propagate. */ + failGet: Set; + /** Replaces all stored documents. */ + reset(seed?: Record): void; +} + +function isSentinel(value: unknown, ctorName: string): boolean { + return ( + typeof value === "object" && + value !== null && + (value as object).constructor?.name === ctorName + ); +} + +// Mirrors the Firestore client's default behavior +// (ignoreUndefinedProperties: false): an `undefined` anywhere in the +// payload fails serialization, so a write accepted here would also be +// accepted by the real SDK. +function assertFirestoreSerializable(value: unknown, path: string): void { + if (value === undefined) { + throw new Error(`Firestore rejected an undefined value at "${path}".`); + } + if (Array.isArray(value)) { + value.forEach((entry, index) => + assertFirestoreSerializable(entry, `${path}[${index}]`) + ); + return; + } + if ( + typeof value === "object" && + value !== null && + (value as object).constructor === Object + ) { + for (const [key, entry] of Object.entries(value)) { + assertFirestoreSerializable(entry, `${path}.${key}`); + } + } +} + +export function createFakeFirestore(): FakeFirestore { + const docs = new Map(); + const failGet = new Set(); + + function snapshot(path: string): FakeSnapshot { + const data = docs.get(path); + return { exists: data !== undefined, data: () => data }; + } + + function applyWrite(path: string, data: Doc): void { + assertFirestoreSerializable(data, path); + const current = docs.get(path) ?? {}; + for (const [key, value] of Object.entries(data)) { + if (isSentinel(value, "DeleteTransform")) { + delete current[key]; + } else if (isSentinel(value, "ServerTimestampTransform")) { + current[key] = "__SERVER_TIMESTAMP__"; + } else { + current[key] = value; + } + } + docs.set(path, current); + } + + const tx: FakeTx = { + get: (ref) => Promise.resolve(snapshot(ref.path)), + create: (ref, data) => { + if (docs.has(ref.path)) { + throw new Error(`Document already exists: ${ref.path}`); + } + applyWrite(ref.path, data); + }, + update: (ref, data) => applyWrite(ref.path, data), + }; + + function docRef(path: string): FakeDocRef { + return { + path, + get: () => + failGet.has(path) + ? Promise.reject(new Error(`Simulated Firestore failure: ${path}`)) + : Promise.resolve(snapshot(path)), + update: (data) => Promise.resolve(applyWrite(path, data)), + set: (data) => Promise.resolve(applyWrite(path, data)), + }; + } + + const db = { + collection: (name: string) => ({ + doc: (id: string) => docRef(`${name}/${id}`), + }), + runTransaction: (fn: (t: FakeTx) => T | Promise) => + Promise.resolve(fn(tx)), + }; + + return { + db, + docs, + failGet, + reset(seed: Record = {}) { + docs.clear(); + for (const [path, data] of Object.entries(seed)) { + docs.set(path, { ...data }); + } + }, + }; +} + +// Registers module mocks for "server-only" and the Admin-SDK Firestore +// accessor, then returns the fake the tests drive. Must run before the +// module under test is first imported — call at the top of the test file +// and dynamic-import the module under test inside the tests. +export function installQboDbMock(): FakeFirestore { + mock.module("server-only", { namedExports: {} }); + const firestore = createFakeFirestore(); + mock.module("@/lib/firebase-admin-db", { + namedExports: { getFirebaseAdminDb: () => firestore.db }, + }); + return firestore; +} + +export async function withEnv( + vars: Record, + fn: () => T | Promise +): Promise { + const originals = new Map(); + for (const [name, value] of Object.entries(vars)) { + originals.set(name, process.env[name]); + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + try { + return await fn(); + } finally { + for (const [name, original] of originals) { + if (original === undefined) { + delete process.env[name]; + } else { + process.env[name] = original; + } + } + } +} diff --git a/tests/lib/qbo-tokens.test.ts b/tests/lib/qbo-tokens.test.ts new file mode 100644 index 0000000..9af88c5 --- /dev/null +++ b/tests/lib/qbo-tokens.test.ts @@ -0,0 +1,258 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { Timestamp } from "firebase-admin/firestore"; +import { + decryptQboSecret, + encryptQboSecret, + parseQboEncryptionKey, +} from "@/lib/qbo-crypto"; +import { installQboDbMock, withEnv } from "./qbo-test-helpers"; + +const firestore = installQboDbMock(); +const CONNECTION = "qboConnections/sandbox"; +const ENV = { + QBO_ENVIRONMENT: "sandbox", + QBO_CLIENT_ID: "client-id", + QBO_CLIENT_SECRET: "client-secret", + QBO_REDIRECT_URI: "https://app.example.com/api/admin/quickbooks/callback", + // A zeroed AES-256 test key — never a real secret. + QBO_TOKEN_ENCRYPTION_KEY: "0".repeat(64), +}; + +const key = () => parseQboEncryptionKey(ENV.QBO_TOKEN_ENCRYPTION_KEY); +const enc = (value: string) => encryptQboSecret(value, key()); +const dec = (value: unknown) => decryptQboSecret(String(value), key()); + +function doc() { + const data = firestore.docs.get(CONNECTION); + assert.ok(data, "connection doc missing"); + return data; +} + +function seedConnected() { + firestore.reset({ + [CONNECTION]: { + environment: "sandbox", + status: "connected", + realmId: "realm-1", + accessTokenEnc: enc("at-0"), + refreshTokenEnc: enc("rt-0"), + // Expired access token → the acquire step takes the refresh path. + accessTokenExpiresAt: Timestamp.fromMillis(Date.now() - 1000), + }, + }); +} + +function tokenResponse(): Response { + return new Response( + JSON.stringify({ + access_token: "at-1", + refresh_token: "rt-1", + expires_in: 3600, + x_refresh_token_expires_in: 8_640_000, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +// Drives the Intuit round-trip. `midFlight` mutates the stored record to +// simulate an admin action or a second caller landing while the provider +// request is outstanding. +function stubFetch(midFlight?: () => void, respond = tokenResponse) { + const original = globalThis.fetch; + globalThis.fetch = (() => { + midFlight?.(); + return Promise.resolve(respond()); + }) as typeof fetch; + return () => { + globalThis.fetch = original; + }; +} + +async function getAccessToken() { + const { getQuickBooksAccessToken } = await import("@/lib/qbo-tokens"); + return withEnv(ENV, () => getQuickBooksAccessToken()); +} + +describe("getQuickBooksAccessToken", () => { + it("commits rotated tokens when the record is unchanged mid-refresh", async () => { + seedConnected(); + const restore = stubFetch(); + try { + const result = await getAccessToken(); + assert.strictEqual(result.accessToken, "at-1"); + assert.strictEqual(result.realmId, "realm-1"); + assert.strictEqual(dec(doc().accessTokenEnc), "at-1"); + assert.strictEqual(dec(doc().refreshTokenEnc), "rt-1"); + assert.strictEqual(doc().status, "connected"); + assert.strictEqual(doc().refreshLeaseUntil, undefined); + } finally { + restore(); + } + }); + + it("cannot revive a connection disconnected while the refresh was in flight", async () => { + seedConnected(); + // Mirrors disconnectQbo: tokens and the lease are destroyed. + const restore = stubFetch(() => { + const data = doc(); + data.status = "disconnected"; + delete data.accessTokenEnc; + delete data.refreshTokenEnc; + delete data.refreshLeaseUntil; + }); + try { + await assert.rejects(getAccessToken(), /changed during token refresh/); + const data = doc(); + assert.strictEqual(data.status, "disconnected"); + assert.strictEqual(data.accessTokenEnc, undefined); + assert.strictEqual(data.refreshTokenEnc, undefined); + } finally { + restore(); + } + }); + + it("cannot overwrite a reconnect to a different realm mid-refresh", async () => { + seedConnected(); + const restore = stubFetch(() => { + const data = doc(); + data.realmId = "realm-2"; + data.refreshTokenEnc = enc("rt-new"); + delete data.refreshLeaseUntil; + }); + try { + await assert.rejects(getAccessToken(), /changed during token refresh/); + const data = doc(); + assert.strictEqual(data.realmId, "realm-2"); + assert.strictEqual(dec(data.refreshTokenEnc), "rt-new"); + } finally { + restore(); + } + }); + + it("cannot commit while a successor holds a different lease", async () => { + seedConnected(); + // Another caller re-acquired the refresh lease after ours expired. + const successorLease = Timestamp.fromMillis(Date.now() + 90_000); + const restore = stubFetch(() => { + doc().refreshLeaseUntil = successorLease; + }); + try { + await assert.rejects(getAccessToken(), /changed during token refresh/); + const data = doc(); + // The stale refresh's rotated tokens were dropped — the stored + // grant and the successor's lease are untouched. + assert.strictEqual(dec(data.refreshTokenEnc), "rt-0"); + assert.strictEqual( + (data.refreshLeaseUntil as Timestamp).toMillis(), + successorLease.toMillis() + ); + } finally { + restore(); + } + }); + + it("leaves a successor's lease alone when the provider refresh fails", async () => { + seedConnected(); + const successorLease = Timestamp.fromMillis(Date.now() + 90_000); + const restore = stubFetch( + () => { + doc().refreshLeaseUntil = successorLease; + }, + () => new Response("upstream error", { status: 502 }) + ); + try { + await assert.rejects(getAccessToken()); + const data = doc(); + assert.strictEqual(data.status, "connected"); + assert.strictEqual( + (data.refreshLeaseUntil as Timestamp).toMillis(), + successorLease.toMillis() + ); + } finally { + restore(); + } + }); + + it("marks the record reauthorization_required when Intuit revokes the grant", async () => { + seedConnected(); + const restore = stubFetch( + undefined, + () => + new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + ); + try { + await assert.rejects(getAccessToken(), /no longer valid/); + const data = doc(); + assert.strictEqual(data.status, "reauthorization_required"); + assert.strictEqual(data.refreshLeaseUntil, undefined); + // The revoked grant stays on record — nothing else was overwritten. + assert.strictEqual(dec(data.refreshTokenEnc), "rt-0"); + } finally { + restore(); + } + }); +}); + +describe("requestQboTokens intuit_tid", () => { + // The token endpoint returns `intuit_tid` on success and failure alike; + // both must reach the caller so refreshes and errors carry the provider + // correlation id into server-side logs. + const REQUEST = { + url: "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "grant_type=refresh_token", + }; + + it("returns the transaction id on a successful token call", async () => { + const restore = stubFetch(undefined, () => { + const res = tokenResponse(); + res.headers.set("intuit_tid", "tid-ok"); + return res; + }); + try { + const { requestQboTokens } = await import("@/lib/qbo-tokens"); + const { tokens, correlationId } = await requestQboTokens( + REQUEST, + "token refresh" + ); + assert.strictEqual(tokens.accessToken, "at-1"); + assert.strictEqual(correlationId, "tid-ok"); + } finally { + restore(); + } + }); + + it("keeps the transaction id on a failed token call", async () => { + const restore = stubFetch( + undefined, + () => + new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { + "content-type": "application/json", + intuit_tid: "tid-fail", + }, + }) + ); + try { + const { requestQboTokens } = await import("@/lib/qbo-tokens"); + await assert.rejects( + requestQboTokens(REQUEST, "token refresh"), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.strictEqual( + (error as { correlationId?: string }).correlationId, + "tid-fail" + ); + return true; + } + ); + } finally { + restore(); + } + }); +}); diff --git a/tests/lib/qbo-webhook.test.ts b/tests/lib/qbo-webhook.test.ts new file mode 100644 index 0000000..a87b514 --- /dev/null +++ b/tests/lib/qbo-webhook.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import type { QboEntityNotification } from "@/lib/qbo-protocol"; +import { installQboDbMock, withEnv } from "./qbo-test-helpers"; + +const firestore = installQboDbMock(); +const ENV = { QBO_ENVIRONMENT: "sandbox" }; +const CONNECTION = "qboConnections/sandbox"; +const RECEIPTS = "qboWebhookReceipts"; + +const notification: QboEntityNotification = { + realmId: "realm-1", + entityName: "Customer", + entityId: "42", + operation: "Create", + lastUpdated: "2026-01-05T12:00:00Z", +}; + +function connectionDoc(status: string) { + return { environment: "sandbox", status, realmId: "realm-1" }; +} + +function receipts() { + return [...firestore.docs.entries()] + .filter(([path]) => path.startsWith(`${RECEIPTS}/`)) + .map(([, data]) => data); +} + +async function record( + notifications: QboEntityNotification[] = [notification] +) { + const { recordQboWebhookNotifications } = await import("@/lib/qbo-webhook"); + return withEnv(ENV, () => + recordQboWebhookNotifications(notifications, "req-1") + ); +} + +describe("recordQboWebhookNotifications realm recognition", () => { + it("marks a receipt realmKnown when it matches the connected realm", async () => { + firestore.reset({ [CONNECTION]: connectionDoc("connected") }); + const outcome = await record(); + + assert.deepStrictEqual(outcome, { + received: 1, + recorded: 1, + duplicates: 0, + ignoredRealms: 0, + }); + assert.strictEqual(receipts()[0]?.realmKnown, true); + }); + + it("does not treat a disconnected record's stored realm as known", async () => { + firestore.reset({ [CONNECTION]: connectionDoc("disconnected") }); + const outcome = await record(); + + assert.deepStrictEqual(outcome, { + received: 1, + recorded: 1, + duplicates: 0, + ignoredRealms: 1, + }); + assert.strictEqual(receipts()[0]?.realmKnown, false); + }); + + it("does not treat a reauthorization_required record's realm as known", async () => { + firestore.reset({ + [CONNECTION]: connectionDoc("reauthorization_required"), + }); + const outcome = await record(); + + assert.strictEqual(outcome.ignoredRealms, 1); + assert.strictEqual(receipts()[0]?.realmKnown, false); + }); + + it("flags notifications for a different realm as ignored", async () => { + firestore.reset({ [CONNECTION]: connectionDoc("connected") }); + const outcome = await record([ + { ...notification, realmId: "realm-other" }, + ]); + + assert.strictEqual(outcome.ignoredRealms, 1); + assert.strictEqual(receipts()[0]?.realmKnown, false); + }); + + it("propagates a realm lookup failure instead of stamping realmKnown:false", async () => { + firestore.reset({ [CONNECTION]: connectionDoc("connected") }); + firestore.failGet.add("qboConnections/sandbox"); + try { + await assert.rejects(record()); + // No receipt may be written — a swallowed failure would otherwise + // dedupe the delivery forever with realmKnown:false. + assert.strictEqual(receipts().length, 0); + } finally { + firestore.failGet.clear(); + } + }); +}); + +describe("recordQboWebhookNotifications dedupe", () => { + it("deduplicates a replayed delivery", async () => { + firestore.reset({ [CONNECTION]: connectionDoc("connected") }); + + const first = await record(); + assert.strictEqual(first.recorded, 1); + assert.strictEqual(first.duplicates, 0); + + const second = await record(); + assert.deepStrictEqual(second, { + received: 1, + recorded: 0, + duplicates: 1, + ignoredRealms: 0, + }); + assert.strictEqual(receipts().length, 1); + }); +}); diff --git a/tests/lib/qbo.test.ts b/tests/lib/qbo.test.ts new file mode 100644 index 0000000..d151282 --- /dev/null +++ b/tests/lib/qbo.test.ts @@ -0,0 +1,153 @@ +// Unit tests for the QBO connection orchestration in lib/qbo.ts. The +// OAuth callback's realmId is the authoritative connected-company +// identity: completeQboAuthorization must persist exactly that value even +// when the CompanyInfo probe reports a different entity Id (which real +// sandbox responses do). The fake Firestore and a URL-keyed fetch stub +// stand in for Firestore and Intuit. +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + decryptQboSecret, + parseQboEncryptionKey, +} from "@/lib/qbo-crypto"; +import { installQboDbMock, withEnv } from "./qbo-test-helpers"; + +const firestore = installQboDbMock(); +const CONNECTION = "qboConnections/sandbox"; +const ENV = { + QBO_ENVIRONMENT: "sandbox", + QBO_CLIENT_ID: "client-id", + QBO_CLIENT_SECRET: "client-secret", + QBO_REDIRECT_URI: "https://app.example.com/api/admin/quickbooks/callback", + // A zeroed AES-256 test key — never a real secret. + QBO_TOKEN_ENCRYPTION_KEY: "0".repeat(64), +}; + +// The realm Intuit delivered on the OAuth callback — the value that must +// end up on the connection record. +const CALLBACK_REALM = "9341454755"; + +function doc() { + return firestore.docs.get(CONNECTION); +} + +// Drives the two Intuit calls inside completeQboAuthorization: the token +// exchange and the scoped CompanyInfo read. +function stubIntuit(companyInfo: unknown) { + const original = globalThis.fetch; + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("oauth2/v1/tokens/bearer")) { + return Promise.resolve( + new Response( + JSON.stringify({ + access_token: "at-1", + refresh_token: "rt-1", + expires_in: 3600, + }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + } + if (url.includes("/companyinfo/")) { + return Promise.resolve( + new Response(JSON.stringify(companyInfo), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + } + return Promise.resolve(new Response("not found", { status: 404 })); + }) as typeof fetch; + return () => { + globalThis.fetch = original; + }; +} + +async function complete(realmId = CALLBACK_REALM) { + const { completeQboAuthorization } = await import("@/lib/qbo"); + return withEnv(ENV, () => + completeQboAuthorization({ + code: "authcode-1", + realmId, + uid: "uid-1", + email: "admin@example.com", + }) + ); +} + +const dec = (value: unknown) => + decryptQboSecret(String(value), parseQboEncryptionKey(ENV.QBO_TOKEN_ENCRYPTION_KEY)); + +describe("completeQboAuthorization", () => { + it("persists the OAuth callback realmId, not the CompanyInfo Id", async () => { + firestore.reset(); + const restore = stubIntuit({ + CompanyInfo: { + // Sandbox CompanyInfo reports an entity Id unrelated to the realm. + Id: "1", + CompanyName: "Sandbox Company_US_a784", + Country: "US", + Email: { Address: "internal@example.com" }, + }, + }); + try { + const result = await complete(); + assert.strictEqual(result.companyName, "Sandbox Company_US_a784"); + const data = doc(); + assert.ok(data, "connection doc missing"); + assert.strictEqual(data.realmId, CALLBACK_REALM); + assert.strictEqual(data.companyName, "Sandbox Company_US_a784"); + assert.strictEqual(data.companyCountry, "US"); + assert.strictEqual(data.status, "connected"); + assert.strictEqual(data.environment, "sandbox"); + // Tokens are persisted encrypted only. + assert.strictEqual(dec(data.accessTokenEnc), "at-1"); + assert.strictEqual(dec(data.refreshTokenEnc), "rt-1"); + } finally { + restore(); + } + }); + + it("stores the same realm when CompanyInfo.Id happens to match", async () => { + firestore.reset(); + const restore = stubIntuit({ + CompanyInfo: { + Id: CALLBACK_REALM, + CompanyName: "Matching Realm Co", + }, + }); + try { + await complete(); + const data = doc(); + assert.strictEqual(data?.realmId, CALLBACK_REALM); + assert.strictEqual(data?.companyName, "Matching Realm Co"); + } finally { + restore(); + } + }); + + it("does not let CompanyInfo metadata re-assign the realm for a different callback realmId", async () => { + firestore.reset(); + const restore = stubIntuit({ + CompanyInfo: { Id: "1", CompanyName: "Other Co" }, + }); + try { + await complete("realm-xyz"); + assert.strictEqual(doc()?.realmId, "realm-xyz"); + } finally { + restore(); + } + }); + + it("persists nothing when the CompanyInfo payload is malformed", async () => { + firestore.reset(); + const restore = stubIntuit({ CompanyInfo: { Id: "1" } }); + try { + await assert.rejects(complete(), /malformed company response/); + assert.strictEqual(doc(), undefined); + } finally { + restore(); + } + }); +}); diff --git a/tests/security-rules.test.ts b/tests/security-rules.test.ts index 6b4605d..272e88c 100644 --- a/tests/security-rules.test.ts +++ b/tests/security-rules.test.ts @@ -75,6 +75,26 @@ describe("firestore.rules", () => { ); }); + it("keeps the QuickBooks credential collections fully server-side (Issue #161)", () => { + // Each QBO collection must carry its own deny-all — scoped assertions so + // the catch-all deny can't mask a missing explicit block. + for (const collection of [ + "qboConnections", + "qboOauthStates", + "qboWebhookReceipts", + "qboConfig", + "qboSyncRecords", + ]) { + const start = rules.indexOf(`match /${collection}/{docId}`); + assert.ok(start > -1, `Expected the ${collection} deny-all match`); + const section = rules.slice(start, start + 200); + assert.ok( + section.includes("allow read, write: if false"), + `Expected ${collection} to deny all client access` + ); + } + }); + it("keeps tradeLeads and its subcollections fully server-side (Issues #150, #152)", () => { // Scope assertions to the tradeLeads block itself (everything until the // next sibling match) so a deny elsewhere in the file can't mask a