diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e34b432e..ccdba289 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,27 @@ name: CI on: pull_request jobs: + typecheck: + name: Typecheck tests + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.14.0 + cache: yarn + cache-dependency-path: './yarn.lock' + + - name: Install dependencies + run: yarn install + + - name: Typecheck test files + run: yarn typecheck:test + run-tests: name: Run tests runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..60f757d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,157 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this app is + +A multi-tenant Next.js (App Router) service that synchronizes Copilot / Assembly workspaces with QuickBooks Online (QBO). It runs on Vercel, persists state in Postgres (Supabase in prod, Drizzle ORM throughout), and reacts to Copilot webhooks (`invoice.created/updated/paid/voided/deleted`, `product.updated`, `price.created`, `payment.succeeded`) by mirroring those entities into the corresponding QBO realm. + +A "portal" is one Copilot/Assembly workspace bonded to one QuickBooks realm. Almost every table is keyed by `portalId`; almost every service derives `this.user.workspaceId` from the request token and scopes everything to that portal. + +## Common commands + +Package manager is **Yarn 4 (Berry)**, Node **22.14.0** (`.nvmrc`). + +```bash +yarn install # install +yarn dev # Next dev (Turbopack) +yarn build # next build (CI uses build.sh which also runs drizzle-kit migrate) +yarn lint:check # ESLint over src/ and test/ +yarn prettier:check # Prettier check +yarn lint:fix # ESLint --fix +yarn prettier:fix # Prettier write + +# Tests (Vitest, two projects defined in vitest.config.ts) +yarn test # both: unit then integration (groupOrder enforces this) +yarn test:watch # watch +yarn test:coverage # v8 coverage +npx vitest run --project unit # only unit +npx vitest run --project integration # only integration +npx vitest run test/integration/quickbooks/priceCreated/happyPath.test.ts # single file +npx vitest run -t 'happy path' # by test-name pattern + +# Trigger.dev (background task runtime) +yarn trigger:dev # local dev worker +yarn trigger:deploy # deploy tasks + +# DB migrations (Drizzle Kit, schema lives at src/db/schema/) +npx drizzle-kit generate # create new migration from schema changes +npx drizzle-kit migrate # apply pending migrations to DATABASE_URL + +# One-off operational scripts (tsx, see src/cmd/*) +yarn cmd:rename-qb-accounts +yarn cmd:backfill-product-info +yarn cmd:sync-missed-invoices +yarn cmd:sync-missed-products +``` + +Husky `pre-commit` runs `lint-staged` (eslint --fix + prettier --write on `src/**/*.{ts,tsx}`). CI (`.github/workflows/test.yml`) runs `yarn test` on PRs; `.github/workflows/lint.yml` runs lint+prettier on every push. CI assumes the testcontainers Postgres image is available (Docker is preinstalled on `ubuntu-latest`). + +## Architecture + +### Request → handler shape + +Every API route follows the same skeleton: + +``` +src/app/api/// + route.ts # exports { POST/GET } = withErrorHandler(controllerFn); sets maxDuration + .controller.ts # auth + Sentry scope + parse + delegate to service + .service.ts # extends BaseService; orchestrates DB + external APIs +``` + +Controllers call `authenticate(req)` (`src/app/api/core/utils/authenticate.ts`), which reads `?token=…`, asks Copilot to decrypt it, and returns a `User` (`src/app/api/core/models/User.model.ts`). `User` carries `workspaceId` (= portalId), role, and the lazily-attached `qbConnection` (service-item / client-fee refs). + +`withErrorHandler` (`src/app/api/core/utils/withErrorHandler.ts`) is the **only** error path. It maps `ZodError` / `APIError` / `CopilotApiError` / `RetryableError` / Intuit OAuth + Axios errors to HTTP responses and forwards categorized exceptions to Sentry. Don't add try/catch in route handlers — throw and let this wrapper format. + +### BaseService and the DB singleton + +Services extend `BaseService` (`src/app/api/core/services/base.service.ts`), which holds: + +- `this.db` — the **module-level Drizzle singleton** from `src/db/index.ts` (`DBClient.getInstance()`); `casing: 'snake_case'`. +- `this.user` — the authenticated `User` for the request. +- `setTransaction(tx)` / `unsetTransaction()` — swap `this.db` for a transaction handle inside a `db.transaction(...)` callback, then restore. + +**Pitfall (known, see `memory/project_unsetTransaction_bug.md`):** `unsetTransaction()` is sometimes called inside the transaction callback or skipped on error paths — across `BaseService` subclasses this leaves the singleton pointed at a closed tx. When introducing or modifying transactional code, audit that `setTransaction` / `unsetTransaction` are paired in `try/finally` and that nested service calls share the tx handle. + +The DB singleton is also why test helpers (`test/helpers/seed.ts`, `test/helpers/testDb.ts`) import `@/db` directly — see `docs/why-test-helpers-use-the-app-db-singleton.md`. Don't introduce a separate test-only Drizzle client; tests must read what the app writes. + +### Webhook flow (the central path) + +`POST /api/quickbooks/webhook` → `WebhookService.handleWebhookEvent` (`src/app/api/quickbooks/webhook/webhook.service.ts`) is a switch on `payload.eventType` that dispatches to `InvoiceService` / `ProductService` / `PaymentService`. A few things to know before changing it: + +1. **Idempotency is enforced via `qb_sync_logs` claim rows.** `SyncLogService.claimWebhookEvent({ copilotId, entityType, eventType, … })` returns `{ claimed: false }` if a row already exists; handlers exit early. Any new webhook handler must call `claimWebhookEvent` before doing real work or duplicate processing will leak into QBO. +2. **`qb_sync_logs.quickbooks_id` is polymorphic.** Its meaning depends on `(entityType, eventType)` — for `INVOICE/PAID` it stores the QBO **Payment** ID, not the Invoice ID. See `memory/project_qb_sync_logs_semantics.md`. +3. **Pre-claim sleeps for ordering.** `INVOICE_UPDATED` / `INVOICE_VOIDED` / `PAYMENT_SUCCEEDED` sleep before `claimWebhookEvent` so a companion event (e.g., `INVOICE_CREATED`) can claim first. The `delayMs` lives in the handler, not the caller — keep it that way; moving the sleep after the claim re-opens the race. +4. **Setting flags gate handlers.** `PRICE_CREATED` / `PRODUCT_UPDATED` no-op when `createNewProductFlag` is false; `PAYMENT_SUCCEEDED` no-ops when `absorbedFeeFlag` is false or there's no platform-paid fee. Read `qb_settings` via `SettingService` rather than passing flags around. +5. **There's a known TOCTOU race on `claimWebhookEvent`** — accepted, parked, will be addressed with an advisory lock + dedupe job, not a rewrite. See `memory/project_qb_sync_logs_toctou_parked.md`. + +### Token refresh + +QBO access tokens expire in ~1h, refresh tokens in ~100 days. `src/utils/intuitAPI.ts` sends authenticated requests; `src/utils/tokenRefresh.ts` (`getValidQbTokens`) refreshes when stale. The `vercel.json` cron `/api/quickbooks/refresh-tokens` runs daily at 06:00 UTC to keep refresh tokens warm. There's a known silent-401 bug — expired tokens cause `null` returns from `getFetchWithHeader/postFetchWithHeaders`; the planned fix is auto-refresh inside those helpers (design at `docs/intuit-api-token-refresh.md`, summary in `memory/project_intuit_api_token_refresh.md`). + +### Background work + +- **Vercel crons** (`vercel.json`): + - `/api/quickbooks/cron` every 12h — kicks off `processResyncForFailedRecords` (Trigger.dev task) to retry failed sync logs. Auth via `Bearer ${CRON_SECRET}`. + - `/api/quickbooks/refresh-tokens` daily 06:00 UTC. +- **Trigger.dev** tasks live in `src/trigger/` (config at `trigger.config.ts`, runtime: node, default 3 retries, `maxDuration: 3600s`). Sentry source maps are uploaded only when `VERCEL_ENV === 'production'`. + +## Multi-tenancy invariant + +Every `WHERE` clause that touches a portal-scoped table needs `portalId = this.user.workspaceId`. Forgetting this leaks one tenant's data into another. The unique indexes on `qb_sync_logs` and `qb_invoice_sync` (see migrations 20260427100328 / 20260427055352) enforce some of this at the DB level, but most of it is service-layer discipline. + +## Database & schema + +- Drizzle schemas in `src/db/schema/*` registered in `src/db/schema/index.ts`. Relations in `relation.ts`. +- Migrations in `src/db/migrations/` (prefix `supabase`, generated by drizzle-kit). The `init.sql` (20250701) defines all enums; subsequent files alter. +- Custom column helpers in `src/db/helper/column.helper.ts` (`timestamps`) and enum bridge in `drizzle.helper.ts` (`enumToPgEnum`). +- `qb_payments` table exists but is currently unused (reserved for future) — no rows in prod. See `memory/project_qb_payments_unused.md`. +- Type-safe Zod schemas come from `drizzle-zod` (`createInsertSchema` / `createSelectSchema`); reuse those rather than hand-rolling Zod for DB rows. + +## Testing + +- Two Vitest **projects** in `vitest.config.ts` — `unit` (mock-heavy, isolated) and `integration` (real Postgres via testcontainers). Run order is enforced via `sequence.groupOrder` (unit=0, integration=1). +- Integration project is configured **`pool: 'forks'` + `fileParallelism: false` + `isolate: false`** so all integration tests share one Postgres container _and_ one app DB connection. Don't change these without reading `docs/vitest-gotchas.md` and `docs/why-test-helpers-use-the-app-db-singleton.md`. +- `.env.test` is loaded by `test/integration/globalSetup.ts` with `override: true` so a developer's local `.env` can't leak into tests. `DATABASE_URL` is intentionally **not** in `.env.test` — globalSetup sets it from the container's URI before any worker imports `src/config`. +- Module mocks for integration are in `test/integration/setup.ts` — `@/utils/copilotAPI`, `@/utils/intuitAPI`, and `@sentry/nextjs` must be mocked with **explicit factories** (and Intuit/Copilot mock implementations must use `function`, not `=>`, because the code does `new IntuitAPI(...)`). See `docs/vitest-gotchas.md` items 1–3. +- Test helpers in `test/helpers/`: `seed.ts` (`seedHealthyPortal`, `TEST_PORTAL_ID`, etc.), `webhook.ts` (`postWebhook` via `next-test-api-route-handler`), `testDb.ts` (`truncateAllTestTables`). +- Test-data philosophy in `docs/test-data-dos-and-donts.md`: static fixtures for the thing under test, factories with explicit overrides for single-dimension variants, **no faker** in fixtures or assertions. + +## Path aliases + +``` +@/* → src/* +@test/* → test/* +``` + +Configured in `tsconfig.json` and propagated to Vitest via `vite-tsconfig-paths` (per-project in `vitest.config.ts`). + +## Style notes + +- Prettier: single quotes, no semis, trailing comma all (`.prettierrc`). +- ESLint: `next/core-web-vitals` + TypeScript; `prefer-const` and `no-var` are errors; unused-var underscore prefix is exempt; `@typescript-eslint/no-explicit-any` is disabled (the codebase uses `any` deliberately at framework boundaries). +- Tailwind v4 + `copilot-design-system`. UI surface is small (settings dashboard + OAuth callback) — most work happens in the API/service layer. +- The `docs/` folder is **gitignored** (per `.gitignore`) and used for local decision notes — design docs, post-mortems, comparison tables. Save non-trivial tradeoff discussions there rather than in code comments or commit messages. + +## Things to read before non-trivial changes + +- `docs/testcontainers-vs-local-supabase.md` — why integration tests use testcontainers, not the local Supabase stack. +- `docs/why-test-helpers-use-the-app-db-singleton.md` — why test helpers import `@/db` and what would break if you opened a separate client. +- `docs/vitest-gotchas.md` — the five real traps already hit in this project. +- `docs/test-data-dos-and-donts.md` — the test-data rules. +- `docs/intuit-api-token-refresh.md` — design for the silent-401 fix. + +## What this repo doesn't have + +- No design system / shared component library — UI is a thin dashboard, mostly settings forms. +- No GraphQL, no tRPC — plain Next.js Route Handlers + service classes. +- No DI container — `BaseService` reads `db` from a module singleton; tests work _with_ that constraint, not around it. +- No existing CLAUDE.md until this one. + +## Engineering notes + +- After a successful implementation, the changes will be reviewed by the team lead and greptileAI in github. +- Do not use let unless absolutely necessary. Use const instead. +- Always keep the comments short, on point and easy to understand with easy wordings. This is must. +- Follow DRY, KISS, SOLID, YAGNI principles. diff --git a/package.json b/package.json index 5cd9f720..3f73d9a1 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "cmd:sync-missed-products": "tsx src/cmd/syncMissedProducts/index.ts", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "typecheck:test": "tsc --noEmit -p test/tsconfig.json" }, "dependencies": { "@sentry/nextjs": "^9.13.0", diff --git a/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts b/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts index 44d8010c..4890c3f7 100644 --- a/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts +++ b/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts @@ -44,7 +44,7 @@ describe('POST /api/quickbooks/webhook — invoice.created (lazy item creation f .where( eq( QBProductSync.productId, - invoiceCreatedPayload.data.lineItems[0].productId, + invoiceCreatedPayload.data.lineItems[0].productId!, ), ) expect(rows).toHaveLength(1) diff --git a/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts b/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts index 005b15a4..b3e96c9d 100644 --- a/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts +++ b/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts @@ -21,7 +21,6 @@ describe('POST /api/quickbooks/webhook — invoice.paid routes off the frozen in await seedQBInvoiceSync({ customerId: customer.id, isBatchedDeposit: true, - status: 'open', }) await seedInvoiceCreatedLog() @@ -41,7 +40,6 @@ describe('POST /api/quickbooks/webhook — invoice.paid routes off the frozen in await seedQBInvoiceSync({ customerId: customer.id, isBatchedDeposit: false, - status: 'open', }) await seedInvoiceCreatedLog() diff --git a/test/tsconfig.json b/test/tsconfig.json index 54a7c47b..ee4fcdf1 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../tsconfig.json", - "include": ["**/*.ts"], + // intuit-oauth ambient shim the root config loads; needed for src/config. + "include": ["../src/type/intuit.d.ts", "**/*.ts"], "exclude": ["node_modules"] } diff --git a/test/unit/utils/intuitAPI.accounts.test.ts b/test/unit/utils/intuitAPI.accounts.test.ts index de3ee2c9..09a244c6 100644 --- a/test/unit/utils/intuitAPI.accounts.test.ts +++ b/test/unit/utils/intuitAPI.accounts.test.ts @@ -32,6 +32,7 @@ const baseTokens: IntuitAPITokensType = { assetAccountRef: 'asset', serviceItemRef: 'service', clientFeeRef: 'client-fee', + bankAccountRef: 'bank', } type Row = { diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts index a07a90d3..0c64e1fe 100644 --- a/test/unit/utils/intuitAPI.responses.test.ts +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -35,6 +35,7 @@ const baseTokens: IntuitAPITokensType = { assetAccountRef: 'asset', serviceItemRef: 'service', clientFeeRef: 'client-fee', + bankAccountRef: 'bank', } function makeApi() { @@ -361,7 +362,6 @@ describe('IntuitAPI POST-based writes', () => { const api = makeApi() const result = await api.createItem({ Name: 'Widget', - UnitPrice: 25, Type: 'Service' as never, Taxable: false, }) diff --git a/test/unit/utils/intuitAPI.test.ts b/test/unit/utils/intuitAPI.test.ts index 956bd58a..fa57466a 100644 --- a/test/unit/utils/intuitAPI.test.ts +++ b/test/unit/utils/intuitAPI.test.ts @@ -52,6 +52,7 @@ const baseTokens: IntuitAPITokensType = { assetAccountRef: 'asset', serviceItemRef: 'service', clientFeeRef: 'client-fee', + bankAccountRef: 'bank', } // Builds a customer row in the shape QBO returns inside `QueryResponse.Customer`.