diff --git a/.env.example b/.env.example index 1718660c..0cc84545 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ VERCEL_URL=localhost:3000 VERCEL_ENV=development CRON_SECRET=vercel cron secret +# Comma-separated portalIds the bank deposit feature is limited to. Empty/unset = all portals. +AB_FEATURE_TESTING_PORTALS= + SENTRY_ORG= SENTRY_PROJECT= NEXT_PUBLIC_SENTRY_DSN= 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/.gitignore b/.gitignore index 871187c7..3275a7ff 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,4 @@ next-env.d.ts .trigger # local decision notes (not published) -/docs /supabase/snippets \ No newline at end of file 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/docs/stripe reconciliation/batched-deposit-fee-edge-case.md b/docs/stripe reconciliation/batched-deposit-fee-edge-case.md new file mode 100644 index 00000000..0a66f202 --- /dev/null +++ b/docs/stripe reconciliation/batched-deposit-fee-edge-case.md @@ -0,0 +1,60 @@ +# Batched-deposit fee edge case (OUT-4009) + +## The setup + +Two settings control how Stripe fees land in QuickBooks: + +- **`absorbedFeeFlag`** — book the Stripe fee as an expense. +- **`bankDepositFeeFlag`** (the "batched-deposit" flag) — decides **who** books that fee: + - **OFF** → fee booked immediately at `payment.succeeded` as an individual QBO **Purchase**. + - **ON** → fee is deferred; the payment parks in **Undeposited Funds (UF)**, and one QBO **Bank Deposit** per Stripe payout books the fee later. + +> **UF (Undeposited Funds)** is a QuickBooks holding account — a "waiting room". Payments sit there until a Bank Deposit sweeps them into the real bank account. That deposit is what matches the bank feed 1:1. + +## The bug + +The flag is read **live** at two different moments — once at `payment.succeeded`, once at `payout.reconciliation`. If the user toggles it in between, the two disagree: + +- **OFF → ON:** fee booked at payment time **and** again in the deposit → **fee booked twice.** +- **ON → OFF:** payment parked in UF, but the payout handler returns early → **fee never booked + payment stranded in UF.** + +```mermaid +sequenceDiagram + participant U as User (settings) + participant P as payment.succeeded + participant PO as payout.reconciliation + + Note over P,PO: OFF→ON ⇒ double fee + P->>P: flag OFF → book individual fee Purchase + U->>U: toggle ON + PO->>PO: flag ON → deposit ALSO books the fee + Note over PO: ❌ same fee booked twice + + Note over P,PO: ON→OFF ⇒ missed fee + P->>P: flag ON → defer fee, payment → UF + U->>U: toggle OFF + PO->>PO: flag OFF → early return, no deposit + Note over PO: ❌ fee never booked + payment stranded in UF +``` + +## The fix + +**Freeze the decision per invoice.** When the payment routing is decided, store `isBatchedDeposit` on the invoice-sync row. Both handlers read that frozen value instead of the live flag. + +At payout time the decision is **all-or-nothing** by frozen intent: + +```mermaid +flowchart TD + A["payout.reconciliation"] --> B["Read frozen isBatchedDepositfor every line item"] + B --> C{All batched?} + C -->|Yes| D["✅ Create one depositfees folded in → 1:1 bank match"] + C -->|No| E{All non-batched?} + E -->|Yes| F["✅ No depositfees already booked at payment"] + E -->|"No — mixed"| G["⚠️ Skip depositlog FAILED + notify → manual reconciliation"] +``` + +## Why "mixed" can't be auto-handled + +A single Stripe payout can straddle a toggle, mixing batched and non-batched invoices. There's no way to render that as one balanced deposit without either double-booking a fee, breaking the 1:1 bank match, or destructively deleting already-booked Purchases (and there is **no `deleteDeposit`** to undo mistakes). So a mixed payout is quarantined for manual reconciliation instead of guessed. + +A settings dialog warns users that toggling mid-cycle can leave one payout needing manual reconciliation — but that's UX only; correctness comes from the frozen intent above. diff --git a/package.json b/package.json index 158fcb24..3f73d9a1 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint-staged": "npx lint-staged", "prepare": "husky", "supabase:dev": "supabase start --ignore-health-check", + "db:migrate": "tsx src/db/migrate.ts", "cmd:rename-qb-accounts": "tsx src/cmd/renameQbAccount/index.ts", "patch-assembly-node-sdk": "cp ./lib-patches/assembly-js-node-sdk.js ./node_modules/@assembly-js/node-sdk/dist/api/init.js", "patch-copilot-node-sdk": "cp ./lib-patches/copilot-node-sdk.js ./node_modules/copilot-node-sdk/dist/api/init.js", @@ -25,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/scripts/build.sh b/scripts/build.sh index 599f2cc9..aac30a4c 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -13,8 +13,8 @@ else echo "[1/3] Skipping copilot-node-sdk patch (production)" fi -echo "[2/3] Running drizzle-kit migrate" -yarn drizzle-kit migrate +echo "[2/3] Running db:migrate" +yarn db:migrate echo "[3/3] Running next build" next build diff --git a/src/app/api/core/types/log.ts b/src/app/api/core/types/log.ts index 4f9a6aa1..b67a6c34 100644 --- a/src/app/api/core/types/log.ts +++ b/src/app/api/core/types/log.ts @@ -2,6 +2,7 @@ export enum EntityType { INVOICE = 'invoice', PRODUCT = 'product', PAYMENT = 'payment', + PAYOUT = 'payout', } export enum LogStatus { @@ -20,6 +21,7 @@ export enum EventType { SUCCEEDED = 'succeeded', MAPPED = 'mapped', UNMAPPED = 'unmapped', + SETTLED = 'settled', } /** diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index 83d6f080..81984753 100644 --- a/src/app/api/core/types/notification.ts +++ b/src/app/api/core/types/notification.ts @@ -10,6 +10,7 @@ export enum NotificationActions { QB_TXN_LINK_FAILED = 'qb_txn_link_failed', QB_ITEM_INCOME_ACCOUNT_MISSING = 'qb_item_income_account_missing', QB_INVALID_ACCOUNT_TYPE = 'qb_invalid_account_type', + QB_PAYOUT_MIXED_INTENT = 'qb_payout_mixed_intent', } /** @@ -24,9 +25,17 @@ export interface NotificationContext { entityType?: string eventType?: string entityKey?: string - invoiceNumber?: string - customerName?: string - productName?: string - qbItemName?: string - errorMessage?: string + // Nullable string fields mirror their nullable qb_sync_logs columns, so + // callers can pass log values directly. Consumers treat null/undefined alike. + invoiceNumber?: string | null + // Comma-joined invoice numbers for a multi-invoice failure (mixed payout), + // where the single invoiceNumber above can't hold them all. + invoiceNumbers?: string | null + // Subset of invoiceNumbers whose absorbed fee is already recorded in QBO, so a + // mixed-payout body can tell IUs which fees not to record a second time. + invoiceNumbersWithFee?: string | null + customerName?: string | null + productName?: string | null + qbItemName?: string | null + errorMessage?: string | null } diff --git a/src/app/api/core/types/webhook.ts b/src/app/api/core/types/webhook.ts index 28c456ab..1030685d 100644 --- a/src/app/api/core/types/webhook.ts +++ b/src/app/api/core/types/webhook.ts @@ -7,4 +7,5 @@ export enum WebhookEvents { INVOICE_VOIDED = 'invoice.voided', INVOICE_UPDATED = 'invoice.updated', PAYMENT_SUCCEEDED = 'payment.succeeded', + PAYOUT_RECONCILIATION_COMPLETED = 'payout.reconciliation_completed', } diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index a1e4d4cc..eb2f34ce 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -65,6 +65,9 @@ const describeAction = (entityType?: string, eventType?: string): string => { if (entityType === 'payment' && eventType === 'succeeded') { return 'invoice fees creation' } + if (entityType === 'payout' && eventType === 'settled') { + return 'payout reconciliation' + } const eventNoun = ( { @@ -236,6 +239,33 @@ export const NotificationCopy: Record< `A sync failed${ref} because a QuickBooks item has no income account assigned. This usually happens when the item was created in QuickBooks without an income account, or the account was removed afterwards. Open Products and Services in QuickBooks, edit the item, and set its income account. The next scheduled retry (within a few hours) will pick it up automatically.`, }, + // A Stripe payout straddled a bank-deposit setting change, so it contains + // both batched and non-batched invoices. We can't book one balanced deposit + // from a mix, so no deposit is created and the payout needs manual handling. + // Terminal — there is no scheduled retry for payouts. + [NotificationActions.QB_PAYOUT_MIXED_INTENT]: { + title: 'QuickBooks sync failed: payout needs manual reconciliation', + body: (ref, ctx) => { + const forInvoices = ctx?.invoiceNumbers + ? ` for invoices ${ctx.invoiceNumbers}` + : '' + const recordedFees = ctx?.invoiceNumbersWithFee + ? ` The Stripe fees for ${ctx.invoiceNumbersWithFee} are already recorded as expenses in QuickBooks, so do not record those fees again.` + : '' + return `A Stripe payout${ref} could not be recorded in QuickBooks because it mixes invoices set to batch into a bank deposit with invoices that are not — this happens when the bank-deposit setting changed between a payment and its payout. No deposit was created${forInvoices}. The payments are already recorded in QuickBooks; they just haven't been grouped into a bank deposit.${recordedFees} Record this payout's deposit manually in QuickBooks. This will not be retried automatically.` + }, + emailSubject: 'QuickBooks sync failed: payout needs manual reconciliation', + emailBody: (ref, ctx) => { + const forInvoices = ctx?.invoiceNumbers + ? ` for invoices ${ctx.invoiceNumbers}` + : '' + const recordedFees = ctx?.invoiceNumbersWithFee + ? ` The Stripe fees for ${ctx.invoiceNumbersWithFee} are already recorded as expenses in QuickBooks, so do not record those fees again.` + : '' + return `A Stripe payout${ref} could not be recorded in QuickBooks because it mixes invoices set to batch into a bank deposit with invoices that are not. This happens when the bank-deposit setting changed between a payment and its payout. No deposit was created${forInvoices}. The payments are already recorded in QuickBooks; they just haven't been grouped into a bank deposit.${recordedFees} Record this payout's deposit manually in QuickBooks. This payout will not be retried automatically.` + }, + }, + [NotificationActions.QB_INVALID_ACCOUNT_TYPE]: { title: 'QuickBooks sync failed: account type is invalid for this transaction', diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index ac555df4..c7055d79 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -27,7 +27,6 @@ import { getValidQbTokens, QBReconnectRequiredError, } from '@/utils/tokenRefresh' -import { after } from 'next/server' export class AuthService extends BaseService { async getAuthUrl( @@ -140,6 +139,7 @@ export class AuthService extends BaseService { assetAccountRef: insertPayload.assetAccountRef, serviceItemRef: existingToken?.serviceItemRef || null, clientFeeRef: existingToken?.clientFeeRef || null, + bankAccountRef: existingToken?.bankAccountRef || null, }) // handle accounts const createPayload = await this.handleAccountReferences( @@ -168,7 +168,7 @@ export class AuthService extends BaseService { connectionStatus: ConnectionStatus.SUCCESS, }) - after(async () => { + afterIfAvailable(async () => { if (existingToken) { console.info('Not initial process. Starting the re-sync process') this.user.qbConnection = { @@ -247,6 +247,7 @@ export class AuthService extends BaseService { assetAccountRef: '', serviceItemRef: '', clientFeeRef: '', + bankAccountRef: null, } // if sync is false but it has been enabled then don't throw error. We have to log in this case diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index f49ac63f..aa4fa517 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -49,6 +49,7 @@ import { InvoiceResponseType, InvoiceVoidedResponse, } from '@/type/dto/webhook.dto' +import { isPortalInBankDepositABTest } from '@/utils/abTesting' import { bottleneck } from '@/utils/bottleneck' import { CopilotAPI } from '@/utils/copilotAPI' import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' @@ -480,6 +481,29 @@ export class InvoiceService extends BaseService { return { value: serviceItemRef } } + // Reads the live batched-deposit setting. Called only at the freeze point + // (row creation); everything else reads the frozen row value. + private async readBankDepositFeeFlag(): Promise { + // AB gate: portals outside the allowlist never freeze as batched. + if (!isPortalInBankDepositABTest(this.user.workspaceId)) return false + const settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId([ + 'bankDepositFeeFlag', + ]) + return setting?.bankDepositFeeFlag ?? false + } + + // Undeposited Funds when the invoice's frozen intent is batched, else + // undefined (QBO default). No live setting read. + private async resolveDepositToAccountRef( + intuitApi: IntuitAPI, + isBatchedDeposit: boolean, + ): Promise { + return isBatchedDeposit + ? await intuitApi.getUndepositedFundsAccountId() + : undefined + } + /** * Pre-flights QBO for invoices whose DocNumber starts with the Assembly * invoice number and returns the lowest free slot (``, `-1`, …). @@ -767,6 +791,7 @@ export class InvoiceService extends BaseService { invoiceRes = await intuitApiService.createInvoice(buildPayload(docNumber)) } + const isBatchedDeposit = await this.readBankDepositFeeFlag() const invoicePayload = { portalId: this.user.workspaceId, invoiceNumber: invoiceResource.number, @@ -776,6 +801,7 @@ export class InvoiceService extends BaseService { recipientId: recipientInfo.recipientId, customerId: existingCustomerMapId, // foreign key to customer mapping status: invoiceResource.status, + isBatchedDeposit, } const inserted = await this.createQBInvoice(invoicePayload, ['id']) @@ -815,11 +841,20 @@ export class InvoiceService extends BaseService { */ if (invoiceResource.status === InvoiceStatus.PAID) { const paymentService = new PaymentService(this.user) + // Same routing as invoice.paid: batched → Undeposited Funds so the + // payout deposit can sweep it later. + const depositToAccountRef = await this.resolveDepositToAccountRef( + intuitApiService, + isBatchedDeposit, + ) const qbPaymentPayload = { TotalAmt: totalWithTax, CustomerRef: { value: customerRefValue, }, + ...(depositToAccountRef && { + DepositToAccountRef: { value: depositToAccountRef }, + }), Line: [ { Amount: totalWithTax, @@ -861,6 +896,7 @@ export class InvoiceService extends BaseService { 'qbInvoiceId', 'status', 'customerId', + 'isBatchedDeposit', ]) if (!invoiceSync) { @@ -914,11 +950,21 @@ export class InvoiceService extends BaseService { ) const invoiceAmount = Number(z.string().parse(invoiceLog.amount)) / 100 + + const intuitApi = new IntuitAPI(qbTokenInfo) + const depositToAccountRef = await this.resolveDepositToAccountRef( + intuitApi, + invoiceSync.isBatchedDeposit, + ) + const qbPaymentPayload = { TotalAmt: invoiceAmount, CustomerRef: { value: existingCustomer.qbCustomerId, }, + ...(depositToAccountRef && { + DepositToAccountRef: { value: depositToAccountRef }, + }), Line: [ { Amount: invoiceAmount, @@ -931,7 +977,6 @@ export class InvoiceService extends BaseService { }, ], } - const intuitApi = new IntuitAPI(qbTokenInfo) const paymentService = new PaymentService(this.user) const customerDisplayName = @@ -1391,6 +1436,7 @@ export class InvoiceService extends BaseService { recipientId: recipientInfo.recipientId, customerId: customerMapId, status, + isBatchedDeposit: await this.readBankDepositFeeFlag(), }, ['id'], ) diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index fd608761..83a32618 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -21,6 +21,8 @@ import { } from '@/db/schema/qbPaymentSync' import { WhereClause } from '@/type/common' import { + QBDepositCreatePayloadSchema, + QBDepositCreatePayloadType, QBPaymentCreatePayloadSchema, QBPaymentCreatePayloadType, QBPurchaseCreatePayloadSchema, @@ -34,6 +36,7 @@ import { addSyncBreadcrumb } from '@/utils/sentry' import dayjs from 'dayjs' import { z } from 'zod' import httpStatus from 'http-status' +import CustomLogger from '@/utils/logger' export class PaymentService extends BaseService { private syncLogService: SyncLogService @@ -195,6 +198,72 @@ export class PaymentService extends BaseService { } } + async createBankDepositForPayment( + intuitApi: IntuitAPI, + opts: { + lines: Array<{ qbPaymentId: string; amount: number }> + feeTotal: number + bankAccountRef: string + expenseAccountRef: string + txnDate: string + privateNote: string + }, + ): Promise { + addSyncBreadcrumb('Creating batched bank deposit in QBO', { + privateNote: opts.privateNote, + lineCount: opts.lines.length, + feeTotal: opts.feeTotal, + }) + + const paymentLines: Required['Line'] = + opts.lines.map((line) => ({ + Amount: line.amount, + LinkedTxn: [ + { + TxnId: line.qbPaymentId, + TxnType: 'Payment' as const, + TxnLineId: '0', + }, + ], + })) + + // feeTotal is always >= 0 (caller rejects negative): 0 = no fee line. + if (opts.feeTotal > 0) { + paymentLines.push({ + Amount: -opts.feeTotal, + DetailType: 'DepositLineDetail' as const, + DepositLineDetail: { + AccountRef: { value: opts.expenseAccountRef }, + }, + Description: 'Stripe processing fees', + }) + } + + const depositPayload: QBDepositCreatePayloadType = { + DepositToAccountRef: { value: opts.bankAccountRef }, + PrivateNote: opts.privateNote, + TxnDate: opts.txnDate, + Line: paymentLines, + } + + const parsedPayload = QBDepositCreatePayloadSchema.parse(depositPayload) + const res = await intuitApi.createDeposit(parsedPayload) + + CustomLogger.info({ + obj: { + depositId: res.Deposit?.Id, + lineCount: opts.lines.length, + feeTotal: opts.feeTotal, + }, + message: `PaymentService#createBankDepositForPayment | Batched bank deposit created (${opts.privateNote})`, + }) + addSyncBreadcrumb('Batched bank deposit created in QBO', { + depositId: res.Deposit?.Id, + }) + + return res.Deposit.Id + } + async webhookPaymentSucceeded({ parsedPaymentSucceedResource, qbTokenInfo, diff --git a/src/app/api/quickbooks/payout/payout.errors.ts b/src/app/api/quickbooks/payout/payout.errors.ts new file mode 100644 index 00000000..eb35c63f --- /dev/null +++ b/src/app/api/quickbooks/payout/payout.errors.ts @@ -0,0 +1,19 @@ +import { getShouldRetryForCategory } from '@/utils/synclog' +import { getMessageAndCodeFromError } from '@/utils/error' + +// Payout problems that retrying will never fix, so we stop trying +// (refund lines, negative fee, duplicate line items, wrong total). +export class TerminalPayoutError extends Error {} + +// A payout that mixes batched and non-batched invoices. Extends +// TerminalPayoutError so it also stops retrying, but stays its own type so +// we can send the special "mixed payout" alert. +export class MixedPayoutIntentError extends TerminalPayoutError {} + +// Terminal payout problems never retry. Everything else (invoice not saved +// yet, missing bank ref, rate-limit, QB 5xx, suspended account) uses the +// shared rule, which still stops on dead tokens (AUTH). +export function getShouldRetryForPayout(error: unknown): boolean { + if (error instanceof TerminalPayoutError) return false + return getShouldRetryForCategory(getMessageAndCodeFromError(error)) +} diff --git a/src/app/api/quickbooks/payout/payout.service.ts b/src/app/api/quickbooks/payout/payout.service.ts new file mode 100644 index 00000000..0cbf46a2 --- /dev/null +++ b/src/app/api/quickbooks/payout/payout.service.ts @@ -0,0 +1,241 @@ +import httpStatus from 'http-status' +import { and, eq, isNull } from 'drizzle-orm' + +import { BaseService } from '@/app/api/core/services/base.service' +import APIError from '@/app/api/core/exceptions/api' +import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' +import { PaymentService } from '@/app/api/quickbooks/payment/payment.service' +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { + MixedPayoutIntentError, + TerminalPayoutError, +} from '@/app/api/quickbooks/payout/payout.errors' +import { + QBPayoutSync, + QBPayoutSyncSelectSchemaType, +} from '@/db/schema/qbPayoutSync' +import { PayoutLineItem } from '@/type/dto/webhook.dto' +import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' +import { AccountTypeObj } from '@/constant/qbConnection' +import { validateAccessToken } from '@/utils/auth' +import User from '@/app/api/core/models/User.model' +import { isPortalInBankDepositABTest } from '@/utils/abTesting' + +export class PayoutService extends BaseService { + private syncLogService: SyncLogService + + constructor(user: User) { + super(user) + this.syncLogService = new SyncLogService(user) + } + + // Same (portalId, payoutId) updates the same row, so a re-sent payout + // never makes a duplicate. + async upsertPayoutSync(input: { + payoutId: string + lineItems: PayoutLineItem[] + netAmount: number + feeCents: number + arrivalDate: number + }): Promise { + const [row] = await this.db + .insert(QBPayoutSync) + .values({ + portalId: this.user.workspaceId, + payoutId: input.payoutId, + lineItems: input.lineItems, + netAmount: input.netAmount, + feeAmount: input.feeCents, + arrivalDate: input.arrivalDate, + }) + .onConflictDoUpdate({ + target: [QBPayoutSync.portalId, QBPayoutSync.payoutId], + // Must match the partial unique index (only rows where deleted_at is + // null). In drizzle-orm 0.42 that goes in `targetWhere`, not `where`. + targetWhere: isNull(QBPayoutSync.deletedAt), + set: { + lineItems: input.lineItems, + netAmount: input.netAmount, + feeAmount: input.feeCents, + arrivalDate: input.arrivalDate, + }, + }) + .returning() + return row + } + + async getPayoutSync( + payoutId: string, + ): Promise { + const row = await this.db.query.QBPayoutSync.findFirst({ + where: and( + eq(QBPayoutSync.portalId, this.user.workspaceId), + eq(QBPayoutSync.payoutId, payoutId), + isNull(QBPayoutSync.deletedAt), + ), + }) + return row ?? null + } + + // Checks the payout, finds its payments, then builds and creates the deposit. + // Neither caller claims again. Returns { depositId: null } when there is + // nothing to deposit. + async reconcile( + row: QBPayoutSyncSelectSchemaType, + qbTokenInfo: IntuitAPITokensType, + opts: { runIdempotencyCheck: boolean }, + ): Promise<{ depositId: string | null }> { + // AB gate: covers both callers (payout webhook + resync cron). Only fires + // for an explicitly excluded portal (empty allowlist = all portals). Logged + // rather than silent so a rare mid-flight exclusion is visible in Sentry. + if (!isPortalInBankDepositABTest(this.user.workspaceId)) { + console.info( + `PayoutService#reconcile | AB gate off for portal ${this.user.workspaceId}; skipping deposit for payout ${row.payoutId}`, + ) + return { depositId: null } + } + validateAccessToken(qbTokenInfo) + + const payoutId = row.payoutId + // One source for the note, used to both find and create the deposit, + // so the two can never drift apart. + const privateNote = `Stripe payout ${payoutId}` + const lineItems = row.lineItems + const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) + const grossCents = lineItems.reduce( + (sum, line) => sum + line.grossAmount, + 0, + ) + const feeCents = lineItems.reduce((sum, line) => sum + line.feeAmount, 0) + const netAmount = row.netAmount + + // These problems never fix themselves on retry, so fail for good. + if (lineItems.some((line) => line.grossAmount < 0)) { + throw new TerminalPayoutError( + `Payout ${payoutId} contains refund lines; batched deposit unsupported in v1`, + ) + } + if (feeCents < 0) { + throw new TerminalPayoutError( + `Payout ${payoutId} has a negative aggregate fee (${feeCents}); unsupported in v1`, + ) + } + if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) { + throw new TerminalPayoutError( + `Payout ${payoutId} contains duplicate invoice line items`, + ) + } + + // On resync only: reuse a deposit we already made, or find one already in QBO. + if (opts.runIdempotencyCheck) { + if (row.qbDepositId) return { depositId: row.qbDepositId } + const intuitApi = new IntuitAPI(qbTokenInfo) + const txnDate = new Date(row.arrivalDate * 1000) + .toISOString() + .split('T')[0] + const existing = await intuitApi.getDepositsByTxnDate(txnDate) + const match = existing.find( + (deposit) => deposit.PrivateNote === privateNote, + ) + if (match) { + await this.db + .update(QBPayoutSync) + .set({ qbDepositId: match.Id }) + .where( + and( + eq(QBPayoutSync.id, row.id), + eq(QBPayoutSync.portalId, this.user.workspaceId), + ), + ) + return { depositId: match.Id } + } + } + + const paymentIdByInvoice = + await this.syncLogService.getSuccessfulPaidPaymentIds(copilotInvoiceIds) + const unresolved = copilotInvoiceIds.filter( + (id) => !paymentIdByInvoice.has(id), + ) + if (unresolved.length > 0) { + // Can retry: the invoice.paid event may just not be saved yet. + throw new APIError( + httpStatus.NOT_FOUND, + `Payout ${payoutId}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`, + ) + } + + const allBatched = copilotInvoiceIds.every( + (id) => paymentIdByInvoice.get(id)?.isBatchedDeposit, + ) + const allNonBatched = copilotInvoiceIds.every( + (id) => !paymentIdByInvoice.get(id)?.isBatchedDeposit, + ) + // All non-batched means the fees were already booked, so nothing to deposit. + if (allNonBatched) return { depositId: null } + if (!allBatched) { + throw new MixedPayoutIntentError( + `Payout ${payoutId} mixes batched and non-batched invoices; unsupported`, + ) + } + + if (grossCents - feeCents !== netAmount) { + throw new TerminalPayoutError( + `Payout ${payoutId}: deposit total ${grossCents - feeCents} != payout net ${netAmount}`, + ) + } + + const bankAccountRef = qbTokenInfo.bankAccountRef + if (!bankAccountRef) { + // Can retry: works once a bank account is set in settings. + throw new APIError( + httpStatus.BAD_REQUEST, + `Bank account ref is not configured for portal ${this.user.workspaceId}. Please select a bank account in the QuickBooks integration settings.`, + ) + } + + const intuitApi = new IntuitAPI(qbTokenInfo) + const tokenService = new TokenService(this.user) + const verifiedBankAccountRef = + await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.Bank, + qbTokenInfo.intuitRealmId, + intuitApi, + bankAccountRef, + ) + const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.Expense, + qbTokenInfo.intuitRealmId, + intuitApi, + qbTokenInfo.expenseAccountRef, + ) + + const paymentService = new PaymentService(this.user) + const depositId = await paymentService.createBankDepositForPayment( + intuitApi, + { + lines: lineItems.map((line) => ({ + qbPaymentId: paymentIdByInvoice.get(line.copilotInvoiceId) + ?.paymentId as string, + amount: line.grossAmount / 100, + })), + feeTotal: feeCents / 100, + bankAccountRef: verifiedBankAccountRef, + expenseAccountRef, + txnDate: new Date(row.arrivalDate * 1000).toISOString().split('T')[0], + privateNote, + }, + ) + + await this.db + .update(QBPayoutSync) + .set({ qbDepositId: depositId }) + .where( + and( + eq(QBPayoutSync.id, row.id), + eq(QBPayoutSync.portalId, this.user.workspaceId), + ), + ) + + return { depositId } + } +} diff --git a/src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts b/src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts new file mode 100644 index 00000000..0efb7d0f --- /dev/null +++ b/src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts @@ -0,0 +1,21 @@ +import authenticate from '@/app/api/core/utils/authenticate' +import { AuthService } from '@/app/api/quickbooks/auth/auth.service' +import { BankAccountService } from '@/app/api/quickbooks/setting/bank-account/bank-account.service' +import IntuitAPI from '@/utils/intuitAPI' +import { NextRequest, NextResponse } from 'next/server' + +export async function getBankAccounts(req: NextRequest) { + const user = await authenticate(req) + const authService = new AuthService(user) + const qbTokenInfo = await authService.getQBPortalConnection( + user.workspaceId, + true, + ) + if (!qbTokenInfo || !qbTokenInfo.accessToken) { + throw new Error('Tokens expired. Reauthorization required.') + } + const intuitApi = new IntuitAPI(qbTokenInfo) + const bankAccountService = new BankAccountService(user) + const accounts = await bankAccountService.listActiveBankAccounts(intuitApi) + return NextResponse.json({ accounts }) +} diff --git a/src/app/api/quickbooks/setting/bank-account/bank-account.service.ts b/src/app/api/quickbooks/setting/bank-account/bank-account.service.ts new file mode 100644 index 00000000..d55d303f --- /dev/null +++ b/src/app/api/quickbooks/setting/bank-account/bank-account.service.ts @@ -0,0 +1,14 @@ +import { BaseService } from '@/app/api/core/services/base.service' +import { QBAccountQueryResponseSchema } from '@/type/dto/intuitAPI.dto' +import IntuitAPI, { QB_ACCOUNT_COLUMNS } from '@/utils/intuitAPI' + +export class BankAccountService extends BaseService { + async listActiveBankAccounts(intuitApi: IntuitAPI) { + // 1000 is QBO's max single-page size — far more bank accounts than any + // real company has, so a single query returns them all. + const rawResult = await intuitApi.customQuery( + `SELECT ${QB_ACCOUNT_COLUMNS.join(', ')} FROM Account WHERE AccountType = 'Bank' AND Active = true maxresults 1000`, + ) + return QBAccountQueryResponseSchema.parse(rawResult ?? {}).Account ?? [] + } +} diff --git a/src/app/api/quickbooks/setting/bank-account/route.ts b/src/app/api/quickbooks/setting/bank-account/route.ts new file mode 100644 index 00000000..78c06f9f --- /dev/null +++ b/src/app/api/quickbooks/setting/bank-account/route.ts @@ -0,0 +1,4 @@ +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' +import { getBankAccounts } from '@/app/api/quickbooks/setting/bank-account/bank-account.controller' + +export const GET = withErrorHandler(getBankAccounts) diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index 63f911a1..7dabdd1c 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,6 +1,11 @@ import authenticate from '@/app/api/core/utils/authenticate' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { isPortalInBankDepositABTest } from '@/utils/abTesting' +import { db } from '@/db' +import { QBPortalConnection } from '@/db/schema/qbPortalConnections' import { QBSetting } from '@/db/schema/qbSettings' +import { getPortalConnection } from '@/db/service/token.service' import { eq } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -22,12 +27,27 @@ export async function getSettings(req: NextRequest) { 'initialProductSettingMap', ) if (parsedType.data === SettingType.INVOICE) - returningFields.push('absorbedFeeFlag', 'useCompanyNameFlag') + returningFields.push( + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + 'useCompanyNameFlag', + ) if (parsedType.data === SettingType.PRODUCT) returningFields.push('createNewProductFlag') } const setting = await settingService.getOneByPortalId(returningFields) - return NextResponse.json({ setting }) + + const bankAccountRef = + parsedType.success && parsedType.data === SettingType.INVOICE + ? (await getPortalConnection(user.workspaceId))?.bankAccountRef || null + : null + + const bankDepositEnabled = + parsedType.success && parsedType.data === SettingType.INVOICE + ? isPortalInBankDepositABTest(user.workspaceId) + : false + + return NextResponse.json({ setting, bankAccountRef, bankDepositEnabled }) } export async function updateSettings(req: NextRequest) { @@ -39,15 +59,50 @@ export async function updateSettings(req: NextRequest) { const parsedType = z.nativeEnum(SettingType).parse(type) + const parsed = SettingRequestSchema.parse(body) + const { bankAccountRef, bankDepositFeeFlag, ...settingFields } = parsed + + // Bank deposit fields are only honored for invoice settings on AB-test + // portals; everyone else has the flag and bank account stripped from writes. + const isBankDepositAB = + parsedType === SettingType.INVOICE && + isPortalInBankDepositABTest(user.workspaceId) + const payload = { - ...SettingRequestSchema.parse(body), + ...settingFields, + ...(isBankDepositAB && { bankDepositFeeFlag }), ...(parsedType === SettingType.INVOICE ? { initialInvoiceSettingMap: true } : { initialProductSettingMap: true }), } - const setting = await settingService.updateQBSettings( - payload, - eq(QBSetting.portalId, user.workspaceId), - ) + + const writeBankAccountRef = + isBankDepositAB && typeof bankAccountRef !== 'undefined' + + const setting = await db.transaction(async (tx) => { + settingService.setTransaction(tx) + try { + const result = await settingService.updateQBSettings( + payload, + eq(QBSetting.portalId, user.workspaceId), + ) + if (writeBankAccountRef) { + const tokenService = new TokenService(user) + tokenService.setTransaction(tx) + try { + await tokenService.updateQBPortalConnection( + { bankAccountRef: bankAccountRef || null }, + eq(QBPortalConnection.portalId, user.workspaceId), + ) + } finally { + tokenService.unsetTransaction() + } + } + return result + } finally { + settingService.unsetTransaction() + } + }) + return NextResponse.json({ setting }, { status: httpStatus.CREATED }) } diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index efd93239..0b3b430c 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -26,6 +26,13 @@ import { captureMessage } from '@sentry/nextjs' import { AccountTypeObj } from '@/constant/qbConnection' import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error' import { getCategory, getShouldRetryForCategory } from '@/utils/synclog' +import { PayoutService } from '@/app/api/quickbooks/payout/payout.service' +import { + MixedPayoutIntentError, + TerminalPayoutError, + getShouldRetryForPayout, +} from '@/app/api/quickbooks/payout/payout.errors' +import { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode' export const runtime = 'nodejs' @@ -254,6 +261,12 @@ export class SyncService extends BaseService { ) } + // Batched deposit: the payout books this fee, so there's no expense to create here. Delete the log + if (invoiceSync.isBatchedDeposit) { + await this.syncLogService.deleteQBSyncLog(record.id) + return + } + const intuitApi = new IntuitAPI(qbTokenInfo) const tokenService = new TokenService(this.user) const assetAccountRef = await tokenService.checkAndUpdateAccountStatus( @@ -307,6 +320,80 @@ export class SyncService extends BaseService { } } + private async processPayoutSync( + record: QBSyncLogSelectSchemaType, + qbTokenInfo: IntuitAPITokensType, + ) { + const payoutService = new PayoutService(this.user) + try { + // Claim the row before any QBO work: flip FAILED -> PENDING in one + // atomic update. If another resync run (12h cron vs OAuth-reconnect) + // already claimed it, this matches zero rows and we skip — otherwise + // both runs could create a second, undeletable bank deposit. + const claimed = await this.syncLogService.updateQBSyncLog( + { status: LogStatus.PENDING }, + and( + eq(QBSyncLog.id, record.id), + eq(QBSyncLog.status, LogStatus.FAILED), + ) as WhereClause, + ) + if (!claimed) { + CustomLogger.info({ + message: + 'SyncService#processPayoutSync | Already claimed by another run, skipping', + obj: { copilotId: record.copilotId }, + }) + return + } + + const payoutRow = await payoutService.getPayoutSync(record.copilotId) + if (!payoutRow) { + // No saved payout data, so we can't rebuild the deposit. Stop trying. + throw new TerminalPayoutError( + `No qb_payout_sync row for payout ${record.copilotId}`, + ) + } + + const { depositId } = await payoutService.reconcile( + payoutRow, + qbTokenInfo, + { + runIdempotencyCheck: true, + }, + ) + + await this.syncLogService.updateQBSyncLog( + { + status: LogStatus.SUCCESS, + quickbooksId: depositId ?? undefined, + errorMessage: '', + }, + eq(QBSyncLog.id, record.id), + ) + } catch (error: unknown) { + CustomLogger.error({ + message: 'SyncService#processPayoutSync', + obj: { error }, + }) + const isMixed = error instanceof MixedPayoutIntentError + const errorWithCode = getMessageAndCodeFromError(error) + await this.syncLogService.updateQBSyncLog( + { + status: LogStatus.FAILED, + errorMessage: errorWithCode.message, + errorCode: isMixed + ? PAYOUT_MIXED_INTENT_CODE + : errorWithCode.code?.toString(), + shouldRetry: getShouldRetryForPayout(error), + category: isMixed + ? FailedRecordCategoryType.OTHERS + : getCategory(errorWithCode), + }, + eq(QBSyncLog.id, record.id), + ) + } + } + private async processProductCreate( record: QBSyncLogSelectSchemaType, qbTokenInfo: IntuitAPITokensType, @@ -464,6 +551,15 @@ export class SyncService extends BaseService { await this.processProductSync(log, qbTokenInfo, log.eventType) break + case EntityType.PAYOUT: + if (log.eventType === EventType.SETTLED) { + CustomLogger.info({ + message: 'SyncService#intiateSync | Payout re-sync started', + }) + await this.processPayoutSync(log, qbTokenInfo) + } + break + default: CustomLogger.error({ message: 'SyncService#intiateSync | Unknown entity type', diff --git a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts index 2682d76a..2861e425 100644 --- a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -5,9 +5,15 @@ import { NotificationContext, } from '@/app/api/core/types/notification' import { NotificationService } from '@/app/api/notification/notification.service' -import { UserActionableErrorCodes } from '@/constant/intuitErrorCode' +import { + AppActionableErrorCodes, + MIXED_INTENT_INVOICE_DELIMITER, + UserActionableErrorCodes, +} from '@/constant/intuitErrorCode' import { QBSyncLogSelectSchemaType } from '@/db/schema/qbSyncLogs' import { getPortalConnection } from '@/db/service/token.service' +import { getInvoiceNumbersWithRecordedFee } from '@/db/service/syncLog.service' +import CustomLogger from '@/utils/logger' /** * Looks up the user-actionable notification action for a given QBO error code. @@ -18,7 +24,11 @@ export function getActionForErrorCode( errorCode: string | null | undefined, ): NotificationActions | null { if (!errorCode) return null - return UserActionableErrorCodes[errorCode] ?? null + return ( + UserActionableErrorCodes[errorCode] ?? + AppActionableErrorCodes[errorCode] ?? + null + ) } /** @@ -36,6 +46,13 @@ export function getEntityKey(log: QBSyncLogSelectSchemaType): string { ) } +type MixedPayoutInvoices = { + // Display-joined affected invoice numbers (from the log `remark`). + affectedInvoiceNumbers?: string + // Subset whose absorbed fee is already recorded in QBO. + invoiceNumbersWithFee?: string +} + export class SyncErrorNotifier extends BaseService { /** * Dispatches an IU notification for a freshly written FAILED sync log row @@ -62,15 +79,26 @@ export class SyncErrorNotifier extends BaseService { return } + // Only mixed-payout rows carry an affected-invoice list to resolve. + const { + affectedInvoiceNumbers, + invoiceNumbersWithFee, + }: MixedPayoutInvoices = + action !== NotificationActions.QB_PAYOUT_MIXED_INTENT + ? {} + : await this.resolveMixedPayoutInvoices(log.remark) + const context: NotificationContext = { entityType: log.entityType, eventType: log.eventType, entityKey: getEntityKey(log), - invoiceNumber: log.invoiceNumber ?? undefined, - customerName: log.customerName ?? undefined, - productName: log.productName ?? undefined, - qbItemName: log.qbItemName ?? undefined, - errorMessage: log.errorMessage ?? undefined, + invoiceNumber: log.invoiceNumber, + customerName: log.customerName, + productName: log.productName, + qbItemName: log.qbItemName, + errorMessage: log.errorMessage, + invoiceNumbers: affectedInvoiceNumbers, + invoiceNumbersWithFee, } const portal = await getPortalConnection(this.user.workspaceId) @@ -84,4 +112,34 @@ export class SyncErrorNotifier extends BaseService { context, ) } + + // Resolve a mixed-payout `remark` into its affected invoices and the subset + // with a recorded fee; a lookup blip drops that detail, not the notification. + private async resolveMixedPayoutInvoices( + remark: string | null, + ): Promise { + if (!remark) return {} + const affected = remark + .split(MIXED_INTENT_INVOICE_DELIMITER) + .filter(Boolean) + let invoiceNumbersWithFee: string | undefined + try { + const withFee = await getInvoiceNumbersWithRecordedFee( + this.user.workspaceId, + affected, + ) + const recorded = affected.filter((invoiceNumber) => + withFee.has(invoiceNumber), + ) + if (recorded.length) + invoiceNumbersWithFee = recorded.join(MIXED_INTENT_INVOICE_DELIMITER) + } catch (error) { + CustomLogger.error({ + message: + 'SyncErrorNotifier | recorded-fee lookup failed; notifying without it', + obj: error, + }) + } + return { affectedInvoiceNumbers: remark, invoiceNumbersWithFee } + } } diff --git a/src/app/api/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index e79ae37b..7d476abd 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -17,11 +17,12 @@ import { QBSyncLogUpdateSchemaType, QBSyncLogWithEntityType, } from '@/db/schema/qbSyncLogs' +import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync' import { WhereClause } from '@/type/common' import { orderMap } from '@/utils/drizzle' import CustomLogger from '@/utils/logger' import dayjs from 'dayjs' -import { and, eq, isNull, lt, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull, lt, sql } from 'drizzle-orm' import { captureException } from '@sentry/nextjs' import { json2csv } from 'json-2-csv' @@ -225,8 +226,8 @@ export class SyncLogService extends BaseService { /** * Atomic idempotency claim via the partial unique index - * `uq_qb_sync_logs_oneshot_active` (covers active invoice one-shot events - * and all payment events). For rows in that slice, ON CONFLICT DO NOTHING + * `uq_qb_sync_logs_oneshot_active` (covers active invoice one-shot events, + * all payment events, and payout/settled). For rows in that slice, ON CONFLICT DO NOTHING * yields no row when another worker has already claimed the tuple, so * `claimed: false` is returned. For rows outside the slice * (INVOICE/UPDATED, PRODUCT, PRICE), the partial index does not apply and @@ -264,6 +265,7 @@ export class SyncLogService extends BaseService { where: sql`deleted_at IS NULL AND ( (entity_type = 'invoice' AND event_type IN ('created','paid','voided','deleted')) OR (entity_type = 'payment' AND event_type = 'succeeded') + OR (entity_type = 'payout' AND event_type = 'settled') )`, }) .returning({ id: QBSyncLog.id }) @@ -285,6 +287,9 @@ export class SyncLogService extends BaseService { .set({ status: LogStatus.FAILED, category: FailedRecordCategoryType.OTHERS, + // Stale payout claims can't be retried (no resync path), so make them + // terminal; other entity types keep their retryability. + shouldRetry: sql`CASE WHEN ${QBSyncLog.entityType} = 'payout' THEN false ELSE ${QBSyncLog.shouldRetry} END`, }) .where( and( @@ -376,6 +381,63 @@ export class SyncLogService extends BaseService { return log || null } + /** + * Maps Copilot invoice IDs → QBO Payment ID + frozen batched-deposit intent + * from this portal's INVOICE/PAID/SUCCESS rows (quickbooksId holds the + * Payment ID there), joined against qb_invoice_sync for the intent flag. + */ + async getSuccessfulPaidPaymentIds( + copilotInvoiceIds: string[], + ): Promise< + Map< + string, + { paymentId: string; isBatchedDeposit: boolean; invoiceNumber: string } + > + > { + if (copilotInvoiceIds.length === 0) return new Map() + + const rows = await this.db + .select({ + copilotId: QBSyncLog.copilotId, + quickbooksId: QBSyncLog.quickbooksId, + isBatchedDeposit: QBInvoiceSync.isBatchedDeposit, + invoiceNumber: QBSyncLog.invoiceNumber, + }) + .from(QBSyncLog) + .innerJoin( + QBInvoiceSync, + and( + eq(QBInvoiceSync.portalId, QBSyncLog.portalId), + eq(QBInvoiceSync.invoiceNumber, QBSyncLog.invoiceNumber), + isNull(QBInvoiceSync.deletedAt), + ), + ) + .where( + and( + eq(QBSyncLog.portalId, this.user.workspaceId), + eq(QBSyncLog.entityType, EntityType.INVOICE), + eq(QBSyncLog.eventType, EventType.PAID), + eq(QBSyncLog.status, LogStatus.SUCCESS), + inArray(QBSyncLog.copilotId, copilotInvoiceIds), + isNull(QBSyncLog.deletedAt), + ), + ) + + const paymentIdByInvoice = new Map< + string, + { paymentId: string; isBatchedDeposit: boolean; invoiceNumber: string } + >() + for (const row of rows) { + if (row.quickbooksId) + paymentIdByInvoice.set(row.copilotId, { + paymentId: row.quickbooksId, + isBatchedDeposit: row.isBatchedDeposit, + invoiceNumber: row.invoiceNumber ?? '', + }) + } + return paymentIdByInvoice + } + async prepareSyncLogsForDownload() { const logs = await this.db.query.QBSyncLog.findMany({ where: eq(QBSyncLog.portalId, this.user.workspaceId), diff --git a/src/app/api/quickbooks/token/token.service.ts b/src/app/api/quickbooks/token/token.service.ts index f4fead40..26f787be 100644 --- a/src/app/api/quickbooks/token/token.service.ts +++ b/src/app/api/quickbooks/token/token.service.ts @@ -177,6 +177,9 @@ export class TokenService extends BaseService { case AccountTypeObj.Asset: payload = { assetAccountRef: accountRef } break + // AccountTypeObj.Bank intentionally falls through: restoreAccountRef + // throws for Bank before we ever get here (bank refs are user-selected, + // never auto-mapped). If that ever changes, add a Bank case here. default: throw new APIError( httpStatus.BAD_REQUEST, @@ -298,6 +301,13 @@ export class TokenService extends BaseService { return this.getOrCreateExpenseAccountRef(intuitApi) case AccountTypeObj.Asset: return this.getOrCreateAssetAccountRef(intuitApi) + case AccountTypeObj.Bank: + // Never auto-restore a bank account — that could deposit into the + // wrong one. Make the user reselect instead. + throw new APIError( + httpStatus.BAD_REQUEST, + 'Bank account is missing or was deleted in QuickBooks. Please reselect a bank account in the QuickBooks integration settings.', + ) default: throw new APIError( httpStatus.BAD_REQUEST, diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index d29e3c08..3d5df6a3 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -1,10 +1,20 @@ import APIError from '@/app/api/core/exceptions/api' import { BaseService } from '@/app/api/core/services/base.service' import { InvoiceStatus } from '@/app/api/core/types/invoice' -import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' +import { + EntityType, + EventType, + FailedRecordCategoryType, + LogStatus, +} from '@/app/api/core/types/log' import { WebhookEvents } from '@/app/api/core/types/webhook' import { InvoiceService } from '@/app/api/quickbooks/invoice/invoice.service' import { PaymentService } from '@/app/api/quickbooks/payment/payment.service' +import { + MixedPayoutIntentError, + getShouldRetryForPayout, +} from '@/app/api/quickbooks/payout/payout.errors' +import { PayoutService } from '@/app/api/quickbooks/payout/payout.service' import { ProductService } from '@/app/api/quickbooks/product/product.service' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' @@ -14,6 +24,7 @@ import { InvoiceDeletedResponseSchema, InvoiceResponseSchema, PaymentSucceededResponseSchema, + PayoutReconciliationCompletedSchema, ProductCreatedResponseSchema, ProductUpdatedResponseSchema, WebhookEventResponseSchema, @@ -29,6 +40,10 @@ import { getCategory, getShouldRetryForCategory } from '@/utils/synclog' import { addSyncBreadcrumb } from '@/utils/sentry' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' +import { + MIXED_INTENT_INVOICE_DELIMITER, + PAYOUT_MIXED_INTENT_CODE, +} from '@/constant/intuitErrorCode' export class WebhookService extends BaseService { async handleWebhookEvent( @@ -109,6 +124,12 @@ export class WebhookService extends BaseService { delayMs: 7000, }) + case WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED: + return await this.handlePayoutReconciliationCompleted( + payload, + qbTokenInfo, + ) + default: console.error('WebhookService#handleWebhookEvent | Unknown event type') } @@ -340,22 +361,13 @@ export class WebhookService extends BaseService { ) } catch (error: unknown) { CustomLogger.error({ message: 'Webhook handler failed', obj: error }) - const errorWithCode = getMessageAndCodeFromError(error) - const errorMessage = errorWithCode.message - - await syncLogService.updateOrCreateQBSyncLog({ - portalId: this.user.workspaceId, - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.FAILED, - copilotId: parsedPaidInvoiceResource.data.id, - invoiceNumber: parsedPaidInvoiceResource.data.number, - amount: parsedPaidInvoiceResource.data.total.toFixed(2), - errorMessage, - errorCode: errorWithCode.code?.toString(), - shouldRetry: getShouldRetryForCategory(errorWithCode), - category: getCategory(errorWithCode), - }) + await this.pushFailedInvoiceToSyncLog( + EventType.PAID, + parsedPaidInvoiceResource.data.id, + parsedPaidInvoiceResource.data.number, + parsedPaidInvoiceResource.data.total, + getMessageAndCodeFromError(error), + ) console.error( `WebhookService#handleWebhookEvent#handleInvoicePaid :: Error | Portal Id: ${this.user.workspaceId} | Invoice: ${parsedPaidInvoiceResource.data.id}`, ) @@ -465,6 +477,34 @@ export class WebhookService extends BaseService { } } + // Shared FAILED absorbed-fee log for the no-mapping and QB-error paths. + private async logAbsorbedFeeFailure(opts: { + copilotId: string + feeAmount: string + errorMessage: string + invoiceNumber?: string + errorCode?: string + shouldRetry: boolean + category?: FailedRecordCategoryType + }) { + const syncLogService = new SyncLogService(this.user) + await syncLogService.updateOrCreateQBSyncLog({ + portalId: this.user.workspaceId, + entityType: EntityType.PAYMENT, + eventType: EventType.SUCCEEDED, + status: LogStatus.FAILED, + copilotId: opts.copilotId, + invoiceNumber: opts.invoiceNumber, + feeAmount: opts.feeAmount, + remark: 'Absorbed fees', + qbItemName: 'Assembly Fees', + errorMessage: opts.errorMessage, + errorCode: opts.errorCode, + shouldRetry: opts.shouldRetry, + category: opts.category, + }) + } + private async handlePaymentSucceeded( payload: unknown, qbTokenInfo: IntuitAPITokensType, @@ -479,90 +519,243 @@ export class WebhookService extends BaseService { ) return } - const parsedPaymentSucceedResource = parsedPaymentSucceed.data - const feeAmount = parsedPaymentSucceedResource.data.feeAmount + const resource = parsedPaymentSucceed.data + const feeAmount = resource.data.feeAmount - if (feeAmount?.paidByPlatform && feeAmount.paidByPlatform > 0) { - // check if absorbed fee flag is true - const settingService = new SettingService(this.user) - const setting = await settingService.getOneByPortalId(['absorbedFeeFlag']) + // Only a platform-absorbed fee books a QBO expense; nothing to do otherwise. + if (!feeAmount?.paidByPlatform || feeAmount.paidByPlatform <= 0) return - if (!setting?.absorbedFeeFlag) { - console.info( - 'WebhookService#handleWebhookEvent#payment-succeeded | Absorbed fee flag is false', - ) - return - } + const { id: paymentId, invoiceId } = resource.data + const platformFee = feeAmount.paidByPlatform - if (opts.delayMs) await sleep(opts.delayMs) + // Gate on the absorbed-fee flag before any fetch (off-flag → no Copilot call). + const settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId(['absorbedFeeFlag']) + if (!setting?.absorbedFeeFlag) { + console.info( + 'WebhookService#handlePaymentSucceeded | Absorbed fee flag is false', + ) + return + } - const syncLogService = new SyncLogService(this.user) - const { claimed } = await syncLogService.claimWebhookEvent({ - copilotId: parsedPaymentSucceedResource.data.id, - entityType: EntityType.PAYMENT, + const syncLogService = new SyncLogService(this.user) + // Cheap duplicate short-circuit before the sleep + Copilot fetch. Status- + // blind like claimWebhookEvent; FAILED recovery is the resync cron's job. + const existingPaymentLog = + await syncLogService.getOneByCopilotIdAndEventType({ + copilotId: paymentId, eventType: EventType.SUCCEEDED, + entityType: EntityType.PAYMENT, }) - if (!claimed) { - console.info( - `WebhookService#handlePaymentSucceeded | Already claimed (payment/${EventType.SUCCEEDED}, copilotId=${parsedPaymentSucceedResource.data.id}), skipping`, - ) - return - } + if (existingPaymentLog) { + console.info( + `WebhookService#handlePaymentSucceeded | Already claimed (payment/${EventType.SUCCEEDED}, copilotId=${paymentId}); skipping`, + ) + return + } - const copilotApp = new CopilotAPI(this.user.token) - const invoice = await copilotApp.getInvoice( - parsedPaymentSucceedResource.data.invoiceId, + if (opts.delayMs) await sleep(opts.delayMs) + + const copilotApp = new CopilotAPI(this.user.token) + const invoice = await copilotApp.getInvoice(invoiceId) + if (!invoice) + throw new APIError( + httpStatus.NOT_FOUND, + `Invoice not found in Assembly for invoice id: ${invoiceId}`, ) - if (!invoice) - throw new APIError( - httpStatus.NOT_FOUND, - `Invoice not found in Assembly for invoice id: ${parsedPaymentSucceedResource.data.invoiceId}`, - ) - try { - validateAccessToken(qbTokenInfo) - const invService = new InvoiceService(this.user) - const invoiceSync = await invService.getInvoiceByNumber(invoice.number) - if (!invoiceSync) { - throw new APIError( - httpStatus.NOT_FOUND, - `No invoice found in invoice sync table for invoice id: ${parsedPaymentSucceedResource.data.invoiceId}`, - ) - } - // only track if the fee amount is paid by platform - const paymentService = new PaymentService(this.user) - await paymentService.webhookPaymentSucceeded({ - parsedPaymentSucceedResource, - qbTokenInfo, - qbDocNumber: invoiceSync.qbDocNumber ?? invoice.number, - invoiceNumber: invoice.number, - }) - } catch (error: unknown) { - CustomLogger.error({ message: 'Webhook handler failed', obj: error }) - const errorWithCode = getMessageAndCodeFromError(error) - const errorMessage = errorWithCode.message - const feeAmount = parsedPaymentSucceedResource.data.feeAmount + // Fetch before claiming so the batched defer below writes zero rows. + const invService = new InvoiceService(this.user) + const invoiceSync = await invService.getInvoiceByNumber(invoice.number, [ + 'id', + 'qbInvoiceId', + 'qbDocNumber', + 'isBatchedDeposit', + ]) + + if (invoiceSync?.isBatchedDeposit) { + // Frozen batched: the payout books the fee. Defer before claiming. + console.info( + 'WebhookService#handlePaymentSucceeded | Batched-deposit mode (frozen); deferring to payout event', + ) + return + } - await syncLogService.updateOrCreateQBSyncLog({ - portalId: this.user.workspaceId, - entityType: EntityType.PAYMENT, - eventType: EventType.SUCCEEDED, - status: LogStatus.FAILED, - copilotId: parsedPaymentSucceedResource.data.id, - invoiceNumber: invoice.number, - feeAmount: feeAmount ? feeAmount.paidByPlatform.toFixed(2) : '0', - remark: 'Absorbed fees', - qbItemName: 'Assembly Fees', - errorMessage, - errorCode: errorWithCode.code?.toString(), - shouldRetry: getShouldRetryForCategory(errorWithCode), - category: getCategory(errorWithCode), - }) - console.error( - `WebhookService#handleWebhookEvent#handlePaymentSucceeded :: Error | Portal Id: ${this.user.workspaceId} | Payment: ${parsedPaymentSucceedResource.data.id}`, - ) - return - } + const { claimed } = await syncLogService.claimWebhookEvent({ + copilotId: paymentId, + eventType: EventType.SUCCEEDED, + entityType: EntityType.PAYMENT, + }) + if (!claimed) { + console.info( + `WebhookService#handlePaymentSucceeded | Already claimed (payment/${EventType.SUCCEEDED}, copilotId=${paymentId}), skipping`, + ) + return + } + + // Post-claim so the update targets the row just claimed, not a racing insert. + if (!invoiceSync) { + await this.logAbsorbedFeeFailure({ + copilotId: paymentId, + feeAmount: platformFee.toFixed(2), + errorMessage: `No invoice found in invoice sync table for invoice id: ${invoiceId}`, + shouldRetry: true, + invoiceNumber: invoice.number, + }) + return + } + + try { + validateAccessToken(qbTokenInfo) + const paymentService = new PaymentService(this.user) + await paymentService.webhookPaymentSucceeded({ + parsedPaymentSucceedResource: resource, + qbTokenInfo, + qbDocNumber: invoiceSync.qbDocNumber ?? invoice.number, + invoiceNumber: invoice.number, + }) + } catch (error: unknown) { + CustomLogger.error({ message: 'Webhook handler failed', obj: error }) + const errorWithCode = getMessageAndCodeFromError(error) + await this.logAbsorbedFeeFailure({ + copilotId: paymentId, + invoiceNumber: invoice.number, + feeAmount: platformFee.toFixed(2), + errorMessage: errorWithCode.message, + errorCode: errorWithCode.code?.toString(), + shouldRetry: getShouldRetryForCategory(errorWithCode), + category: getCategory(errorWithCode), + }) + console.error( + `WebhookService#handlePaymentSucceeded :: Error | Portal Id: ${this.user.workspaceId} | Payment: ${paymentId}`, + ) + return + } + } + + private async handlePayoutReconciliationCompleted( + payload: unknown, + qbTokenInfo: IntuitAPITokensType, + ) { + console.info('###### PAYOUT RECONCILIATION COMPLETED ######') + const parsedPayout = PayoutReconciliationCompletedSchema.safeParse(payload) + if (!parsedPayout.success) { + console.error( + 'WebhookService#handlePayoutReconciliationCompleted | Could not parse payout payload', + ) + return + } + const { + data: { payout, lineItems }, + } = parsedPayout.data + + const payoutId = payout.id + const syncLogService = new SyncLogService(this.user) + const payoutService = new PayoutService(this.user) + const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) + + // Resolve intent before claiming: an all-non-batched payout books nothing, + // so skip with zero rows (claiming would leave a PENDING that flips FAILED). + const paymentIdByInvoice = + await syncLogService.getSuccessfulPaidPaymentIds(copilotInvoiceIds) + const resolvedIntents = copilotInvoiceIds.map((id) => + paymentIdByInvoice.get(id), + ) + if (resolvedIntents.every((intent) => intent && !intent.isBatchedDeposit)) { + console.info( + `WebhookService#handlePayoutReconciliationCompleted | Payout ${payoutId}: all invoices non-batched, nothing to deposit`, + ) + return + } + + // Add up the fee here so the payout row, the success log, and the + // failure log can all use it. + const feeCents = lineItems.reduce((sum, line) => sum + line.feeAmount, 0) + + // Save the payout details first so a failed attempt can be rebuilt on + // resync. Runs before the claim, so a re-sent payout just updates the row. + const payoutRow = await payoutService.upsertPayoutSync({ + payoutId, + lineItems, + netAmount: payout.netAmount, + feeCents, + arrivalDate: payout.arrivalDate, + }) + + const { claimed } = await syncLogService.claimWebhookEvent({ + copilotId: payoutId, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + }) + if (!claimed) { + console.info( + `WebhookService#handlePayoutReconciliationCompleted | Already claimed (payout/${EventType.SETTLED}, copilotId=${payoutId}), skipping`, + ) + return + } + + try { + const { depositId } = await payoutService.reconcile( + payoutRow, + qbTokenInfo, + { runIdempotencyCheck: false }, + ) + + await syncLogService.updateOrCreateQBSyncLog({ + portalId: this.user.workspaceId, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.SUCCESS, + copilotId: payoutId, + quickbooksId: depositId ?? undefined, + amount: payout.netAmount.toFixed(2), + feeAmount: feeCents.toFixed(2), + remark: 'Stripe payout batched deposit', + qbItemName: 'Stripe payout', + errorMessage: '', + }) + } catch (error: unknown) { + CustomLogger.error({ + message: 'Payout reconciliation handler failed', + obj: error, + }) + const isMixed = error instanceof MixedPayoutIntentError + const errorWithCode = getMessageAndCodeFromError(error) + // Mixed intent stashes the affected invoice numbers in `remark` (a payout + // spans multiple invoices, so they don't fit the single invoiceNumber col). + const affectedInvoiceNumbers = copilotInvoiceIds + .map((id) => paymentIdByInvoice.get(id)?.invoiceNumber) + .filter(Boolean) + .join(MIXED_INTENT_INVOICE_DELIMITER) + // Single FAILED-log write. Mixed intent gets the routable sentinel so + // SyncErrorNotifier alerts IUs; everything else keeps its derived code. + // No qbItemName — it would outrank copilotId (the payout id) in the + // notification's entity reference. + await syncLogService.updateOrCreateQBSyncLog({ + portalId: this.user.workspaceId, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + copilotId: payoutId, + amount: payout.netAmount.toFixed(2), + feeAmount: feeCents.toFixed(2), + remark: + isMixed && affectedInvoiceNumbers + ? affectedInvoiceNumbers + : 'Stripe payout batched deposit', + errorMessage: errorWithCode.message, + errorCode: isMixed + ? PAYOUT_MIXED_INTENT_CODE + : errorWithCode.code?.toString(), + shouldRetry: getShouldRetryForPayout(error), + category: isMixed + ? FailedRecordCategoryType.OTHERS + : getCategory(errorWithCode), + }) + console.error( + `WebhookService#handlePayoutReconciliationCompleted :: Error | Portal Id: ${this.user.workspaceId} | Payout: ${payoutId}`, + ) + return } } } diff --git a/src/cmd/renameQbAccount/renameQbAccount.service.ts b/src/cmd/renameQbAccount/renameQbAccount.service.ts index e9d16847..6afbeb95 100644 --- a/src/cmd/renameQbAccount/renameQbAccount.service.ts +++ b/src/cmd/renameQbAccount/renameQbAccount.service.ts @@ -161,6 +161,7 @@ export class RenameQbAccountService extends BaseService { assetAccountRef: portal.assetAccountRef, serviceItemRef: portal.serviceItemRef, clientFeeRef: portal.clientFeeRef, + bankAccountRef: portal.bankAccountRef, } } } diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index 33bb6a90..2d05810a 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -3,6 +3,7 @@ import InvoiceDetail from '@/components/dashboard/settings/sections/invoice/Invo import AccountMapping from '@/components/dashboard/settings/sections/account/AccountMapping' import ProductMapping from '@/components/dashboard/settings/sections/product/ProductMapping' import Accordion from '@/components/ui/Accordion' +import ConfirmModal from '@/components/ui/ConfirmModal' import Divider from '@/components/ui/Divider' import { useInvoiceDetailSettings, @@ -39,11 +40,18 @@ export default function SettingAccordion({ const { settingState, - submitInvoiceSettings, cancelInvoiceSettings, isLoading, changeSettings, showButton: showInvoiceButton, + bankDepositEnabled, + bankAccountOptions, + bankAccountsError, + canSave, + showBankDepositWarning, + requestInvoiceSettingsSave, + confirmBankDepositChange, + cancelBankDepositChange, } = useInvoiceDetailSettings() const { @@ -86,6 +94,9 @@ export default function SettingAccordion({ settingState={settingState} changeSettings={changeSettings} isLoading={isLoading} + bankDepositEnabled={bankDepositEnabled} + bankAccountOptions={bankAccountOptions} + bankAccountsError={bankAccountsError} /> ), }, @@ -166,7 +177,8 @@ export default function SettingAccordion({ } variant="primary" prefixIcon="Check" - onClick={submitInvoiceSettings} + disabled={!canSave} + onClick={requestInvoiceSettingsSave} /> > )} @@ -196,6 +208,13 @@ export default function SettingAccordion({ ) })} + ) } diff --git a/src/components/dashboard/settings/sections/account/AccountMapping.tsx b/src/components/dashboard/settings/sections/account/AccountMapping.tsx index ea402a97..ba562c63 100644 --- a/src/components/dashboard/settings/sections/account/AccountMapping.tsx +++ b/src/components/dashboard/settings/sections/account/AccountMapping.tsx @@ -1,8 +1,6 @@ import { AccountsListResponseUi, AccountMappingState } from '@/hook/useSettings' -import useClickOutside from '@/hook/useClickOutside' -import { AccountOption } from '@/type/common' -import { Icon, Spinner } from 'copilot-design-system' -import { useRef, useState } from 'react' +import AccountSelect from '@/components/dashboard/settings/sections/account/AccountSelect' +import { Spinner } from 'copilot-design-system' type AccountMappingProps = { options: AccountsListResponseUi['options'] | undefined @@ -13,104 +11,6 @@ type AccountMappingProps = { isDisconnected: boolean } -function AccountSelect({ - label, - description, - value, - options, - placeholder, - onChange, -}: { - label: string - description: string - value: string - options: AccountOption[] | undefined - placeholder: string - onChange: (id: string) => void -}) { - const [isOpen, setIsOpen] = useState(false) - const dropdownRef = useRef(null) - const buttonRef = useRef(null) - - useClickOutside(dropdownRef, () => setIsOpen(false), [buttonRef]) - - const disabled = !options || options.length === 0 - const selected = options?.find((o) => o.id === value) - // Defends against an account being deleted in QBO between load and save — - // surfaces the stale id with a hint instead of silently snapping to empty. - const optionMissing = !!value && !selected - - const labelId = `account-select-label-${label.replace(/\s+/g, '-').toLowerCase()}` - return ( - - - {label} - - {description} - - setIsOpen((v) => !v)} - aria-haspopup="listbox" - aria-expanded={isOpen} - aria-labelledby={labelId} - className="w-full bg-gray-100 hover:bg-gray-150 grid grid-cols-6 md:grid-cols-14 py-2 pl-4 pr-3 border border-gray-200 rounded text-left disabled:opacity-50 focus:outline-none focus:border-gray-200" - > - - {selected ? ( - selected.name - ) : optionMissing ? ( - - Please select {label.toLowerCase()} - - ) : ( - - {disabled ? 'No matching accounts in QuickBooks' : placeholder} - - )} - - - - - - {isOpen && !disabled && ( - - - {options?.map((o) => ( - { - onChange(o.id) - setIsOpen(false) - }} - className="w-full px-3 py-1.5 text-sm hover:bg-gray-100 focus:outline-none transition-colors cursor-pointer text-left text-gray-600 line-clamp-1 break-all lg:break-normal" - > - {o.name} - - ))} - - - )} - - - ) -} - export default function AccountMapping({ options, settingState, diff --git a/src/components/dashboard/settings/sections/account/AccountSelect.tsx b/src/components/dashboard/settings/sections/account/AccountSelect.tsx new file mode 100644 index 00000000..32f3e471 --- /dev/null +++ b/src/components/dashboard/settings/sections/account/AccountSelect.tsx @@ -0,0 +1,107 @@ +import useClickOutside from '@/hook/useClickOutside' +import { AccountOption } from '@/type/common' +import { Icon } from 'copilot-design-system' +import { useRef, useState } from 'react' + +export default function AccountSelect({ + label, + description, + value, + options, + placeholder, + onChange, +}: { + label: string + description: string + value: string + options: AccountOption[] | undefined + placeholder: string + onChange: (id: string) => void +}) { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + const buttonRef = useRef(null) + + useClickOutside(dropdownRef, () => setIsOpen(false), [buttonRef]) + + const loading = options === undefined + const disabled = !options || options.length === 0 + const selected = options?.find((o) => o.id === value) + // Defends against an account being deleted in QBO between load and save — + // surfaces the stale id with a hint instead of silently snapping to empty. + const optionMissing = !!value && !selected + + const labelId = `account-select-label-${label.replace(/\s+/g, '-').toLowerCase()}` + return ( + + + {label} + + {description} + + setIsOpen((v) => !v)} + aria-haspopup="listbox" + aria-expanded={isOpen} + aria-labelledby={labelId} + className="w-full bg-gray-100 hover:bg-gray-150 grid grid-cols-6 md:grid-cols-14 py-2 pl-4 pr-3 border border-gray-200 rounded text-left disabled:opacity-50 focus:outline-none focus:border-gray-200" + > + + {selected ? ( + selected.name + ) : optionMissing ? ( + + Please select {label.toLowerCase()} + + ) : ( + + {loading + ? 'Loading accounts…' + : disabled + ? 'No matching accounts in QuickBooks' + : placeholder} + + )} + + + + + + {isOpen && !disabled && ( + + + {options?.map((o) => ( + { + onChange(o.id) + setIsOpen(false) + }} + className="w-full px-3 py-1.5 text-sm hover:bg-gray-100 focus:outline-none transition-colors cursor-pointer text-left text-gray-600 line-clamp-1 break-all lg:break-normal" + > + {o.name} + + ))} + + + )} + + + ) +} diff --git a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx index 0eb8701f..8f705050 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -1,18 +1,28 @@ import { useApp } from '@/app/context/AppContext' -import { InvoiceSettingType } from '@/type/common' +import AccountSelect from '@/components/dashboard/settings/sections/account/AccountSelect' +import { AccountOption, InvoiceSettingType } from '@/type/common' import { getWorkspaceLabel } from '@/utils/workspace' import { Checkbox, Spinner } from 'copilot-design-system' type InvoiceDetailProps = { settingState: InvoiceSettingType - changeSettings: (flag: keyof InvoiceSettingType, state: boolean) => void + changeSettings: ( + flag: K, + value: InvoiceSettingType[K], + ) => void isLoading: boolean + bankDepositEnabled: boolean + bankAccountOptions: AccountOption[] | undefined + bankAccountsError: unknown } export default function InvoiceDetail({ settingState, changeSettings, isLoading, + bankDepositEnabled, + bankAccountOptions, + bankAccountsError, }: InvoiceDetailProps) { const { workspace } = useApp() @@ -33,6 +43,50 @@ export default function InvoiceDetail({ } /> + {/* Bank deposit UI is gated behind the AB rollout allowlist. */} + {bankDepositEnabled && ( + <> + + + changeSettings( + 'bankDepositFeeFlag', + !settingState.bankDepositFeeFlag, + ) + } + /> + + {settingState.bankDepositFeeFlag && ( + + {bankAccountsError ? ( + + Could not load bank accounts. Reload to retry. + + ) : ( + <> + changeSettings('bankAccountRef', id)} + /> + {bankAccountOptions !== undefined && + !settingState.bankAccountRef && ( + + Select a deposit bank account to enable bank deposits. + + )} + > + )} + + )} + > + )} void + onCancel: () => void +} + +export default function ConfirmModal({ + open, + title, + description, + confirmLabel = 'Continue', + cancelLabel = 'Cancel', + onConfirm, + onCancel, +}: ConfirmModalProps) { + const titleId = useId() + const descId = useId() + const dialogRef = useRef(null) + + // On open, focus into the dialog; on close, restore focus to the opener. + useEffect(() => { + if (!open) return + const previouslyFocused = document.activeElement as HTMLElement | null + const buttons = dialogRef.current?.querySelectorAll('button') + buttons?.[0]?.focus() + return () => previouslyFocused?.focus() + }, [open]) + + // Escape cancels; Tab is trapped between the dialog's buttons. + useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') return onCancel() + if (e.key !== 'Tab') return + const buttons = Array.from( + dialogRef.current?.querySelectorAll('button') ?? [], + ) + if (buttons.length === 0) return + const first = buttons[0] + const last = buttons[buttons.length - 1] + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open, onCancel]) + + if (!open) return null + + return createPortal( + + e.stopPropagation()} + > + + {title} + + + {description} + + + + + + + , + document.body, + ) +} diff --git a/src/config/index.ts b/src/config/index.ts index 465c5d4a..abe0539b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -53,6 +53,15 @@ export const externalFetchTimeoutMs = parsePositiveMs( 30_000, ) +// Portal allowlist gating in-testing features (currently the bank deposit +// flow). Empty/unset means the feature is available to all portals. +export const abFeatureTestingPortals = ( + process.env.AB_FEATURE_TESTING_PORTALS || '' +) + .split(',') + .map((portalId) => portalId.trim()) + .filter(Boolean) + // Supabase export const supabaseProjectUrl = process.env.NEXT_PUBLIC_SUPABASE_PROJECT_URL || '' diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index f78fe985..f164400a 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -57,3 +57,18 @@ export const UserActionableErrorCodes: Record = { [QBOErrorCodes.DEPOSITED_TXN_LOCKED]: NotificationActions.QB_DEPOSITED_TXN_LOCKED, } + +// App-level sentinel error code for a payout that mixes batched and +// non-batched invoices — not a QBO code, so it lives outside the registry +// above. Written to qb_sync_logs.error_code so SyncErrorNotifier routes it. +export const PAYOUT_MIXED_INTENT_CODE = 'payout_mixed_intent' + +// Packs the affected invoice numbers into a mixed-payout log's `remark`. Shared +// so the writer's join and the notifier's split can't drift. +export const MIXED_INTENT_INVOICE_DELIMITER = ', ' + +// App-level (non-QBO) sentinel codes routed to IU notifications, consulted by +// getActionForErrorCode alongside UserActionableErrorCodes. +export const AppActionableErrorCodes: Record = { + [PAYOUT_MIXED_INTENT_CODE]: NotificationActions.QB_PAYOUT_MIXED_INTENT, +} diff --git a/src/constant/qbConnection.ts b/src/constant/qbConnection.ts index ca6ce522..f00eca51 100644 --- a/src/constant/qbConnection.ts +++ b/src/constant/qbConnection.ts @@ -2,4 +2,5 @@ export const AccountTypeObj = { Income: 'income', Expense: 'expense', Asset: 'asset', + Bank: 'bank', } as const diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 00000000..dbf9ede3 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,39 @@ +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import path from 'node:path' +import { databaseUrl } from '@/config' +import { migratePerFile } from '@/db/migratePerFile' + +/** + * Production/dev migration runner. Replaces `drizzle-kit migrate` in + * `scripts/build.sh` — the drizzle-kit CLI batches every pending migration + * into one transaction (same underlying `drizzle-orm` migrator), which + * breaks whenever an enum-add migration and a later migration that + * references the new value are both pending in the same run. `migratePerFile` + * applies one migration per transaction instead; see that module for why. + * + * command to run: `yarn db:migrate` + */ + +const MIGRATIONS_FOLDER = path.resolve(process.cwd(), 'src/db/migrations') + +;(async function run() { + if (!databaseUrl) { + console.error('migrate | DATABASE_URL is not set') + process.exit(1) + } + + const client = postgres(databaseUrl, { max: 1, prepare: false }) + try { + console.info('migrate | Applying pending migrations...') + await migratePerFile(drizzle(client), MIGRATIONS_FOLDER) + console.info('migrate | Migrations applied successfully') + } catch (error) { + console.error('migrate | Migration failed', error) + await client.end() + process.exit(1) + } + + await client.end() + process.exit(0) +})() diff --git a/src/db/migratePerFile.ts b/src/db/migratePerFile.ts new file mode 100644 index 00000000..cae4e7f2 --- /dev/null +++ b/src/db/migratePerFile.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { migrate } from 'drizzle-orm/postgres-js/migrator' +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { sql } from 'drizzle-orm' + +type Journal = { + version: string + dialect: string + entries: { idx: number; when: number; tag: string; breakpoints: boolean }[] +} + +// Fixed key so concurrent runners serialize on one advisory lock. +const MIGRATION_ADVISORY_LOCK_KEY = 4030604 + +/** + * Applies migrations one-per-transaction under a session advisory lock. + * + * Per-file commits avoid drizzle's batched `migrate()` "unsafe use of new + * value" error (enum added, then used in a later file); the lock stops + * concurrent runners racing the same pending migration. Used by globalSetup + * and the prod runner (src/db/migrate.ts). + */ +export async function migratePerFile>( + db: PostgresJsDatabase, + migrationsFolder: string, +): Promise { + const journal = JSON.parse( + fs.readFileSync(path.join(migrationsFolder, 'meta/_journal.json'), 'utf-8'), + ) as Journal + + await db.execute(sql`SELECT pg_advisory_lock(${MIGRATION_ADVISORY_LOCK_KEY})`) + + const tempFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'drizzle-migrate-')) + try { + fs.mkdirSync(path.join(tempFolder, 'meta')) + for (const entry of journal.entries) { + fs.copyFileSync( + path.join(migrationsFolder, `${entry.tag}.sql`), + path.join(tempFolder, `${entry.tag}.sql`), + ) + } + + for (let i = 0; i < journal.entries.length; i++) { + fs.writeFileSync( + path.join(tempFolder, 'meta/_journal.json'), + JSON.stringify({ + ...journal, + entries: journal.entries.slice(0, i + 1), + }), + ) + await migrate(db, { migrationsFolder: tempFolder }) + } + } finally { + fs.rmSync(tempFolder, { recursive: true, force: true }) + await db.execute( + sql`SELECT pg_advisory_unlock(${MIGRATION_ADVISORY_LOCK_KEY})`, + ) + } +} diff --git a/src/db/migrations/20260717110112_add_bank_deposit_fee_column.sql b/src/db/migrations/20260717110112_add_bank_deposit_fee_column.sql new file mode 100644 index 00000000..793e8730 --- /dev/null +++ b/src/db/migrations/20260717110112_add_bank_deposit_fee_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE "qb_portal_connections" ADD COLUMN "bank_account_ref" varchar(100);--> statement-breakpoint +ALTER TABLE "qb_settings" ADD COLUMN "bank_deposit_fee_flag" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/20260721083213_add_payout_settled_enums.sql b/src/db/migrations/20260721083213_add_payout_settled_enums.sql new file mode 100644 index 00000000..fdc92e7a --- /dev/null +++ b/src/db/migrations/20260721083213_add_payout_settled_enums.sql @@ -0,0 +1,2 @@ +ALTER TYPE "public"."entity_types" ADD VALUE 'payout';--> statement-breakpoint +ALTER TYPE "public"."event_types" ADD VALUE 'settled'; \ No newline at end of file diff --git a/src/db/migrations/20260721100005_extend_oneshot_index_payout.sql b/src/db/migrations/20260721100005_extend_oneshot_index_payout.sql new file mode 100644 index 00000000..cab3a7f2 --- /dev/null +++ b/src/db/migrations/20260721100005_extend_oneshot_index_payout.sql @@ -0,0 +1,6 @@ +DROP INDEX "uq_qb_sync_logs_oneshot_active";--> statement-breakpoint +CREATE UNIQUE INDEX "uq_qb_sync_logs_oneshot_active" ON "qb_sync_logs" USING btree ("portal_id","copilot_id","entity_type","event_type") WHERE "qb_sync_logs"."deleted_at" IS NULL AND ( + ("qb_sync_logs"."entity_type" = 'invoice' AND "qb_sync_logs"."event_type" IN ('created','paid','voided','deleted')) + OR ("qb_sync_logs"."entity_type" = 'payment' AND "qb_sync_logs"."event_type" = 'succeeded') + OR ("qb_sync_logs"."entity_type" = 'payout' AND "qb_sync_logs"."event_type" = 'settled') + ); \ No newline at end of file diff --git a/src/db/migrations/20260724091542_add_is_batched_deposit.sql b/src/db/migrations/20260724091542_add_is_batched_deposit.sql new file mode 100644 index 00000000..82c5ce3b --- /dev/null +++ b/src/db/migrations/20260724091542_add_is_batched_deposit.sql @@ -0,0 +1 @@ +ALTER TABLE "qb_invoice_sync" ADD COLUMN "is_batched_deposit" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/20260729105911_add_qb_payout_sync_table.sql b/src/db/migrations/20260729105911_add_qb_payout_sync_table.sql new file mode 100644 index 00000000..f6ceb6a0 --- /dev/null +++ b/src/db/migrations/20260729105911_add_qb_payout_sync_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE "qb_payout_sync" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "portal_id" varchar(255) NOT NULL, + "payout_id" varchar(100) NOT NULL, + "line_items" jsonb NOT NULL, + "net_amount" integer NOT NULL, + "fee_amount" integer NOT NULL, + "arrival_date" integer NOT NULL, + "qb_deposit_id" varchar(100), + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp +); +--> statement-breakpoint +CREATE UNIQUE INDEX "uq_qb_payout_sync_portal_payout_active" ON "qb_payout_sync" USING btree ("portal_id","payout_id") WHERE "qb_payout_sync"."deleted_at" is null; \ No newline at end of file diff --git a/src/db/migrations/meta/20260717110112_snapshot.json b/src/db/migrations/meta/20260717110112_snapshot.json new file mode 100644 index 00000000..7b08568e --- /dev/null +++ b/src/db/migrations/meta/20260717110112_snapshot.json @@ -0,0 +1,1144 @@ +{ + "id": "21b9376f-a29d-4c58-9bf2-dd10a8a85e04", + "prevId": "9dfe5213-8442-4637-946c-257a8f151d40", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_doc_number": { + "name": "qb_doc_number", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "bank_account_ref": { + "name": "bank_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_product_sync_product_active": { + "name": "uq_qb_product_sync_product_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_product_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bank_deposit_fee_flag": { + "name": "bank_deposit_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "should_retry": { + "name": "should_retry", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/20260721083213_snapshot.json b/src/db/migrations/meta/20260721083213_snapshot.json new file mode 100644 index 00000000..86ff8829 --- /dev/null +++ b/src/db/migrations/meta/20260721083213_snapshot.json @@ -0,0 +1,1146 @@ +{ + "id": "f6fc3d84-0b56-4a66-a843-b2f84bf4f83d", + "prevId": "21b9376f-a29d-4c58-9bf2-dd10a8a85e04", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_doc_number": { + "name": "qb_doc_number", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "bank_account_ref": { + "name": "bank_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_product_sync_product_active": { + "name": "uq_qb_product_sync_product_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_product_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bank_deposit_fee_flag": { + "name": "bank_deposit_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "should_retry": { + "name": "should_retry", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment", + "payout" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped", + "settled" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/20260721100005_snapshot.json b/src/db/migrations/meta/20260721100005_snapshot.json new file mode 100644 index 00000000..7ba16a31 --- /dev/null +++ b/src/db/migrations/meta/20260721100005_snapshot.json @@ -0,0 +1,1146 @@ +{ + "id": "9be3cb25-9fd3-4903-92e9-9d9cb0f197ff", + "prevId": "f6fc3d84-0b56-4a66-a843-b2f84bf4f83d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_doc_number": { + "name": "qb_doc_number", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "bank_account_ref": { + "name": "bank_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_product_sync_product_active": { + "name": "uq_qb_product_sync_product_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_product_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bank_deposit_fee_flag": { + "name": "bank_deposit_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "should_retry": { + "name": "should_retry", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n OR (\"qb_sync_logs\".\"entity_type\" = 'payout' AND \"qb_sync_logs\".\"event_type\" = 'settled')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment", + "payout" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped", + "settled" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/20260724091542_snapshot.json b/src/db/migrations/meta/20260724091542_snapshot.json new file mode 100644 index 00000000..ece839f8 --- /dev/null +++ b/src/db/migrations/meta/20260724091542_snapshot.json @@ -0,0 +1,1153 @@ +{ + "id": "f8ba789c-e9ae-4f5d-8651-949a7bfcf6c9", + "prevId": "9be3cb25-9fd3-4903-92e9-9d9cb0f197ff", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_doc_number": { + "name": "qb_doc_number", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "is_batched_deposit": { + "name": "is_batched_deposit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "bank_account_ref": { + "name": "bank_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_product_sync_product_active": { + "name": "uq_qb_product_sync_product_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_product_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bank_deposit_fee_flag": { + "name": "bank_deposit_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "should_retry": { + "name": "should_retry", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n OR (\"qb_sync_logs\".\"entity_type\" = 'payout' AND \"qb_sync_logs\".\"event_type\" = 'settled')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment", + "payout" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped", + "settled" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/20260729105911_snapshot.json b/src/db/migrations/meta/20260729105911_snapshot.json new file mode 100644 index 00000000..bbe80e18 --- /dev/null +++ b/src/db/migrations/meta/20260729105911_snapshot.json @@ -0,0 +1,1258 @@ +{ + "id": "2399a1e8-2af3-45ea-ba0a-1f8db0a33a55", + "prevId": "f8ba789c-e9ae-4f5d-8651-949a7bfcf6c9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.qb_connection_logs": { + "name": "qb_connection_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection_status": { + "name": "connection_status", + "type": "connection_statuses", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_customers": { + "name": "qb_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_company_id": { + "name": "client_company_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "given_name": { + "name": "given_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "customer_type": { + "name": "customer_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'client'" + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "qb_customer_id": { + "name": "qb_customer_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_customers_client_company_id_type_active_idx": { + "name": "uq_qb_customers_client_company_id_type_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "customer_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_customers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_invoice_sync": { + "name": "qb_invoice_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "customer_id": { + "name": "customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_invoice_id": { + "name": "qb_invoice_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_doc_number": { + "name": "qb_doc_number", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invoice_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "is_batched_deposit": { + "name": "is_batched_deposit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_invoice_sync_portal_id_invoice_number_active_idx": { + "name": "uq_qb_invoice_sync_portal_id_invoice_number_active_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invoice_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_invoice_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qb_invoice_sync_customer_id_qb_customers_id_fk": { + "name": "qb_invoice_sync_customer_id_qb_customers_id_fk", + "tableFrom": "qb_invoice_sync", + "tableTo": "qb_customers", + "columnsFrom": [ + "customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payment_sync": { + "name": "qb_payment_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "qb_payment_id": { + "name": "qb_payment_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_payout_sync": { + "name": "qb_payout_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payout_id": { + "name": "payout_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "line_items": { + "name": "line_items", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "net_amount": { + "name": "net_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fee_amount": { + "name": "fee_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "arrival_date": { + "name": "arrival_date", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "qb_deposit_id": { + "name": "qb_deposit_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_payout_sync_portal_payout_active": { + "name": "uq_qb_payout_sync_portal_payout_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payout_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_payout_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_portal_connections": { + "name": "qb_portal_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "intuit_realm_id": { + "name": "intuit_realm_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "expires_in": { + "name": "expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "x_refresh_token_expires_in": { + "name": "x_refresh_token_expires_in", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_type": { + "name": "token_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "token_set_time": { + "name": "token_set_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "intiated_by": { + "name": "intiated_by", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "income_account_ref": { + "name": "income_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "asset_account_ref": { + "name": "asset_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "expense_account_ref": { + "name": "expense_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "client_fee_ref": { + "name": "client_fee_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "service_item_ref": { + "name": "service_item_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "bank_account_ref": { + "name": "bank_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_item_id": { + "name": "qb_item_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "qb_sync_token": { + "name": "qb_sync_token", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_excluded": { + "name": "is_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_product_sync_product_active": { + "name": "uq_qb_product_sync_product_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_product_sync\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_settings": { + "name": "qb_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "absorbed_fee_flag": { + "name": "absorbed_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bank_deposit_fee_flag": { + "name": "bank_deposit_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "should_retry": { + "name": "should_retry", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_qb_sync_logs_lookup_active": { + "name": "idx_qb_sync_logs_lookup_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qb_sync_logs_pending_reaper": { + "name": "idx_qb_sync_logs_pending_reaper", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"qb_sync_logs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_qb_sync_logs_oneshot_active": { + "name": "uq_qb_sync_logs_oneshot_active", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "copilot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"qb_sync_logs\".\"deleted_at\" IS NULL AND (\n (\"qb_sync_logs\".\"entity_type\" = 'invoice' AND \"qb_sync_logs\".\"event_type\" IN ('created','paid','voided','deleted'))\n OR (\"qb_sync_logs\".\"entity_type\" = 'payment' AND \"qb_sync_logs\".\"event_type\" = 'succeeded')\n OR (\"qb_sync_logs\".\"entity_type\" = 'payout' AND \"qb_sync_logs\".\"event_type\" = 'settled')\n )", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment", + "payout" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped", + "settled" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info", + "pending" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 82075dc5..6ef0c736 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -169,6 +169,41 @@ "when": 1780482267187, "tag": "20260603102427_collapse_qb_product_sync_one_row_drop_price_columns", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1784286072146, + "tag": "20260717110112_add_bank_deposit_fee_column", + "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1784622733206, + "tag": "20260721083213_add_payout_settled_enums", + "breakpoints": true + }, + { + "idx": 26, + "version": "7", + "when": 1784628005402, + "tag": "20260721100005_extend_oneshot_index_payout", + "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1784884542846, + "tag": "20260724091542_add_is_batched_deposit", + "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1785322751721, + "tag": "20260729105911_add_qb_payout_sync_table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 5e3cbfb3..7b15a4c1 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -6,6 +6,7 @@ import { QBConnectionLogs } from '@/db/schema/qbConnectionLogs' import { QBCustomers } from '@/db/schema/qbCustomers' import { QBSetting } from '@/db/schema/qbSettings' import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { QBPayoutSync } from '@/db/schema/qbPayoutSync' export const schema = { QBInvoiceSync, @@ -16,4 +17,5 @@ export const schema = { QBCustomers, QBSetting, QBSyncLog, + QBPayoutSync, } diff --git a/src/db/schema/qbInvoiceSync.ts b/src/db/schema/qbInvoiceSync.ts index 928e801c..8bf5fc27 100644 --- a/src/db/schema/qbInvoiceSync.ts +++ b/src/db/schema/qbInvoiceSync.ts @@ -28,6 +28,7 @@ export const QBInvoiceSync = table( qbSyncToken: t.varchar('qb_sync_token', { length: 100 }), recipientId: t.uuid('recipient_id'), status: invoiceStatusEnum('status').default(InvoiceStatus.OPEN).notNull(), + isBatchedDeposit: t.boolean('is_batched_deposit').notNull().default(false), ...timestamps, }, (table) => [ diff --git a/src/db/schema/qbPayoutSync.ts b/src/db/schema/qbPayoutSync.ts new file mode 100644 index 00000000..cadd5d2b --- /dev/null +++ b/src/db/schema/qbPayoutSync.ts @@ -0,0 +1,51 @@ +import { PayoutLineItem } from '@/type/dto/webhook.dto' +import { timestamps } from '@/db/helper/column.helper' +import { isNull } from 'drizzle-orm' +import { pgTable as table } from 'drizzle-orm/pg-core' +import * as t from 'drizzle-orm/pg-core' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' +import { z } from 'zod' + +export const QBPayoutSync = table( + 'qb_payout_sync', + { + id: t.uuid().defaultRandom().primaryKey(), + portalId: t.varchar('portal_id', { length: 255 }).notNull(), + // Copilot payout id. Same value we store as copilotId on the payout sync log. + payoutId: t.varchar('payout_id', { length: 100 }).notNull(), + // Small list we always read as a whole, so jsonb is fine (never query one item). + lineItems: t.jsonb('line_items').$type().notNull(), + // Cents, like line_items — the payout payload is in cents throughout. + netAmount: t.integer('net_amount').notNull(), + feeAmount: t.integer('fee_amount').notNull(), + // Unix seconds, as delivered in the payout payload. + arrivalDate: t.integer('arrival_date').notNull(), + // Filled in once the QBO deposit is made. On resync we reuse it instead of + // making another. + qbDepositId: t.varchar('qb_deposit_id', { length: 100 }), + ...timestamps, + }, + (table) => [ + t + .uniqueIndex('uq_qb_payout_sync_portal_payout_active') + .on(table.portalId, table.payoutId) + .where(isNull(table.deletedAt)), + ], +) + +export const QBPayoutSyncCreateSchema = createInsertSchema(QBPayoutSync) +export type QBPayoutSyncCreateSchemaType = z.infer< + typeof QBPayoutSyncCreateSchema +> + +export const QBPayoutSyncSelectSchema = createSelectSchema(QBPayoutSync) +export type QBPayoutSyncSelectSchemaType = z.infer< + typeof QBPayoutSyncSelectSchema +> + +export const QBPayoutSyncUpdateSchema = QBPayoutSyncCreateSchema.omit({ + createdAt: true, +}).partial() +export type QBPayoutSyncUpdateSchemaType = z.infer< + typeof QBPayoutSyncUpdateSchema +> diff --git a/src/db/schema/qbPortalConnections.ts b/src/db/schema/qbPortalConnections.ts index b35e4ed0..4176ef03 100644 --- a/src/db/schema/qbPortalConnections.ts +++ b/src/db/schema/qbPortalConnections.ts @@ -28,6 +28,7 @@ export const QBPortalConnection = table( .notNull(), clientFeeRef: t.varchar('client_fee_ref', { length: 100 }), serviceItemRef: t.varchar('service_item_ref', { length: 100 }), + bankAccountRef: t.varchar('bank_account_ref', { length: 100 }), isSuspended: t.boolean('is_suspended').notNull().default(false), ...timestamps, }, diff --git a/src/db/schema/qbSettings.ts b/src/db/schema/qbSettings.ts index 2d2d580b..18eca40f 100644 --- a/src/db/schema/qbSettings.ts +++ b/src/db/schema/qbSettings.ts @@ -15,6 +15,10 @@ export const QBSetting = table('qb_settings', { .references(() => QBPortalConnection.portalId, { onDelete: 'cascade' }) .notNull(), absorbedFeeFlag: t.boolean('absorbed_fee_flag').default(false).notNull(), + bankDepositFeeFlag: t + .boolean('bank_deposit_fee_flag') + .default(false) + .notNull(), useCompanyNameFlag: t.boolean('company_name_flag').default(false).notNull(), createNewProductFlag: t .boolean('create_new_product_flag') diff --git a/src/db/schema/qbSyncLogs.ts b/src/db/schema/qbSyncLogs.ts index 6b79ce57..98472487 100644 --- a/src/db/schema/qbSyncLogs.ts +++ b/src/db/schema/qbSyncLogs.ts @@ -79,6 +79,7 @@ export const QBSyncLog = table( // - INVOICE/{created,paid,voided,deleted}: one-shot per invoice; dual-fire // would cause customer-visible duplicate QBO invoices. // - PAYMENT/succeeded: one-shot per payment. + // - PAYOUT/settled: one-shot per payout. // INVOICE/updated, PRODUCT, and PRICE events are excluded because repeated // edits / re-fires are legitimate for those entity-event combinations. t @@ -88,6 +89,7 @@ export const QBSyncLog = table( sql`${table.deletedAt} IS NULL AND ( (${table.entityType} = 'invoice' AND ${table.eventType} IN ('created','paid','voided','deleted')) OR (${table.entityType} = 'payment' AND ${table.eventType} = 'succeeded') + OR (${table.entityType} = 'payout' AND ${table.eventType} = 'settled') )`, ), ], diff --git a/src/db/service/syncLog.service.ts b/src/db/service/syncLog.service.ts new file mode 100644 index 00000000..927b0b75 --- /dev/null +++ b/src/db/service/syncLog.service.ts @@ -0,0 +1,36 @@ +'use server' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { and, eq, inArray, isNull } from 'drizzle-orm' + +// Which of these invoices already have a recorded absorbed-fee expense in QBO. +// A SUCCESS PAYMENT/SUCCEEDED row exists only if the fee Purchase was created. +export const getInvoiceNumbersWithRecordedFee = async ( + portalId: string, + invoiceNumbers: string[], +): Promise> => { + if (invoiceNumbers.length === 0) return new Set() + + const rows = await db + .select({ invoiceNumber: QBSyncLog.invoiceNumber }) + .from(QBSyncLog) + .where( + and( + eq(QBSyncLog.portalId, portalId), + eq(QBSyncLog.entityType, EntityType.PAYMENT), + eq(QBSyncLog.eventType, EventType.SUCCEEDED), + eq(QBSyncLog.status, LogStatus.SUCCESS), + inArray(QBSyncLog.invoiceNumber, invoiceNumbers), + isNull(QBSyncLog.deletedAt), + ), + ) + + return new Set( + rows + .map((row) => row.invoiceNumber) + .filter((invoiceNumber): invoiceNumber is string => + Boolean(invoiceNumber), + ), + ) +} diff --git a/src/db/service/token.service.ts b/src/db/service/token.service.ts index 38d986f6..507660ea 100644 --- a/src/db/service/token.service.ts +++ b/src/db/service/token.service.ts @@ -122,5 +122,6 @@ export const getPortalTokens = async ( assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, + bankAccountRef: portalConnection.bankAccountRef, } } diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index 4505321b..ce56727d 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -86,7 +86,7 @@ export const useProductMappingSettings = () => { if (!productSetting || !intialSettingState) return const showButton = !equal(intialSettingState, productSetting) setSettingShowConfirm(showButton) - }, [productSetting]) + }, [productSetting, intialSettingState]) useEffect(() => { if (setting && setting?.setting) { @@ -104,7 +104,7 @@ export const useProductMappingSettings = () => { false, })) } - }, [setting]) + }, [setting, setAppParams]) // End of checkbox settings const tableMappingSubmit = async () => { @@ -287,17 +287,18 @@ function formatQBItemForListing( : undefined } +const emptyMappedItem = { + name: null, + description: '', + productId: null, + qbItemId: null, + qbSyncToken: null, + isExcluded: true, +} + export const useProductTableSetting = ( setMappingItems: (mapProducts: ProductMappingItemType[]) => void, ) => { - const emptyMappedItem = { - name: null, - description: '', - productId: null, - qbItemId: null, - qbSyncToken: null, - isExcluded: true, - } const { token, setAppParams, syncFlag } = useApp() const { data: products } = useSwrHelper( `/api/quickbooks/product/flatten?token=${token}`, @@ -366,7 +367,7 @@ export const useProductTableSetting = ( } setMappingItems(newMap) } - }, [products, mappedItems, quickbooksItems]) + }, [products, mappedItems, quickbooksItems, setAppParams, setMappingItems]) const handleCopilotProductCreate = () => { const payload = { @@ -385,9 +386,16 @@ export const useProductTableSetting = ( } }, [products]) + // Memoized so its reference is stable across unrelated re-renders — + // downstream useMapItem depends on this list. + const formattedQuickbooksItems = useMemo( + () => formatQBItemForListing(quickbooksItems), + [quickbooksItems], + ) + return { products: formattedProducts, - quickbooksItems: formatQBItemForListing(quickbooksItems), + quickbooksItems: formattedQuickbooksItems, handleCopilotProductCreate, hasLongProductName, } @@ -401,28 +409,18 @@ export const useMapItem = ( const [currentlyMapped, setCurrentlyMapped] = useState< { name: string } | undefined >() - const checkIfMappedItemExists = () => { - const currentMapItem = mappingItems?.find((item) => { - return item.productId === productId && item.qbItemId - }) - const currentQbItem = qbItems?.find((item) => { - return item.id === currentMapItem?.qbItemId - }) - - let itemToReturn: { name: string } | undefined - const itemName = currentQbItem?.name || currentMapItem?.name - - if (itemName) { - itemToReturn = { name: itemName } - } - - setCurrentlyMapped(itemToReturn) - return itemToReturn - } useEffect(() => { - if (mappingItems) checkIfMappedItemExists() - }, [mappingItems]) + if (!mappingItems) return + const currentMapItem = mappingItems.find( + (item) => item.productId === productId && item.qbItemId, + ) + const currentQbItem = qbItems?.find( + (item) => item.id === currentMapItem?.qbItemId, + ) + const itemName = currentQbItem?.name || currentMapItem?.name + setCurrentlyMapped(itemName ? { name: itemName } : undefined) + }, [mappingItems, productId, qbItems]) return { currentlyMapped, @@ -432,13 +430,19 @@ export const useMapItem = ( export const useInvoiceDetailSettings = () => { const initialInvoiceSetting = { absorbedFeeFlag: false, + bankDepositFeeFlag: false, useCompanyNameFlag: false, + bankAccountRef: '', } - const { token, setAppParams } = useApp() + const { token, setAppParams, syncFlag, portalConnectionStatus } = useApp() + // Skip the /bank-account fetch when QB isn't connected or sync is off, same + // rationale as useAccountMapping's isDisconnected. + const isDisconnected = !syncFlag || !portalConnectionStatus const [settingState, setSettingState] = useState( initialInvoiceSetting, ) const [showButton, setShowButton] = useState(false) + const [showBankDepositWarning, setShowBankDepositWarning] = useState(false) const [intialSettingState, setIntialSettingState] = useState< InvoiceSettingType | undefined >() @@ -448,26 +452,48 @@ export const useInvoiceDetailSettings = () => { isLoading, } = useSwrHelper(`/api/quickbooks/setting?type=invoice&token=${token}`) - const changeSettings = async ( - flag: keyof InvoiceSettingType, - state: boolean, - ) => { - setSettingState((prev) => ({ - ...prev, - [flag]: state, + // AB gate from the settings GET; hides the bank deposit UI when off. + const bankDepositEnabled = setting?.bankDepositEnabled ?? false + + const { data: bankAccountsData, error: bankAccountsError } = useSwrHelper<{ + accounts: { Id: string; Name: string }[] + }>( + isDisconnected || !bankDepositEnabled + ? null + : `/api/quickbooks/setting/bank-account?token=${token}`, + { suspense: false, revalidateOnMount: true }, + ) + const bankAccountOptions: AccountOption[] | undefined = + bankAccountsData?.accounts.map((account) => ({ + id: account.Id, + name: account.Name, })) + + const changeSettings = async ( + flag: K, + value: InvoiceSettingType[K], + ) => { + setSettingState((prev) => ({ ...prev, [flag]: value })) } + const canSave = !( + settingState.bankDepositFeeFlag && !settingState.bankAccountRef + ) + useEffect(() => { if (!settingState || !intialSettingState) return const showButton = !equal(intialSettingState, settingState) setShowButton(showButton) - }, [settingState]) + }, [settingState, intialSettingState]) useEffect(() => { if (setting && setting?.setting) { - setSettingState(setting.setting) - setIntialSettingState(structuredClone(setting.setting)) + const hydratedSetting = { + ...setting.setting, + bankAccountRef: setting.bankAccountRef ?? '', + } + setSettingState(hydratedSetting) + setIntialSettingState(structuredClone(hydratedSetting)) setAppParams((prev) => ({ ...prev, initialInvoiceSettingMapFlag: setting.setting.initialInvoiceSettingMap, @@ -477,7 +503,7 @@ export const useInvoiceDetailSettings = () => { setting.setting.initialProductSettingMap, })) } - }, [setting]) + }, [setting, setAppParams]) const submitInvoiceSettings = async () => { setShowButton(false) @@ -500,14 +526,41 @@ export const useInvoiceDetailSettings = () => { setSettingState(intialSettingState || initialInvoiceSetting) } + // Warn only when the bank-deposit flag actually changed vs the saved value. + const bankDepositFlagChanged = + !!intialSettingState && + settingState.bankDepositFeeFlag !== intialSettingState.bankDepositFeeFlag + + const requestInvoiceSettingsSave = () => { + if (bankDepositFlagChanged) { + setShowBankDepositWarning(true) + return + } + submitInvoiceSettings() + } + + const confirmBankDepositChange = () => { + setShowBankDepositWarning(false) + submitInvoiceSettings() + } + + const cancelBankDepositChange = () => setShowBankDepositWarning(false) + return { settingState, changeSettings, - submitInvoiceSettings, cancelInvoiceSettings, error, isLoading, showButton, + bankDepositEnabled, + bankAccountOptions, + bankAccountsError, + canSave, + showBankDepositWarning, + requestInvoiceSettingsSave, + confirmBankDepositChange, + cancelBankDepositChange, } } diff --git a/src/type/common.ts b/src/type/common.ts index 252b42f2..11e90c0a 100644 --- a/src/type/common.ts +++ b/src/type/common.ts @@ -276,6 +276,8 @@ export const SettingRequestSchema = z id: z.string().optional(), type: z.nativeEnum(SettingType), absorbedFeeFlag: z.boolean().optional(), + bankDepositFeeFlag: z.boolean().optional(), + bankAccountRef: z.string().nullable().optional(), useCompanyNameFlag: z.boolean().optional(), createNewProductFlag: z.boolean().optional(), }) @@ -288,6 +290,21 @@ export const SettingRequestSchema = z message: 'absorbedFeeFlag is required when type is invoice', }) } + if (typeof val.bankDepositFeeFlag !== 'boolean') { + ctx.addIssue({ + path: ['bankDepositFeeFlag'], + code: z.ZodIssueCode.custom, + message: 'bankDepositFeeFlag is required when type is invoice', + }) + } + if (val.bankDepositFeeFlag === true && !val.bankAccountRef) { + ctx.addIssue({ + path: ['bankAccountRef'], + code: z.ZodIssueCode.custom, + message: + 'bankAccountRef is required when bankDepositFeeFlag is enabled', + }) + } if (typeof val.useCompanyNameFlag !== 'boolean') { ctx.addIssue({ path: ['useCompanyNameFlag'], @@ -310,8 +327,11 @@ export const SettingRequestSchema = z export type SettingRequestType = z.infer export type InvoiceSettingType = Required< - Pick -> & { id?: string } + Pick< + SettingRequestType, + 'absorbedFeeFlag' | 'bankDepositFeeFlag' | 'useCompanyNameFlag' + > +> & { id?: string; bankAccountRef?: string | null } export type ProductSettingType = Required< Pick diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index f87d770d..baefecb1 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -147,6 +147,11 @@ export const QBPaymentCreatePayloadSchema = z.object({ CustomerRef: z.object({ value: z.string(), }), + DepositToAccountRef: z + .object({ + value: z.string(), + }) + .optional(), Line: z.array( z.object({ Amount: z.number(), @@ -240,6 +245,63 @@ export type QBPurchaseCreatePayloadType = z.infer< typeof QBPurchaseCreatePayloadSchema > +export const QBDepositLineSchema = z.union([ + z.object({ + Amount: z.number(), + LinkedTxn: z.array( + z.object({ + TxnId: z.string(), + TxnType: z.literal('Payment'), + TxnLineId: z.string(), + }), + ), + }), + z.object({ + Amount: z.number(), + DetailType: z.literal('DepositLineDetail'), + DepositLineDetail: z.object({ + AccountRef: QBNameValueSchema, + }), + Description: z.string().optional(), + }), +]) + +export const QBDepositCreatePayloadSchema = z.object({ + DepositToAccountRef: z.object({ + value: z.string(), + }), + PrivateNote: z.string().optional(), + TxnDate: z.string(), + Line: z.array(QBDepositLineSchema), +}) + +export type QBDepositCreatePayloadType = z.infer< + typeof QBDepositCreatePayloadSchema +> + +export const QBDepositResponseSchema = z.object({ + Deposit: z.object({ + Id: z.string(), + SyncToken: z.string().optional(), + }), +}) +export type QBDepositResponseType = z.infer + +export const QBDepositQueryResponseSchema = z.object({ + Deposit: z + .array( + z.object({ + Id: z.string(), + PrivateNote: z.string().optional(), + TxnDate: z.string().optional(), + }), + ) + .optional(), +}) +export type QBDepositQueryResponseType = z.infer< + typeof QBDepositQueryResponseSchema +> + export const QBDeletePayloadSchema = z.object({ SyncToken: z.string(), Id: z.string(), diff --git a/src/type/dto/webhook.dto.ts b/src/type/dto/webhook.dto.ts index 8e2c3f56..c2a5c7e6 100644 --- a/src/type/dto/webhook.dto.ts +++ b/src/type/dto/webhook.dto.ts @@ -1,4 +1,5 @@ import { InvoiceStatus, PaymentStatus } from '@/app/api/core/types/invoice' +import { WebhookEvents } from '@/app/api/core/types/webhook' import { ProductStatus } from '@/app/api/core/types/product' import { z } from 'zod' @@ -133,3 +134,28 @@ export const PaymentSucceededResponseSchema = z.object({ export type PaymentSucceededResponseType = z.infer< typeof PaymentSucceededResponseSchema > + +export const PayoutLineItemSchema = z.object({ + copilotInvoiceId: z.string(), + grossAmount: z.number(), + feeAmount: z.number(), +}) +export type PayoutLineItem = z.infer + +export const PayoutReconciliationCompletedSchema = z.object({ + eventType: z.literal(WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED), + eventTime: z.string().optional(), + data: z.object({ + payout: z.object({ + id: z.string(), + arrivalDate: z.number(), + currency: z.string().optional(), + netAmount: z.number(), + status: z.string(), + }), + lineItems: z.array(PayoutLineItemSchema).min(1), + }), +}) +export type PayoutReconciliationCompletedType = z.infer< + typeof PayoutReconciliationCompletedSchema +> diff --git a/src/utils/abTesting.ts b/src/utils/abTesting.ts new file mode 100644 index 00000000..2a64c5cb --- /dev/null +++ b/src/utils/abTesting.ts @@ -0,0 +1,11 @@ +import { abFeatureTestingPortals } from '@/config' + +/** + * Whether a portal may use the bank deposit feature during its incremental + * rollout. An empty/unset allowlist means the feature is on for all portals; + * otherwise only listed portals get it. + */ +export function isPortalInBankDepositABTest(portalId: string): boolean { + if (abFeatureTestingPortals.length === 0) return true + return abFeatureTestingPortals.includes(portalId) +} diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index a48b84e3..10470bf7 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -13,6 +13,10 @@ import { QBPaymentCreatePayloadType, QBAccountCreatePayloadType, QBPurchaseCreatePayloadType, + QBDepositCreatePayloadType, + QBDepositResponseSchema, + QBDepositResponseType, + QBDepositQueryResponseSchema, QBDeletePayloadType, QBDestructiveInvoicePayloadSchema, QBItemRowType, @@ -64,6 +68,7 @@ export type IntuitAPITokensType = Pick< | 'assetAccountRef' | 'serviceItemRef' | 'clientFeeRef' + | 'bankAccountRef' > & { isSuspended?: boolean } export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#' @@ -976,6 +981,68 @@ export default class IntuitAPI { return parsed } + async _createDeposit( + payload: QBDepositCreatePayloadType, + ): Promise { + CustomLogger.info({ + obj: { payload }, + message: `IntuitAPI#createDeposit | Deposit create start for realmId: ${this.tokens.intuitRealmId}.`, + }) + const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/deposit?minorversion=${intuitApiMinorVersion}` + const deposit = await this.postFetchWithHeaders(url, payload) + + if (!deposit) + throw new APIError( + httpStatus.BAD_REQUEST, + 'IntuitAPI#createDeposit | message = no response', + ) + + assertNotQBFault(deposit, 'createDeposit') + + const parsed = QBDepositResponseSchema.parse(deposit) + CustomLogger.info({ + obj: { response: parsed.Deposit }, + message: `IntuitAPI#createDeposit | Deposit created with Id = ${parsed.Deposit.Id}.`, + }) + return parsed + } + + // Read all pages so we don't miss a deposit on a busy day. Miss one and + // resync makes a duplicate deposit that QBO won't let us delete. maxPages is + // just a safety cap — hitting it would need 50k deposits in a single day. + async _getDepositsByTxnDate( + txnDate: string, + ): Promise> { + CustomLogger.info({ + obj: { txnDate }, + message: `IntuitAPI#getDepositsByTxnDate | start for realmId: ${this.tokens.intuitRealmId}.`, + }) + + const pageSize = 1000 + const maxPages = 50 + const deposits: Array<{ Id: string; PrivateNote?: string }> = [] + let startPosition = 1 + + for (let pages = 0; pages < maxPages; pages++) { + const query = `select Id, PrivateNote, TxnDate from Deposit where TxnDate = '${escapeForQBQuery(txnDate)}' STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}` + const response = await this.customQuery(query) + if (!response) return deposits + + const envelope = QBDepositQueryResponseSchema.parse(response) + const page = envelope.Deposit ?? [] + deposits.push(...page) + + if (page.length < pageSize) return deposits + startPosition += pageSize + } + + CustomLogger.error({ + obj: { txnDate, maxPages }, + message: `IntuitAPI#getDepositsByTxnDate | pagination cap (${maxPages} pages) hit for realmId: ${this.tokens.intuitRealmId} — result truncated at ${deposits.length} deposits.`, + }) + return deposits + } + async _deletePurchase( payload: QBDeletePayloadType, ): Promise { @@ -1016,6 +1083,47 @@ export default class IntuitAPI { return parsedCompanyInfo.CompanyInfo[0] } + /** + * Look up the QBO system "Undeposited Funds" account. + * Every QBO company has exactly one — it cannot be deleted or recreated. + * Queries by AccountSubType first (survives user renames), falls back to name. + */ + async getUndepositedFundsAccountId(): Promise { + CustomLogger.info({ + message: + 'IntuitAPI#getUndepositedFundsAccountId | Looking up Undeposited Funds account', + }) + const rawResult = await this.customQuery( + `SELECT ${QB_ACCOUNT_COLUMNS.join(', ')} FROM Account WHERE AccountSubType = 'UndepositedFunds' AND Active = true maxresults 1`, + ) + const undepositedAccount = QBAccountQueryResponseSchema.parse( + rawResult ?? {}, + ).Account?.[0] + if (undepositedAccount?.Id) { + CustomLogger.info({ + obj: { account: undepositedAccount }, + message: + 'IntuitAPI#getUndepositedFundsAccountId | Found Undeposited Funds account', + }) + return undepositedAccount.Id + } + + const byName = await this.getAnAccount('Undeposited Funds') + if (byName?.Id) { + CustomLogger.info({ + obj: { account: byName }, + message: + 'IntuitAPI#getUndepositedFundsAccountId | Found Undeposited Funds account by name', + }) + return byName.Id + } + + throw new APIError( + httpStatus.INTERNAL_SERVER_ERROR, + 'IntuitAPI#getUndepositedFundsAccountId | Undeposited Funds account not found in QuickBooks', + ) + } + private wrapWithRetry( fn: (...args: Args) => Promise, options?: RetryOptions, @@ -1062,5 +1170,7 @@ export default class IntuitAPI { createPurchase = this.wrapWithRetry(this._createPurchase) deletePayment = this.wrapWithRetry(this._deletePayment) deletePurchase = this.wrapWithRetry(this._deletePurchase) + createDeposit = this.wrapWithRetry(this._createDeposit) + getDepositsByTxnDate = this._getDepositsByTxnDate.bind(this) getCompanyInfo = this._getCompanyInfo.bind(this) } diff --git a/src/utils/tokenRefresh.ts b/src/utils/tokenRefresh.ts index d367f36f..dac1ac17 100644 --- a/src/utils/tokenRefresh.ts +++ b/src/utils/tokenRefresh.ts @@ -60,6 +60,7 @@ function extractTokens( assetAccountRef: row.assetAccountRef, serviceItemRef: row.serviceItemRef, clientFeeRef: row.clientFeeRef, + bankAccountRef: row.bankAccountRef, } } @@ -161,6 +162,7 @@ export async function getRefreshedQbTokenInfo( assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, + bankAccountRef: portalConnection.bankAccountRef, } const updatedPayload: QBPortalConnectionUpdateSchemaType = { diff --git a/test/fixtures/payout.webhook.ts b/test/fixtures/payout.webhook.ts new file mode 100644 index 00000000..f262618d --- /dev/null +++ b/test/fixtures/payout.webhook.ts @@ -0,0 +1,25 @@ +import { WebhookEvents } from '@/app/api/core/types/webhook' +import { TEST_COPILOT_INVOICE_ID } from '@test/helpers/seed' + +// Two invoices: $200.00 + $150.00 gross, $3.75 + $2.00 fees → net $344.25 (34425 cents) +export const payoutPayload = { + eventType: WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED, + eventTime: '1713744000', + data: { + payout: { + id: 'po_test_1', + arrivalDate: 1713744000, // 2024-04-22 + currency: 'usd', + netAmount: 34425, + status: 'paid', + }, + lineItems: [ + { + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + grossAmount: 20000, + feeAmount: 375, + }, + { copilotInvoiceId: 'inv-cop-0002', grossAmount: 15000, feeAmount: 200 }, + ], + }, +} diff --git a/test/helpers/abTestGate.ts b/test/helpers/abTestGate.ts new file mode 100644 index 00000000..5749f73b --- /dev/null +++ b/test/helpers/abTestGate.ts @@ -0,0 +1,17 @@ +// Drives the bank-deposit AB gate mocked in test/integration/setup.ts. The mock +// reads its allowlist from a globalThis-pinned holder (the real allowlist is +// env-parsed at module load and can't be varied per-test). `null` = feature on +// for all portals. Always reset in afterEach so state doesn't leak across files. +const AB_GATE_GLOBAL_KEY = '__qbsync_ab_test_gate__' +type ABGate = { allowlist: string[] | null } +const ref = globalThis as unknown as Record +ref[AB_GATE_GLOBAL_KEY] ??= { allowlist: null } + +export const abTestGate = { + setAllowlist(portalIds: string[] | null) { + ref[AB_GATE_GLOBAL_KEY]!.allowlist = portalIds + }, + reset() { + ref[AB_GATE_GLOBAL_KEY]!.allowlist = null + }, +} diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index b11d0d79..676256a3 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -10,6 +10,7 @@ import { TEST_QB_PURCHASE_ID, TEST_QB_PAYMENT_ID, TEST_QB_INVOICE_ID, + TEST_UNDEPOSITED_FUNDS_REF, } from './seed' // Restricts override keys to the actual method names of the underlying class @@ -70,6 +71,9 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { id: TEST_COPILOT_INVOICE_ID, number: TEST_INVOICE_NUMBER, }), + // Deferred SyncErrorNotifier dispatch calls these; empty IU list = no-op. + getInternalUsers: vi.fn().mockResolvedValue({ data: [] }), + createNotification: vi.fn().mockResolvedValue({ id: 'notif-1' }), ...overrides, } } @@ -130,6 +134,16 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { createPayment: vi.fn().mockResolvedValue({ Payment: { Id: TEST_QB_PAYMENT_ID, SyncToken: '0' }, }), + // Batched-deposit routing looks this up when the frozen intent is batched. + getUndepositedFundsAccountId: vi + .fn() + .mockResolvedValue(TEST_UNDEPOSITED_FUNDS_REF), + // payout.reconciliation_completed sweeps the batched payments into a deposit. + createDeposit: vi.fn().mockResolvedValue({ + Deposit: { Id: 'qb-deposit-1', SyncToken: '0' }, + }), + // Payout resync checks for an existing deposit first — none by default. + getDepositsByTxnDate: vi.fn().mockResolvedValue([]), // Handler ignores the response; it just needs the call to succeed (OUT-3921). voidInvoice: vi.fn().mockResolvedValue({ Invoice: { Id: TEST_QB_INVOICE_ID, SyncToken: '1' }, diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index 8d5c9d63..dbfbf089 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -10,6 +10,7 @@ import { QBSetting, QBSettingCreateSchema } from '@/db/schema/qbSettings' import { QBCustomers } from '@/db/schema/qbCustomers' import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync' import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { QBPayoutSync } from '@/db/schema/qbPayoutSync' import { InvoiceStatus } from '@/app/api/core/types/invoice' import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' @@ -20,6 +21,8 @@ export const TEST_REFRESH_TOKEN = 'test-refresh-token' export const TEST_INCOME_ACCOUNT_REF = '100' export const TEST_ASSET_ACCOUNT_REF = '101' export const TEST_EXPENSE_ACCOUNT_REF = '102' +export const TEST_BANK_ACCOUNT_REF = '103' +export const TEST_UNDEPOSITED_FUNDS_REF = '150' export const TEST_INTERNAL_USER_ID = 'test-internal-user-id' export const TEST_WEBHOOK_TOKEN = 'test-token-xyz' @@ -185,3 +188,76 @@ export async function seedInvoiceCreatedLog(overrides: SyncLogOverrides = {}) { .returning() return row } + +/** + * Seeds the two rows a payout needs per invoice: the qb_invoice_sync row + * (carrying the frozen `isBatchedDeposit`) and the INVOICE/PAID SUCCESS sync + * log (quickbooksId = QBO Payment ID). getSuccessfulPaidPaymentIds joins them + * by (portalId, invoiceNumber). + */ +export async function seedPaidInvoiceForPayout(opts: { + copilotInvoiceId: string + invoiceNumber: string + paymentId: string + isBatchedDeposit: boolean +}) { + await db.insert(QBInvoiceSync).values({ + portalId: TEST_PORTAL_ID, + invoiceNumber: opts.invoiceNumber, + qbInvoiceId: TEST_QB_INVOICE_ID, + qbSyncToken: '0', + recipientId: TEST_CLIENT_ID, + status: InvoiceStatus.PAID, + isBatchedDeposit: opts.isBatchedDeposit, + }) + await db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + copilotId: opts.copilotInvoiceId, + invoiceNumber: opts.invoiceNumber, + quickbooksId: opts.paymentId, + }) +} + +// A failed, retryable payout sync log plus its qb_payout_sync row — +// the state the resync cron picks up. +export async function seedFailedPayout(opts: { + payoutId: string + lineItems: Array<{ + copilotInvoiceId: string + grossAmount: number + feeAmount: number + }> + netAmount: number + feeCents: number + arrivalDate: number + qbDepositId?: string + errorMessage?: string +}) { + // Independent tables, no FK between them — insert both at once. + await Promise.all([ + db.insert(QBPayoutSync).values({ + portalId: TEST_PORTAL_ID, + payoutId: opts.payoutId, + lineItems: opts.lineItems, + netAmount: opts.netAmount, + feeAmount: opts.feeCents, + arrivalDate: opts.arrivalDate, + qbDepositId: opts.qbDepositId ?? null, + }), + db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + copilotId: opts.payoutId, + // Cents-as-string, matching what the webhook writes for a payout log. + amount: opts.netAmount.toFixed(2), + feeAmount: opts.feeCents.toFixed(2), + errorMessage: opts.errorMessage ?? 'QuickBooks timed out', + shouldRetry: true, + }), + ]) +} diff --git a/test/helpers/testDb.ts b/test/helpers/testDb.ts index d6d88645..dd623c5c 100644 --- a/test/helpers/testDb.ts +++ b/test/helpers/testDb.ts @@ -20,7 +20,8 @@ export async function truncateAllTestTables() { qb_payment_sync, qb_product_sync, qb_settings, - qb_portal_connections + qb_portal_connections, + qb_payout_sync RESTART IDENTITY CASCADE `) } diff --git a/test/integration/globalSetup.ts b/test/integration/globalSetup.ts index 4397f811..268fc37e 100644 --- a/test/integration/globalSetup.ts +++ b/test/integration/globalSetup.ts @@ -6,8 +6,8 @@ import { StartedPostgreSqlContainer, } from '@testcontainers/postgresql' import { drizzle } from 'drizzle-orm/postgres-js' -import { migrate } from 'drizzle-orm/postgres-js/migrator' import postgres from 'postgres' +import { migratePerFile } from '@/db/migratePerFile' /** * Vitest globalSetup for integration tests. @@ -15,7 +15,8 @@ import postgres from 'postgres' * Responsibilities: * - Start an ephemeral Postgres container via testcontainers * - Set process.env.DATABASE_URL before any test worker imports src/config - * - Apply all Drizzle migrations from src/db/migrations to the fresh DB + * - Apply all Drizzle migrations from src/db/migrations to the fresh DB, one + * file per transaction (see `migratePerFile` for why) * - Stub any src/config env vars that must be non-empty at import time * - Stop the container on teardown * @@ -53,7 +54,7 @@ export default async function globalSetup() { const migrationClient = postgres(url, { max: 1, prepare: false }) const migrationDb = drizzle(migrationClient) try { - await migrate(migrationDb, { migrationsFolder: MIGRATIONS_FOLDER }) + await migratePerFile(migrationDb, MIGRATIONS_FOLDER) } finally { await migrationClient.end() } diff --git a/test/integration/quickbooks/invoiceCreated/abTestingGate.test.ts b/test/integration/quickbooks/invoiceCreated/abTestingGate.test.ts new file mode 100644 index 00000000..c02c8453 --- /dev/null +++ b/test/integration/quickbooks/invoiceCreated/abTestingGate.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { db } from '@/db' +import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync' +import invoiceCreatedPayload from '@test/fixtures/invoiceCreated.webhook' +import { + seedHealthyPortal, + seedProductSync, + TEST_PORTAL_ID, +} from '@test/helpers/seed' +import { setupInvoiceCreatedTest } from '@test/helpers/invoiceCreatedTestSetup' +import { postWebhook } from '@test/helpers/webhook' +import { abTestGate } from '@test/helpers/abTestGate' + +// The freeze gate must win over the stored flag: a portal outside the AB +// allowlist freezes non-batched even with bankDepositFeeFlag=true, so the whole +// downstream payout/deposit path never engages for it. +describe('POST /api/quickbooks/webhook — invoice.created AB gate on batched intent', () => { + setupInvoiceCreatedTest() + + afterEach(() => { + abTestGate.reset() + }) + + it('freezes non-batched for a portal outside the allowlist despite the flag being on', async () => { + abTestGate.setAllowlist(['some-other-portal']) + await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } }) + await seedProductSync() + + await postWebhook(invoiceCreatedPayload) + + const [row] = await db.select().from(QBInvoiceSync) + expect(row.isBatchedDeposit).toBe(false) + }) + + it('freezes batched for a portal on the allowlist with the flag on', async () => { + abTestGate.setAllowlist([TEST_PORTAL_ID]) + await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } }) + await seedProductSync() + + await postWebhook(invoiceCreatedPayload) + + const [row] = await db.select().from(QBInvoiceSync) + expect(row.isBatchedDeposit).toBe(true) + }) +}) diff --git a/test/integration/quickbooks/invoiceCreated/freezeBatchedIntent.test.ts b/test/integration/quickbooks/invoiceCreated/freezeBatchedIntent.test.ts new file mode 100644 index 00000000..665009bf --- /dev/null +++ b/test/integration/quickbooks/invoiceCreated/freezeBatchedIntent.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { db } from '@/db' +import { QBInvoiceSync } from '@/db/schema/qbInvoiceSync' +import invoiceCreatedPayload from '@test/fixtures/invoiceCreated.webhook' +import { seedHealthyPortal, seedProductSync } from '@test/helpers/seed' +import { setupInvoiceCreatedTest } from '@test/helpers/invoiceCreatedTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — invoice.created freezes batched-deposit intent', () => { + setupInvoiceCreatedTest() + + it('stores is_batched_deposit=true when the flag is on at creation', async () => { + await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } }) + await seedProductSync() + await postWebhook(invoiceCreatedPayload) + const [row] = await db.select().from(QBInvoiceSync) + expect(row.isBatchedDeposit).toBe(true) + }) + + it('stores is_batched_deposit=false when the flag is off at creation', async () => { + await seedHealthyPortal({ setting: { bankDepositFeeFlag: false } }) + await seedProductSync() + await postWebhook(invoiceCreatedPayload) + const [row] = await db.select().from(QBInvoiceSync) + expect(row.isBatchedDeposit).toBe(false) + }) +}) 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/invoiceCreated/statusPaidDepositRouting.test.ts b/test/integration/quickbooks/invoiceCreated/statusPaidDepositRouting.test.ts new file mode 100644 index 00000000..f79a6aad --- /dev/null +++ b/test/integration/quickbooks/invoiceCreated/statusPaidDepositRouting.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' + +import invoiceCreatedPayload from '@test/fixtures/invoiceCreated.webhook' +import { + seedHealthyPortal, + seedProductSync, + TEST_UNDEPOSITED_FUNDS_REF, +} from '@test/helpers/seed' +import { setupInvoiceCreatedTest } from '@test/helpers/invoiceCreatedTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — invoice.created (paid-on-create) deposit routing', () => { + const apis = setupInvoiceCreatedTest() + + const paidPayload = { + ...invoiceCreatedPayload, + data: { ...invoiceCreatedPayload.data, status: 'paid' }, + } + + it('routes the payment to Undeposited Funds when the flag is on at creation', async () => { + await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } }) + await seedProductSync() + + const res = await postWebhook(paidPayload) + expect(res.status).toBe(200) + + const [paymentPayload] = apis.intuit.createPayment.mock.calls[0] + expect(paymentPayload.DepositToAccountRef).toEqual({ + value: TEST_UNDEPOSITED_FUNDS_REF, + }) + expect(apis.intuit.getUndepositedFundsAccountId).toHaveBeenCalledTimes(1) + }) + + it('leaves DepositToAccountRef unset when the flag is off at creation', async () => { + await seedHealthyPortal({ setting: { bankDepositFeeFlag: false } }) + await seedProductSync() + + const res = await postWebhook(paidPayload) + expect(res.status).toBe(200) + + const [paymentPayload] = apis.intuit.createPayment.mock.calls[0] + expect(paymentPayload.DepositToAccountRef).toBeUndefined() + expect(apis.intuit.getUndepositedFundsAccountId).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts b/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts new file mode 100644 index 00000000..b3e96c9d --- /dev/null +++ b/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' + +import { invoicePaidPayload } from '@test/fixtures/invoicePaid.webhook' +import { + seedHealthyPortal, + seedQBCustomer, + seedQBInvoiceSync, + seedInvoiceCreatedLog, + TEST_UNDEPOSITED_FUNDS_REF, +} from '@test/helpers/seed' +import { setupInvoicePaidTest } from '@test/helpers/invoicePaidTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — invoice.paid routes off the frozen intent, not the live flag', () => { + const apis = setupInvoicePaidTest() + + it('routes to Undeposited Funds when the row was frozen batched even though the live flag is now off', async () => { + // Live flag is off, but the invoice's frozen intent (set at row creation) is batched. + await seedHealthyPortal({ setting: { bankDepositFeeFlag: false } }) + const customer = await seedQBCustomer() + await seedQBInvoiceSync({ + customerId: customer.id, + isBatchedDeposit: true, + }) + await seedInvoiceCreatedLog() + + const res = await postWebhook(invoicePaidPayload) + expect(res.status).toBe(200) + + const [paymentPayload] = apis.intuit.createPayment.mock.calls[0] + expect(paymentPayload.DepositToAccountRef).toEqual({ + value: TEST_UNDEPOSITED_FUNDS_REF, + }) + }) + + it('omits DepositToAccountRef when the row was frozen non-batched even though the live flag is now on', async () => { + // Live flag is on, but the invoice's frozen intent (set at row creation) is non-batched. + await seedHealthyPortal({ setting: { bankDepositFeeFlag: true } }) + const customer = await seedQBCustomer() + await seedQBInvoiceSync({ + customerId: customer.id, + isBatchedDeposit: false, + }) + await seedInvoiceCreatedLog() + + const res = await postWebhook(invoicePaidPayload) + expect(res.status).toBe(200) + + // No DepositToAccountRef → QBO uses its default account, no Undeposited Funds lookup. + const [paymentPayload] = apis.intuit.createPayment.mock.calls[0] + expect(paymentPayload.DepositToAccountRef).toBeUndefined() + expect(apis.intuit.getUndepositedFundsAccountId).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/invoiceVoided/qbVoidStaleObject.test.ts b/test/integration/quickbooks/invoiceVoided/qbVoidStaleObject.test.ts new file mode 100644 index 00000000..7e53ab66 --- /dev/null +++ b/test/integration/quickbooks/invoiceVoided/qbVoidStaleObject.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest' +import { and, eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' +import { QBOErrorCodes } from '@/constant/intuitErrorCode' +import { HttpFetchError } from '@/utils/error' + +import { invoiceVoidedPayload } from '@test/fixtures/invoiceVoided.webhook' +import { + seedHealthyPortal, + seedQBCustomer, + seedQBInvoiceSync, + seedInvoiceCreatedLog, + TEST_COPILOT_INVOICE_ID, +} from '@test/helpers/seed' +import { createMockIntuitAPI } from '@test/helpers/mocks' +import { setupInvoiceVoidedTest } from '@test/helpers/invoiceVoidedTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +// Invoice voided out-of-band in QBO leaves our row OPEN, so the void hits a +// stale SyncToken → 5010 → FAILED with error_code=5010 (routes to QB_STALE_OBJECT). +describe('POST /api/quickbooks/webhook — invoice.voided (QBO returns 5010 stale object)', () => { + const apis = setupInvoiceVoidedTest(() => ({ + intuit: createMockIntuitAPI({ + voidInvoice: vi.fn().mockRejectedValue( + new HttpFetchError({ + status: 400, + statusText: 'Bad Request', + url: 'https://quickbooks.api.intuit.com/v3/company/realm/invoice', + body: { + Fault: { + Error: [ + { + code: String(QBOErrorCodes.STALE_OBJECT), + Message: 'Stale Object Error', + Detail: + 'Stale Object Error : You and quickbooks-sync were working on this at the same time.', + }, + ], + type: 'ValidationFault', + }, + }, + }), + ), + }), + })) + + it('marks the voided log FAILED with error_code 5010 (routes to QB_STALE_OBJECT)', async () => { + await seedHealthyPortal() + const customer = await seedQBCustomer() + await seedQBInvoiceSync({ customerId: customer.id }) // defaults to OPEN + await seedInvoiceCreatedLog() + + const res = await postWebhook(invoiceVoidedPayload) + expect(res.status).toBe(200) + + const [voidedLog] = await db + .select() + .from(QBSyncLog) + .where( + and( + eq(QBSyncLog.copilotId, TEST_COPILOT_INVOICE_ID), + eq(QBSyncLog.eventType, EventType.VOIDED), + ), + ) + expect(voidedLog.entityType).toBe(EntityType.INVOICE) + expect(voidedLog.status).toBe(LogStatus.FAILED) + // The QBO fault code must survive as error_code to route to QB_STALE_OBJECT. + expect(voidedLog.errorCode).toBe(String(QBOErrorCodes.STALE_OBJECT)) + + expect(apis.intuit.voidInvoice).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts index 37bfd7f5..65cc75ff 100644 --- a/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts +++ b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts @@ -3,7 +3,6 @@ import { eq } from 'drizzle-orm' import { db } from '@/db' import { QBSyncLog } from '@/db/schema/qbSyncLogs' -import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' import { @@ -27,25 +26,21 @@ describe('POST /api/quickbooks/webhook — payment.succeeded (Copilot returns no await seedQBInvoiceSync() const res = await postWebhook(paymentSucceededPayload) - // The not-found throw escapes the inner try/catch (which only wraps the QB - // calls) and propagates to withErrorHandler, which surfaces it as 404. + // The not-found throw happens before the claim (Copilot fetch now runs + // pre-claim so the frozen batched-defer check can read the invoice-sync + // row first) and propagates to withErrorHandler, which surfaces it as 404. expect(res.status).toBe(404) - // No FAILED log is written — the throw happens before the outer catch block - // that writes sync logs for QB-layer errors. The claimed PENDING row is the - // only row in the table. + // No row at all is written — the throw happens before claimWebhookEvent. const logs = await db .select() .from(QBSyncLog) .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) - expect(logs).toHaveLength(1) - expect(logs[0]).toMatchObject({ - entityType: EntityType.PAYMENT, - eventType: EventType.SUCCEEDED, - status: LogStatus.PENDING, - }) + expect(logs).toHaveLength(0) expect(apis.intuit.createPurchase).not.toHaveBeenCalled() expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() + // Throw happens before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() }) }) diff --git a/test/integration/quickbooks/paymentSucceeded/frozenIntentDefer.test.ts b/test/integration/quickbooks/paymentSucceeded/frozenIntentDefer.test.ts new file mode 100644 index 00000000..b1927efc --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/frozenIntentDefer.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' + +import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' +import { seedHealthyPortal, seedQBInvoiceSync } from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payment.succeeded (frozen batched-deposit intent)', () => { + const apis = setupPaymentSucceededTest() + + it('defers with zero rows when the invoice froze batched intent, even though the live flag is now off', async () => { + await seedHealthyPortal({ + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: false }, + }) + await seedQBInvoiceSync({ isBatchedDeposit: true }) + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + expect(await db.select().from(QBSyncLog)).toHaveLength(0) + }) + + it('books the absorbed-fee expense when the invoice froze non-batched intent, even though the live flag is now on', async () => { + await seedHealthyPortal({ + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedQBInvoiceSync({ isBatchedDeposit: false }) + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createPurchase).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/integration/quickbooks/paymentSucceeded/idempotency.test.ts b/test/integration/quickbooks/paymentSucceeded/idempotency.test.ts index 8a7a5a65..d561ca97 100644 --- a/test/integration/quickbooks/paymentSucceeded/idempotency.test.ts +++ b/test/integration/quickbooks/paymentSucceeded/idempotency.test.ts @@ -47,6 +47,8 @@ describe('POST /api/quickbooks/webhook — payment.succeeded (same webhook deliv expect(logs).toHaveLength(1) expect(logs[0].status).toBe(LogStatus.SUCCESS) + // A replay finds the existing SUCCEEDED claim row and short-circuits + // before the sleep + Copilot fetch, so no external call is made. expect(apis.copilot.getInvoice).not.toHaveBeenCalled() expect(apis.intuit.createPurchase).not.toHaveBeenCalled() expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() diff --git a/test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts b/test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts index 81655ba5..0e2b1a9a 100644 --- a/test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts +++ b/test/integration/quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts @@ -36,5 +36,7 @@ describe('POST /api/quickbooks/webhook — payment.succeeded (no local invoice m expect(apis.intuit.createPurchase).not.toHaveBeenCalled() expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() + // Lookup miss throws before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() }) }) diff --git a/test/integration/quickbooks/payoutReconciliation/allNonBatched.test.ts b/test/integration/quickbooks/payoutReconciliation/allNonBatched.test.ts new file mode 100644 index 00000000..3f7f8960 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/allNonBatched.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_COPILOT_INVOICE_ID, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — every invoice froze non-batched', () => { + const apis = setupPaymentSucceededTest() + + it('books no deposit and writes no sync log — skipped before the claim', async () => { + await seedHealthyPortal() + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: false, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: false, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + + // No claim, no audit row — resolved before claiming. + const payoutLogs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.entityType, EntityType.PAYOUT)) + expect(payoutLogs).toHaveLength(0) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts new file mode 100644 index 00000000..acef9ca9 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { createMockIntuitAPI } from '@test/helpers/mocks' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — configured bank account no longer exists in QuickBooks', () => { + const apis = setupPaymentSucceededTest(() => ({ + intuit: createMockIntuitAPI({ + // The bank ref query comes back empty (deleted in QBO); the expense + // ref lookup must still resolve normally. + getAnAccount: vi + .fn() + .mockImplementation(async (_name?: string, id?: string) => { + if (id === TEST_BANK_ACCOUNT_REF) return undefined + return { + Id: id, + Name: 'Sales of Product Income', + SyncToken: '0', + Active: true, + } + }), + }), + })) + + it('aborts the deposit and logs a failed payout/settled entry asking to reselect a bank account', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + // Not permanent: it works again once the user picks a bank account. + shouldRetry: true, + }) + // Pins the abort to restoreAccountRef's Bank-type throw specifically — + // a deleted bank account is never auto-restored/created. + expect(logs[0].errorMessage).toContain('reselect a bank account') + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts b/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts new file mode 100644 index 00000000..404c0134 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { createMockIntuitAPI } from '@test/helpers/mocks' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — configured bank account is inactive in QuickBooks', () => { + const apis = setupPaymentSucceededTest(() => ({ + intuit: createMockIntuitAPI({ + // Only the bank ref comes back inactive; the expense ref (queried in + // the same flow) must stay on the default active response so this + // test exercises the bank-reactivation path specifically. + getAnAccount: vi + .fn() + .mockImplementation(async (_name?: string, id?: string) => { + if (id === TEST_BANK_ACCOUNT_REF) { + return { + Id: TEST_BANK_ACCOUNT_REF, + Name: 'Business Checking', + SyncToken: '0', + Active: false, + } + } + return { + Id: id, + Name: 'Sales of Product Income', + SyncToken: '0', + Active: true, + } + }), + updateAccount: vi.fn().mockResolvedValue({ + Account: { + Id: TEST_BANK_ACCOUNT_REF, + Name: 'Business Checking', + SyncToken: '1', + Active: true, + }, + }), + }), + })) + + it('reactivates the bank account and still creates the deposit', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.updateAccount).toHaveBeenCalledTimes(1) + expect(apis.intuit.updateAccount).toHaveBeenCalledWith( + expect.objectContaining({ + Id: TEST_BANK_ACCOUNT_REF, + SyncToken: '0', + Active: true, + }), + ) + + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + const [depositPayload] = apis.intuit.createDeposit.mock.calls[0] + expect(depositPayload.DepositToAccountRef).toEqual({ + value: TEST_BANK_ACCOUNT_REF, + }) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.SUCCESS, + }) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/claimIdempotency.test.ts b/test/integration/quickbooks/payoutReconciliation/claimIdempotency.test.ts new file mode 100644 index 00000000..76eecd4a --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/claimIdempotency.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' + +import User from '@/app/api/core/models/User.model' +import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' +import { EntityType, EventType } from '@/app/api/core/types/log' + +import { seedHealthyPortal, TEST_PORTAL_ID } from '@test/helpers/seed' +import { truncateAllTestTables } from '@test/helpers/testDb' + +describe('claimWebhookEvent — payout/settled idempotency', () => { + it('claims once and refuses the duplicate', async () => { + await truncateAllTestTables() + await seedHealthyPortal() + + const user = { workspaceId: TEST_PORTAL_ID } as User + const service = new SyncLogService(user) + + const first = await service.claimWebhookEvent({ + copilotId: 'po_test_1', + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + }) + const second = await service.claimWebhookEvent({ + copilotId: 'po_test_1', + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + }) + + expect(first.claimed).toBe(true) + expect(second.claimed).toBe(false) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts b/test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts new file mode 100644 index 00000000..26568d21 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — two line items share the same invoice', () => { + const apis = setupPaymentSucceededTest() + + it('aborts the deposit and logs a failed payout/settled entry', async () => { + // Seed the bank + expense account refs so the handler's missing- + // bankAccountRef guard can't be what trips instead of the guard under test. + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + copilotId: TEST_COPILOT_INVOICE_ID, + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_A', + }) + + // Both line items reference the same invoice. gross (20000 + 375) - fee + // (375 + 50) = 19950, so netAmount is set to 19950 to keep the sum-mismatch + // guard from being what trips instead of the duplicate-line guard under test. + const payloadWithDuplicateInvoice = { + ...payoutPayload, + data: { + ...payoutPayload.data, + payout: { ...payoutPayload.data.payout, netAmount: 19950 }, + lineItems: [ + { + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + grossAmount: 20000, + feeAmount: 375, + }, + { + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + grossAmount: 375, + feeAmount: 50, + }, + ], + }, + } + + const res = await postWebhook(payloadWithDuplicateInvoice) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + // Guard trips before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + // Payout FAILED rows are terminal by design — never retryable. + shouldRetry: false, + }) + // Pins the abort to the duplicate-line guard specifically — not the + // unresolved-line guard, the sum-mismatch guard, or the bankAccountRef guard. + expect(logs[0].errorMessage).toContain('duplicate invoice line items') + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts b/test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts new file mode 100644 index 00000000..f9743366 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — no line items on the payload', () => { + const apis = setupPaymentSucceededTest() + + it('returns 200 without creating a deposit or a payout/settled log', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + + const payloadWithNoLineItems = { + ...payoutPayload, + data: { ...payoutPayload.data, lineItems: [] }, + } + + const res = await postWebhook(payloadWithNoLineItems) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + // Parse fails before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() + + // The schema's `.min(1)` on lineItems fails the safeParse before the + // handler ever calls claimWebhookEvent, so no row is written at all — + // not even a FAILED one. + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(0) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts b/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts new file mode 100644 index 00000000..fa840a58 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (batched deposit)', () => { + const apis = setupPaymentSucceededTest() + + it('creates one deposit with N payment lines + fee line and logs PAYOUT/SETTLED success', async () => { + // Healthy, non-expired token (seed.ts default tokenSetTime/expiresIn) — + // isTokenFresh is true, so getValidQbTokens takes the extractTokens path, + // not the OAuth-refresh path. This is the common production case and + // guards the bankAccountRef regression in tokenRefresh.ts#extractTokens. + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + const [depositPayload] = apis.intuit.createDeposit.mock.calls[0] + expect(depositPayload.DepositToAccountRef).toEqual({ + value: TEST_BANK_ACCOUNT_REF, + }) + expect(depositPayload.TxnDate).toBe('2024-04-22') + expect(depositPayload.Line).toHaveLength(3) // 2 payments + 1 fee + expect(depositPayload.Line[0]).toMatchObject({ + Amount: 200, + LinkedTxn: [{ TxnId: 'qbpay_A', TxnType: 'Payment', TxnLineId: '0' }], + }) + expect(depositPayload.Line[1]).toMatchObject({ + Amount: 150, + LinkedTxn: [{ TxnId: 'qbpay_B', TxnType: 'Payment', TxnLineId: '0' }], + }) + expect(depositPayload.Line[2]).toMatchObject({ + Amount: -5.75, + DepositLineDetail: { AccountRef: { value: TEST_EXPENSE_ACCOUNT_REF } }, + }) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.SUCCESS, + // Deterministic: createBankDepositForPayment returns res.Deposit.Id. + quickbooksId: 'qb-deposit-1', + }) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts b/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts new file mode 100644 index 00000000..3abb6e5f --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — the same webhook is redelivered', () => { + const apis = setupPaymentSucceededTest() + + it('creates the deposit only once and keeps a single payout/settled log', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + const first = await postWebhook(payoutPayload) + expect(first.status).toBe(200) + const second = await postWebhook(payoutPayload) + expect(second.status).toBe(200) + + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.SUCCESS, + }) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/mixed.test.ts b/test/integration/quickbooks/payoutReconciliation/mixed.test.ts new file mode 100644 index 00000000..769356f9 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/mixed.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { and, eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode' +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — invoices froze a mix of batched and non-batched', () => { + const apis = setupPaymentSucceededTest() + + it('rejects the payout without booking a deposit and logs it FAILED (no retry)', async () => { + await seedHealthyPortal({ + portal: { bankAccountRef: TEST_BANK_ACCOUNT_REF }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: false, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + + const [payoutLog] = await db + .select() + .from(QBSyncLog) + .where( + and( + eq(QBSyncLog.entityType, EntityType.PAYOUT), + eq(QBSyncLog.eventType, EventType.SETTLED), + eq(QBSyncLog.copilotId, 'po_test_1'), + ), + ) + expect(payoutLog.status).toBe(LogStatus.FAILED) + expect(payoutLog.shouldRetry).toBe(false) + expect(payoutLog.errorMessage).toContain('mixes batched and non-batched') + // Routable sentinel so SyncErrorNotifier notifies IUs for manual reconciliation. + expect(payoutLog.errorCode).toBe(PAYOUT_MIXED_INTENT_CODE) + // No qbItemName: it would outrank copilotId in the notification's entity + // reference, hiding which payout to reconcile. + expect(payoutLog.qbItemName).toBeNull() + // remark carries the affected invoice numbers so the IU notification can + // name which invoices went unrecorded. + expect(payoutLog.remark).toBe('INV-A, INV-B') + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts b/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts new file mode 100644 index 00000000..e32e893d --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — the total fee across line items is negative', () => { + const apis = setupPaymentSucceededTest() + + it('aborts the deposit and logs a failed payout/settled entry', async () => { + // Seed the bank + expense account refs so the missing-bankAccountRef guard + // can't be what trips instead of the guard under test. + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + // Both invoices resolve, so the unresolved-line guard can't trip either. + await db.insert(QBSyncLog).values([ + { + portalId: TEST_PORTAL_ID, + copilotId: TEST_COPILOT_INVOICE_ID, + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_A', + }, + { + portalId: TEST_PORTAL_ID, + copilotId: 'inv-cop-0002', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_B', + }, + ]) + + // Positive gross on every line (so the refund guard passes), but the + // aggregate fee is negative: 375 + (-1000) = -625. netAmount is set to the + // internally consistent gross - fee (35000 - (-625) = 35625) so it is the + // negative-fee guard — not a sum mismatch — that aborts. + const payloadWithNegativeFee = { + ...payoutPayload, + data: { + ...payoutPayload.data, + payout: { ...payoutPayload.data.payout, netAmount: 35625 }, + lineItems: [ + { + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + grossAmount: 20000, + feeAmount: 375, + }, + { + copilotInvoiceId: 'inv-cop-0002', + grossAmount: 15000, + feeAmount: -1000, + }, + ], + }, + } + + const res = await postWebhook(payloadWithNegativeFee) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + // Guard trips before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + // Payout FAILED rows are terminal by design — never retryable. + shouldRetry: false, + }) + // Pins the abort to the negative-fee guard specifically — not the refund, + // unresolved-line, sum-mismatch, or bankAccountRef guards. + expect(logs[0].errorMessage).toContain('negative aggregate fee (-625)') + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts b/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts new file mode 100644 index 00000000..b4f3ad61 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — a line item is a refund (negative gross amount)', () => { + const apis = setupPaymentSucceededTest() + + it('aborts the deposit and logs a failed payout/settled entry', async () => { + // Seed the bank + expense account refs so the handler's missing- + // bankAccountRef guard can't be what trips instead of the guard under test. + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + // PAID sync logs for all three lines — including the refund line's + // invoice — so every invoice resolves and the unresolved-line guard + // can't be what trips instead of the refund guard. + await db.insert(QBSyncLog).values([ + { + portalId: TEST_PORTAL_ID, + copilotId: TEST_COPILOT_INVOICE_ID, + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_A', + }, + { + portalId: TEST_PORTAL_ID, + copilotId: 'inv-cop-0002', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_B', + }, + { + portalId: TEST_PORTAL_ID, + copilotId: 'inv-cop-0003', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_C', + }, + ]) + + const payloadWithRefund = { + ...payoutPayload, + data: { + ...payoutPayload.data, + payout: { ...payoutPayload.data.payout, netAmount: 29425 }, + lineItems: [ + ...payoutPayload.data.lineItems, + { + copilotInvoiceId: 'inv-cop-0003', + grossAmount: -5000, + feeAmount: 0, + }, + ], + }, + } + + const res = await postWebhook(payloadWithRefund) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + // Guard trips before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + // Payout FAILED rows are terminal by design — never retryable. + shouldRetry: false, + }) + // Pins the abort to the refund guard specifically — not the + // unresolved-line guard, the sum-mismatch guard, or the bankAccountRef guard. + expect(logs[0].errorMessage).toContain('contains refund lines') + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts b/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts new file mode 100644 index 00000000..9b5cd065 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import User from '@/app/api/core/models/User.model' +import { SyncLogService } from '@/app/api/quickbooks/syncLog/syncLog.service' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, +} from '@test/helpers/seed' +import { truncateAllTestTables } from '@test/helpers/testDb' + +describe('SyncLogService.getSuccessfulPaidPaymentIds', () => { + it('returns paymentId + frozen intent only for this portal’s SUCCESS INVOICE/PAID rows', async () => { + await truncateAllTestTables() + await seedHealthyPortal() + + // inv_a: SUCCESS in this portal with a matching invoice-sync row → resolves. + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv_a', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_a', + isBatchedDeposit: true, + }) + // inv_b: FAILED (excluded by status); inv_c: SUCCESS but another portal. + await db.insert(QBSyncLog).values([ + { + portalId: TEST_PORTAL_ID, + copilotId: 'inv_b', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.FAILED, + invoiceNumber: 'INV-B', + quickbooksId: 'qbpay_b', + }, + { + portalId: 'other-portal', + copilotId: 'inv_c', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + invoiceNumber: 'INV-C', + quickbooksId: 'qbpay_c', + }, + ]) + + const user = { workspaceId: TEST_PORTAL_ID } as User + const service = new SyncLogService(user) + + const result = await service.getSuccessfulPaidPaymentIds([ + 'inv_a', + 'inv_b', + 'inv_c', + ]) + + expect(result.get('inv_a')).toEqual({ + paymentId: 'qbpay_a', + isBatchedDeposit: true, + invoiceNumber: 'INV-A', + }) + expect(result.has('inv_b')).toBe(false) // FAILED excluded + expect(result.has('inv_c')).toBe(false) // other portal excluded + expect(result.size).toBe(1) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts b/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts new file mode 100644 index 00000000..754ec8d3 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — reported net amount does not match the line items', () => { + const apis = setupPaymentSucceededTest() + + it('aborts the deposit and logs a failed payout/settled entry', async () => { + // Seed the bank + expense account refs so the handler's missing- + // bankAccountRef guard can't be what trips instead of the guard under test. + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + const payloadWithWrongNetAmount = { + ...payoutPayload, + data: { + ...payoutPayload.data, + payout: { ...payoutPayload.data.payout, netAmount: 99999 }, + }, + } + + const res = await postWebhook(payloadWithWrongNetAmount) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + // Guard trips before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + // Payout FAILED rows are terminal by design — never retryable. + shouldRetry: false, + }) + // Pins the abort to the sum-mismatch guard specifically — not the + // unresolved-line guard, the refund guard, or the bankAccountRef guard. + expect(logs[0].errorMessage).toContain( + 'deposit total 34425 != payout net 99999', + ) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts b/test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts new file mode 100644 index 00000000..e4507f07 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { createMockIntuitAPI } from '@test/helpers/mocks' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout reconciliation — transient failure', () => { + // Registers module mocks; not read directly — this test asserts on + // qb_sync_logs / qb_payout_sync instead of QBO call args. + setupPaymentSucceededTest(() => ({ + intuit: createMockIntuitAPI({ + createDeposit: vi + .fn() + .mockRejectedValue(new Error('QuickBooks timed out')), + }), + })) + + it('marks a QBO write failure retryable and keeps the payout context', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + shouldRetry: true, + }) + expect(logs[0].errorMessage).toContain('QuickBooks timed out') + + const payoutRow = await db.query.QBPayoutSync.findFirst() + expect(payoutRow?.payoutId).toBe('po_test_1') + expect(payoutRow?.qbDepositId).toBeNull() + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts new file mode 100644 index 00000000..50f1d568 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { payoutPayload } from '@test/fixtures/payout.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, + TEST_COPILOT_INVOICE_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — one invoice has no PAID sync log', () => { + const apis = setupPaymentSucceededTest() + + it('aborts the deposit and logs a failed payout/settled entry', async () => { + // Seed the bank + expense account refs so the handler's missing- + // bankAccountRef guard can't be what trips instead of the guard under test. + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + // Only the first invoice is fully synced; inv-cop-0002 has no PAID sync + // log / invoice-sync row, so the handler can't resolve it to a payment id. + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + + const res = await postWebhook(payoutPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + // Guard trips before any QBO round-trip, so account verification never runs. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_test_1')) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL_ID, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + // The invoice.paid event may not have saved yet, so retry. + shouldRetry: true, + }) + // Pins the abort to the unresolved-line guard specifically — not the + // refund guard, the sum-mismatch guard, or the bankAccountRef guard. + expect(logs[0].errorMessage).toContain( + 'no SUCCESS INVOICE/PAID sync log for invoices [inv-cop-0002]', + ) + }) +}) diff --git a/test/integration/quickbooks/payoutResync/payoutServiceReconcile.test.ts b/test/integration/quickbooks/payoutResync/payoutServiceReconcile.test.ts new file mode 100644 index 00000000..aa3042a8 --- /dev/null +++ b/test/integration/quickbooks/payoutResync/payoutServiceReconcile.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect } from 'vitest' +import { and, eq } from 'drizzle-orm' + +import { PayoutService } from '@/app/api/quickbooks/payout/payout.service' +import { + TerminalPayoutError, + MixedPayoutIntentError, +} from '@/app/api/quickbooks/payout/payout.errors' +import { QBPayoutSync } from '@/db/schema/qbPayoutSync' +import { db } from '@/db' +import User from '@/app/api/core/models/User.model' +import { getValidQbTokens } from '@/utils/tokenRefresh' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' + +const user = { workspaceId: TEST_PORTAL_ID } as User + +// No `@test/helpers/tokens` helper exists. AuthService.getQBPortalConnection +// would be the obvious pick, but it transitively imports next/server's +// `after` (auth.service.ts), which corrupts NTARH's AsyncLocalStorage for +// every other postWebhook-based test sharing this worker (isolate: false) — +// the same class of bug test/integration/setup.ts already documents and +// shims for afterIfAvailable. getValidQbTokens is the exact function +// getQBPortalConnection delegates to for a healthy/synced seeded portal +// (our fixtures always have isEnabled/syncFlag true), with no next/server +// in its import graph — a plain DB read, IntuitAPI already mocked. +async function getQBTokens() { + return getValidQbTokens(TEST_PORTAL_ID) +} + +async function insertPayoutRow(overrides = {}) { + const [row] = await db + .insert(QBPayoutSync) + .values({ + portalId: TEST_PORTAL_ID, + payoutId: 'po_test_1', + lineItems: [ + { + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 20000, + feeAmount: 375, + }, + { + copilotInvoiceId: 'inv-cop-0002', + grossAmount: 15000, + feeAmount: 200, + }, + ], + netAmount: 34425, + feeAmount: 575, + arrivalDate: 1713744000, + ...overrides, + }) + .returning() + return row +} + +describe('PayoutService.reconcile', () => { + const apis = setupPaymentSucceededTest() + + async function seedResolvableBatchedPayout() { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + } + + it('creates a deposit for an all-batched payout and persists qbDepositId', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow() + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: false }, + ) + + expect(depositId).toBe('qb-deposit-1') + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + const saved = await db.query.QBPayoutSync.findFirst() + expect(saved?.qbDepositId).toBe('qb-deposit-1') + }) + + it('throws NOT_FOUND (retryable) when an invoice payment is unresolved', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + // Only one of two invoices seeded → the other is unresolved. + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + const row = await insertPayoutRow() + + await expect( + new PayoutService(user).reconcile(row, await getQBTokens(), { + runIdempotencyCheck: false, + }), + ).rejects.toThrow(/no SUCCESS INVOICE\/PAID/) + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) + + it('throws TerminalPayoutError on a sum mismatch', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow({ netAmount: 99999 }) + + await expect( + new PayoutService(user).reconcile(row, await getQBTokens(), { + runIdempotencyCheck: false, + }), + ).rejects.toBeInstanceOf(TerminalPayoutError) + }) + + it('throws MixedPayoutIntentError when intents are mixed', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: false, + }) + const row = await insertPayoutRow() + + await expect( + new PayoutService(user).reconcile(row, await getQBTokens(), { + runIdempotencyCheck: false, + }), + ).rejects.toBeInstanceOf(MixedPayoutIntentError) + }) + + it('returns depositId null when all invoices are non-batched', async () => { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: false }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: false, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: false, + }) + const row = await insertPayoutRow() + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: false }, + ) + expect(depositId).toBeNull() + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) + + it('idempotency: skips creation when qbDepositId is already set', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow({ qbDepositId: 'qb-deposit-existing' }) + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: true }, + ) + expect(depositId).toBe('qb-deposit-existing') + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) + + it('idempotency: reconciles to an existing QBO deposit found by note', async () => { + await seedResolvableBatchedPayout() + const row = await insertPayoutRow() + apis.intuit.getDepositsByTxnDate.mockResolvedValueOnce([ + { Id: 'qb-deposit-found', PrivateNote: 'Stripe payout po_test_1' }, + ]) + + const { depositId } = await new PayoutService(user).reconcile( + row, + await getQBTokens(), + { runIdempotencyCheck: true }, + ) + expect(depositId).toBe('qb-deposit-found') + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + const saved = await db.query.QBPayoutSync.findFirst() + expect(saved?.qbDepositId).toBe('qb-deposit-found') + }) +}) + +describe('PayoutService.upsertPayoutSync', () => { + setupPaymentSucceededTest() + + it('re-delivery with the same payoutId updates the row instead of inserting a duplicate', async () => { + const service = new PayoutService(user) + + // Exercises the ON CONFLICT arbiter directly: the unique index is + // partial (`WHERE deleted_at IS NULL`), so a wrong predicate here throws + // "no unique or exclusion constraint matching the ON CONFLICT specification" + // on this very call rather than silently inserting a duplicate row. + await service.upsertPayoutSync({ + payoutId: 'po_test_1', + lineItems: [ + { + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 20000, + feeAmount: 375, + }, + ], + netAmount: 19625, + feeCents: 375, + arrivalDate: 1713744000, + }) + await service.upsertPayoutSync({ + payoutId: 'po_test_1', + lineItems: [ + { + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 25000, + feeAmount: 500, + }, + ], + netAmount: 24500, + feeCents: 500, + arrivalDate: 1713744000, + }) + + const rows = await db + .select() + .from(QBPayoutSync) + .where( + and( + eq(QBPayoutSync.portalId, TEST_PORTAL_ID), + eq(QBPayoutSync.payoutId, 'po_test_1'), + ), + ) + + expect(rows).toHaveLength(1) + expect(rows[0].netAmount).toBe(24500) + expect(rows[0].feeAmount).toBe(500) + expect(rows[0].lineItems).toEqual([ + { copilotInvoiceId: 'inv-cop-0001', grossAmount: 25000, feeAmount: 500 }, + ]) + }) +}) diff --git a/test/integration/quickbooks/payoutResync/resync.test.ts b/test/integration/quickbooks/payoutResync/resync.test.ts new file mode 100644 index 00000000..7f1601d5 --- /dev/null +++ b/test/integration/quickbooks/payoutResync/resync.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { LogStatus } from '@/app/api/core/types/log' +import User from '@/app/api/core/models/User.model' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + seedFailedPayout, + TEST_PORTAL_ID, + TEST_BANK_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' + +const user = { workspaceId: TEST_PORTAL_ID } as User +const lineItems = [ + { copilotInvoiceId: 'inv-cop-0001', grossAmount: 20000, feeAmount: 375 }, + { copilotInvoiceId: 'inv-cop-0002', grossAmount: 15000, feeAmount: 200 }, +] + +// Dynamic (not top-level) import: SyncService's graph pulls AuthService, +// which imports `next/server`'s `after`. Importing it at module-collection +// time corrupts NTARH's AsyncLocalStorage for sibling postWebhook-based +// test files sharing this worker (isolate:false). Deferring the import to +// inside each test keeps that import out of collection time. +async function syncFailedRecords() { + const { SyncService } = await import('@/app/api/quickbooks/sync/sync.service') + await new SyncService(user).syncFailedRecords() +} + +describe('payout resync', () => { + const apis = setupPaymentSucceededTest() + + async function seedResolvableBatchedInvoices() { + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + } + + it('creates the deposit on a retry once the transient cause clears', async () => { + await seedResolvableBatchedInvoices() + await seedFailedPayout({ + payoutId: 'po_test_1', + lineItems, + netAmount: 34425, + feeCents: 575, + arrivalDate: 1713744000, + }) + + await syncFailedRecords() + + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_test_1'), + }) + expect(log?.status).toBe(LogStatus.SUCCESS) + expect(log?.quickbooksId).toBe('qb-deposit-1') + const payoutRow = await db.query.QBPayoutSync.findFirst() + expect(payoutRow?.qbDepositId).toBe('qb-deposit-1') + }) + + it('recovers a payout that arrived before its invoice.paid committed', async () => { + // Failed log seeded first, invoices resolvable only now (ordering fixed). + await seedHealthyPortal({ + portal: { + bankAccountRef: TEST_BANK_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + }, + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + await seedFailedPayout({ + payoutId: 'po_test_1', + lineItems, + netAmount: 34425, + feeCents: 575, + arrivalDate: 1713744000, + errorMessage: 'no SUCCESS INVOICE/PAID', + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0001', + invoiceNumber: 'INV-A', + paymentId: 'qbpay_A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: 'inv-cop-0002', + invoiceNumber: 'INV-B', + paymentId: 'qbpay_B', + isBatchedDeposit: true, + }) + + await syncFailedRecords() + + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_test_1'), + }) + expect(log?.status).toBe(LogStatus.SUCCESS) + }) + + it('does not create a second deposit when qbDepositId is already set', async () => { + await seedResolvableBatchedInvoices() + await seedFailedPayout({ + payoutId: 'po_test_1', + lineItems, + netAmount: 34425, + feeCents: 575, + arrivalDate: 1713744000, + qbDepositId: 'qb-deposit-existing', + }) + + await syncFailedRecords() + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_test_1'), + }) + expect(log?.status).toBe(LogStatus.SUCCESS) + expect(log?.quickbooksId).toBe('qb-deposit-existing') + }) + + it('keeps a missing-context payout terminal', async () => { + await seedResolvableBatchedInvoices() + // FAILED log with NO qb_payout_sync row → cannot rebuild. + await db.insert(QBSyncLog).values({ + portalId: TEST_PORTAL_ID, + entityType: 'payout' as never, + eventType: 'settled' as never, + status: LogStatus.FAILED, + copilotId: 'po_orphan', + shouldRetry: true, + }) + + await syncFailedRecords() + + const log = await db.query.QBSyncLog.findFirst({ + where: eq(QBSyncLog.copilotId, 'po_orphan'), + }) + expect(log?.status).toBe(LogStatus.FAILED) + expect(log?.shouldRetry).toBe(false) + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + }) +}) diff --git a/test/integration/quickbooks/payoutResync/schema.test.ts b/test/integration/quickbooks/payoutResync/schema.test.ts new file mode 100644 index 00000000..a589c42c --- /dev/null +++ b/test/integration/quickbooks/payoutResync/schema.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { and, eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBPayoutSync } from '@/db/schema/qbPayoutSync' +import { TEST_PORTAL_ID } from '@test/helpers/seed' +import { truncateAllTestTables } from '@test/helpers/testDb' + +describe('qb_payout_sync table', () => { + beforeEach(async () => { + await truncateAllTestTables() + }) + + it('round-trips a payout row with jsonb line items', async () => { + await db.insert(QBPayoutSync).values({ + portalId: TEST_PORTAL_ID, + payoutId: 'po_test_1', + lineItems: [ + { + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 20000, + feeAmount: 375, + }, + ], + netAmount: 34425, + feeAmount: 575, + arrivalDate: 1713744000, + }) + + const row = await db.query.QBPayoutSync.findFirst({ + where: and( + eq(QBPayoutSync.portalId, TEST_PORTAL_ID), + eq(QBPayoutSync.payoutId, 'po_test_1'), + ), + }) + + expect(row?.qbDepositId).toBeNull() + expect(row?.lineItems).toEqual([ + { copilotInvoiceId: 'inv-cop-0001', grossAmount: 20000, feeAmount: 375 }, + ]) + }) +}) diff --git a/test/integration/quickbooks/setting/bankAccounts.test.ts b/test/integration/quickbooks/setting/bankAccounts.test.ts new file mode 100644 index 00000000..9eef1b79 --- /dev/null +++ b/test/integration/quickbooks/setting/bankAccounts.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { testApiHandler } from 'next-test-api-route-handler' + +import * as appHandler from '@/app/api/quickbooks/setting/bank-account/route' +import { truncateAllTestTables } from '@test/helpers/testDb' +import { createMockIntuitAPI, installMockApis } from '@test/helpers/mocks' +import { seedHealthyPortal, TEST_WEBHOOK_TOKEN } from '@test/helpers/seed' + +describe('GET /api/quickbooks/setting/bank-account', () => { + beforeEach(async () => { + await truncateAllTestTables() + installMockApis({ + intuit: createMockIntuitAPI({ + // Mirrors QBO's real response shape: the controller's SELECT lists + // QB_ACCOUNT_COLUMNS (Id, Name, SyncToken, Active, AccountType), and + // QBO only returns the columns asked for. + customQuery: vi.fn().mockResolvedValue({ + Account: [ + { + Id: '103', + Name: 'Checking', + SyncToken: '0', + Active: true, + AccountType: 'Bank', + }, + ], + }), + }), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('returns active bank accounts with Id and Name present', async () => { + await seedHealthyPortal() + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/setting/bank-account?token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ method: 'GET' }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.accounts).toHaveLength(1) + // Full row also carries SyncToken/Active/AccountType (see mock above); + // the UI only needs Id/Name, so check those two are present. + expect(body.accounts[0]).toMatchObject({ + Id: '103', + Name: 'Checking', + }) + }, + }) + }) + + it('returns 401 without a token', async () => { + await testApiHandler({ + appHandler, + url: `/api/quickbooks/setting/bank-account`, + test: async ({ fetch }) => { + const res = await fetch({ method: 'GET' }) + expect(res.status).toBe(401) + }, + }) + }) +}) diff --git a/test/integration/quickbooks/setting/invoiceSettings.test.ts b/test/integration/quickbooks/setting/invoiceSettings.test.ts new file mode 100644 index 00000000..cf088788 --- /dev/null +++ b/test/integration/quickbooks/setting/invoiceSettings.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { testApiHandler } from 'next-test-api-route-handler' +import { eq } from 'drizzle-orm' + +import * as appHandler from '@/app/api/quickbooks/setting/route' +import { db } from '@/db' +import { QBSetting } from '@/db/schema/qbSettings' +import { QBPortalConnection } from '@/db/schema/qbPortalConnections' +import { truncateAllTestTables } from '@test/helpers/testDb' +import { installMockApis } from '@test/helpers/mocks' +import { + seedHealthyPortal, + TEST_PORTAL_ID, + TEST_WEBHOOK_TOKEN, +} from '@test/helpers/seed' + +describe('GET/POST /api/quickbooks/setting?type=invoice', () => { + beforeEach(async () => { + await truncateAllTestTables() + installMockApis() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('persists bankDepositFeeFlag on qb_settings and bankAccountRef on qb_portal_connections', async () => { + await seedHealthyPortal() + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/setting?type=invoice&token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ + method: 'POST', + body: JSON.stringify({ + type: 'invoice', + absorbedFeeFlag: false, + useCompanyNameFlag: false, + bankDepositFeeFlag: true, + bankAccountRef: '103', + }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(201) + }, + }) + + const [setting] = await db + .select() + .from(QBSetting) + .where(eq(QBSetting.portalId, TEST_PORTAL_ID)) + expect(setting.bankDepositFeeFlag).toBe(true) + + const [portalConnection] = await db + .select() + .from(QBPortalConnection) + .where(eq(QBPortalConnection.portalId, TEST_PORTAL_ID)) + expect(portalConnection.bankAccountRef).toBe('103') + }) + + it('rejects bankDepositFeeFlag true without a bankAccountRef with 422', async () => { + await seedHealthyPortal() + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/setting?type=invoice&token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ + method: 'POST', + body: JSON.stringify({ + type: 'invoice', + absorbedFeeFlag: false, + useCompanyNameFlag: false, + bankDepositFeeFlag: true, + }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(422) + }, + }) + + // Rejected request must not have written a bank account ref. + const [portalConnection] = await db + .select() + .from(QBPortalConnection) + .where(eq(QBPortalConnection.portalId, TEST_PORTAL_ID)) + expect(portalConnection.bankAccountRef).toBeNull() + }) + + it('returns bankAccountRef alongside the invoice setting', async () => { + await seedHealthyPortal({ + portal: { bankAccountRef: '103' }, + }) + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/setting?type=invoice&token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ method: 'GET' }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.bankAccountRef).toBe('103') + expect(body.setting).toMatchObject({ + absorbedFeeFlag: false, + useCompanyNameFlag: false, + }) + }, + }) + }) +}) diff --git a/test/integration/quickbooks/syncLog/getInvoiceNumbersWithRecordedFee.test.ts b/test/integration/quickbooks/syncLog/getInvoiceNumbersWithRecordedFee.test.ts new file mode 100644 index 00000000..a95af66e --- /dev/null +++ b/test/integration/quickbooks/syncLog/getInvoiceNumbersWithRecordedFee.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, beforeEach } from 'vitest' + +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { getInvoiceNumbersWithRecordedFee } from '@/db/service/syncLog.service' +import { TEST_PORTAL_ID } from '@test/helpers/seed' +import { truncateAllTestTables } from '@test/helpers/testDb' + +const OTHER_PORTAL_ID = 'portal-other-0001' + +type LogSeed = { + invoiceNumber: string + portalId?: string + entityType?: EntityType + eventType?: EventType + status?: LogStatus + deletedAt?: Date | null +} + +const seedLog = (seed: LogSeed) => + db.insert(QBSyncLog).values({ + portalId: seed.portalId ?? TEST_PORTAL_ID, + copilotId: `pay_${seed.invoiceNumber}`, + entityType: seed.entityType ?? EntityType.PAYMENT, + eventType: seed.eventType ?? EventType.SUCCEEDED, + status: seed.status ?? LogStatus.SUCCESS, + invoiceNumber: seed.invoiceNumber, + deletedAt: seed.deletedAt ?? null, + }) + +describe('getInvoiceNumbersWithRecordedFee', () => { + beforeEach(async () => { + await truncateAllTestTables() + }) + + it('returns invoices that have a SUCCESS PAYMENT/SUCCEEDED log', async () => { + await seedLog({ invoiceNumber: 'INV-A' }) + await seedLog({ invoiceNumber: 'INV-B' }) + + const recorded = await getInvoiceNumbersWithRecordedFee(TEST_PORTAL_ID, [ + 'INV-A', + 'INV-B', + ]) + + expect(recorded).toEqual(new Set(['INV-A', 'INV-B'])) + }) + + it('only counts the recorded ones, ignoring the rest of the requested list', async () => { + await seedLog({ invoiceNumber: 'INV-A' }) + + const recorded = await getInvoiceNumbersWithRecordedFee(TEST_PORTAL_ID, [ + 'INV-A', + 'INV-B', + ]) + + expect(recorded).toEqual(new Set(['INV-A'])) + }) + + it('excludes non-SUCCESS, wrong entity/event, soft-deleted, and other-portal rows', async () => { + await seedLog({ invoiceNumber: 'INV-OK' }) + await seedLog({ invoiceNumber: 'INV-FAILED', status: LogStatus.FAILED }) + await seedLog({ + invoiceNumber: 'INV-WRONG-EVENT', + eventType: EventType.CREATED, + }) + await seedLog({ + invoiceNumber: 'INV-WRONG-ENTITY', + entityType: EntityType.INVOICE, + }) + await seedLog({ invoiceNumber: 'INV-DELETED', deletedAt: new Date() }) + await seedLog({ invoiceNumber: 'INV-OTHER', portalId: OTHER_PORTAL_ID }) + + const recorded = await getInvoiceNumbersWithRecordedFee(TEST_PORTAL_ID, [ + 'INV-OK', + 'INV-FAILED', + 'INV-WRONG-EVENT', + 'INV-WRONG-ENTITY', + 'INV-DELETED', + 'INV-OTHER', + ]) + + expect(recorded).toEqual(new Set(['INV-OK'])) + }) + + it('returns an empty set for empty input', async () => { + await seedLog({ invoiceNumber: 'INV-A' }) + + const recorded = await getInvoiceNumbersWithRecordedFee(TEST_PORTAL_ID, []) + + expect(recorded).toEqual(new Set()) + }) +}) diff --git a/test/integration/quickbooks/syncLog/staleReaperPayoutTerminal.test.ts b/test/integration/quickbooks/syncLog/staleReaperPayoutTerminal.test.ts new file mode 100644 index 00000000..039f17e5 --- /dev/null +++ b/test/integration/quickbooks/syncLog/staleReaperPayoutTerminal.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { eq } from 'drizzle-orm' +import dayjs from 'dayjs' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { + SyncLogService, + STALE_PENDING_THRESHOLD_MINUTES, +} from '@/app/api/quickbooks/syncLog/syncLog.service' +import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' + +import { seedHealthyPortal, TEST_PORTAL_ID } from '@test/helpers/seed' +import { truncateAllTestTables } from '@test/helpers/testDb' + +const makeUser = () => ({ workspaceId: TEST_PORTAL_ID }) as any + +describe('flipStalePendingToFailed keeps a reaped payout claim terminal', () => { + beforeEach(async () => { + await truncateAllTestTables() + await seedHealthyPortal() + }) + + it('flips both stale PENDING rows to FAILED, but only the payout row is non-retryable', async () => { + const staleCreatedAt = dayjs() + .subtract(STALE_PENDING_THRESHOLD_MINUTES + 5, 'minutes') + .toDate() + + await db.insert(QBSyncLog).values([ + { + portalId: TEST_PORTAL_ID, + copilotId: 'po_stale_1', + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.PENDING, + createdAt: staleCreatedAt, + }, + { + portalId: TEST_PORTAL_ID, + copilotId: 'inv_stale_1', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.PENDING, + createdAt: staleCreatedAt, + }, + ]) + + const service = new SyncLogService(makeUser()) + await service.flipStalePendingToFailed() + + const [payoutLog] = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'po_stale_1')) + const [invoiceLog] = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, 'inv_stale_1')) + + expect(payoutLog.status).toBe(LogStatus.FAILED) + expect(payoutLog.shouldRetry).toBe(false) + + expect(invoiceLog.status).toBe(LogStatus.FAILED) + expect(invoiceLog.shouldRetry).toBe(true) + }) +}) diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 2e210acd..f50e5295 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -37,6 +37,10 @@ vi.mock('@/utils/intuitAPI', () => ({ // Named export used by src/utils/error.ts to detect Intuit-sourced APIErrors // when unwrapping error messages in the webhook catch block. IntuitAPIErrorMessage: '#IntuitAPIErrorMessage#', + // Named export consumed directly by controllers (e.g. bank-account) to + // build a customQuery SELECT list. Must be kept in sync with the real + // `QB_ACCOUNT_COLUMNS` in src/utils/intuitAPI.ts. + QB_ACCOUNT_COLUMNS: ['Id', 'Name', 'SyncToken', 'Active', 'AccountType'], })) // `@/utils/intuit` is the OAuth wrapper (separate from `@/utils/intuitAPI`, @@ -99,6 +103,23 @@ vi.mock('@/utils/sleep', () => ({ sleep: vi.fn().mockResolvedValue(undefined), })) +// AB gate for the bank deposit rollout. The real allowlist is parsed from env +// at `@/config` module load, so it can't be varied per-test once loaded. We +// mock the gate here (setupFiles runs before any app module binds it) and drive +// it via a globalThis-pinned allowlist. Default `null` = feature on for all +// portals, matching the empty-env behavior so existing tests are unaffected. +// A test opts in by setting `abTestGate.allowlist`; reset it in afterEach. +const AB_GATE_GLOBAL_KEY = '__qbsync_ab_test_gate__' +type ABGate = { allowlist: string[] | null } +const abGateRef = globalThis as unknown as Record +abGateRef[AB_GATE_GLOBAL_KEY] ??= { allowlist: null } +vi.mock('@/utils/abTesting', () => ({ + isPortalInBankDepositABTest: (portalId: string) => { + const gate = abGateRef[AB_GATE_GLOBAL_KEY]! + return gate.allowlist === null || gate.allowlist.includes(portalId) + }, +})) + // Importing modules that pull `next/server` corrupts NTARH's AsyncLocalStorage. // Shimming this entry point keeps the next/server import out of the graph. vi.mock('@/app/api/core/utils/afterIfAvailable', () => ({ 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/app/api/quickbooks/invoice/invoice.service.bankDepositGate.test.ts b/test/unit/app/api/quickbooks/invoice/invoice.service.bankDepositGate.test.ts new file mode 100644 index 00000000..4f7a1e03 --- /dev/null +++ b/test/unit/app/api/quickbooks/invoice/invoice.service.bankDepositGate.test.ts @@ -0,0 +1,90 @@ +/** + * Freeze-point coverage for InvoiceService#readBankDepositFeeFlag — the one + * place invoice creation decides batched intent. A portal outside the AB + * allowlist must freeze non-batched regardless of its stored setting, and must + * not even read the setting. readBankDepositFeeFlag is private, reached via a + * type cast (same approach as invoice.service.docNumber.test.ts). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn((cb: (scope: unknown) => void) => + cb({ setTag: vi.fn(), setExtra: vi.fn(), addEventProcessor: vi.fn() }), + ), + captureException: vi.fn(), + captureMessage: vi.fn(), + addBreadcrumb: vi.fn(), + init: vi.fn(), +})) +vi.mock('@/utils/logger', () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})) +vi.mock('@/utils/copilotAPI', () => ({ CopilotAPI: vi.fn() })) +vi.mock('@/utils/intuitAPI', () => ({ + default: vi.fn(), + IntuitAPIErrorMessage: '#IntuitAPIErrorMessage#', +})) +// BaseService imports `@/db`, which initialises postgres at module load. +vi.mock('@/db', () => ({ db: {}, client: {} })) +vi.mock('@/utils/sentry', () => ({ + addSyncBreadcrumb: vi.fn(), + captureSyncError: vi.fn(), +})) +// SyncLogService is instantiated in the InvoiceService constructor. +vi.mock('@/app/api/quickbooks/syncLog/syncLog.service', () => ({ + SyncLogService: vi.fn(function () { + return {} + }), +})) + +const { getOneByPortalId, isPortalInBankDepositABTest } = vi.hoisted(() => ({ + getOneByPortalId: vi.fn(), + isPortalInBankDepositABTest: vi.fn(), +})) +vi.mock('@/app/api/quickbooks/setting/setting.service', () => ({ + SettingService: vi.fn(function () { + return { getOneByPortalId } + }), +})) +vi.mock('@/utils/abTesting', () => ({ isPortalInBankDepositABTest })) + +import { InvoiceService } from '@/app/api/quickbooks/invoice/invoice.service' +import User from '@/app/api/core/models/User.model' + +const stubUser = { + workspaceId: 'test-portal-00000001', + token: 'tkn', + qbConnection: undefined, +} as unknown as User + +type WithReadFlag = { readBankDepositFeeFlag: () => Promise } +const newSvc = () => new InvoiceService(stubUser) as unknown as WithReadFlag + +describe('InvoiceService#readBankDepositFeeFlag — AB freeze gate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('freezes non-batched and skips the setting read for an excluded portal', async () => { + isPortalInBankDepositABTest.mockReturnValue(false) + getOneByPortalId.mockResolvedValue({ bankDepositFeeFlag: true }) + + expect(await newSvc().readBankDepositFeeFlag()).toBe(false) + expect(getOneByPortalId).not.toHaveBeenCalled() + }) + + it('honors the stored flag for an allowlisted portal', async () => { + isPortalInBankDepositABTest.mockReturnValue(true) + getOneByPortalId.mockResolvedValue({ bankDepositFeeFlag: true }) + + expect(await newSvc().readBankDepositFeeFlag()).toBe(true) + }) + + it('defaults to false when an allowlisted portal has no setting row', async () => { + isPortalInBankDepositABTest.mockReturnValue(true) + getOneByPortalId.mockResolvedValue(undefined) + + expect(await newSvc().readBankDepositFeeFlag()).toBe(false) + }) +}) diff --git a/test/unit/dto/depositQueryResponse.test.ts b/test/unit/dto/depositQueryResponse.test.ts new file mode 100644 index 00000000..551d1087 --- /dev/null +++ b/test/unit/dto/depositQueryResponse.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { QBDepositQueryResponseSchema } from '@/type/dto/intuitAPI.dto' + +describe('QBDepositQueryResponseSchema', () => { + it('parses a QueryResponse with deposits', () => { + const parsed = QBDepositQueryResponseSchema.parse({ + Deposit: [{ Id: 'dep-1', PrivateNote: 'Stripe payout po_1' }], + }) + expect(parsed.Deposit?.[0].Id).toBe('dep-1') + }) + + it('parses an empty QueryResponse (no Deposit key)', () => { + const parsed = QBDepositQueryResponseSchema.parse({}) + expect(parsed.Deposit).toBeUndefined() + }) +}) diff --git a/test/unit/dto/payoutLineItem.test.ts b/test/unit/dto/payoutLineItem.test.ts new file mode 100644 index 00000000..60e8a849 --- /dev/null +++ b/test/unit/dto/payoutLineItem.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest' +import { PayoutLineItemSchema } from '@/type/dto/webhook.dto' + +describe('PayoutLineItemSchema', () => { + it('parses a valid line item', () => { + const parsed = PayoutLineItemSchema.parse({ + copilotInvoiceId: 'inv-cop-0001', + grossAmount: 20000, + feeAmount: 375, + }) + expect(parsed.copilotInvoiceId).toBe('inv-cop-0001') + }) + + it('rejects a line item missing the invoice id', () => { + expect(() => + PayoutLineItemSchema.parse({ grossAmount: 1, feeAmount: 0 }), + ).toThrow() + }) +}) diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts index 580ce839..04f8b7f6 100644 --- a/test/unit/notification/notification.helper.test.ts +++ b/test/unit/notification/notification.helper.test.ts @@ -82,6 +82,61 @@ describe('getInProductNotificationDetail', () => { expect(detail.body).not.toContain('payment completion') }) + it('renders payout reconciliation with the payout id as ref and names the affected invoices', () => { + const ctx: NotificationContext = { + entityType: 'payout', + eventType: 'settled', + entityKey: 'po_test_1', + invoiceNumbers: 'INV-A, INV-B', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_PAYOUT_MIXED_INTENT, + ctx, + ) + expect(detail.body).toContain('during payout reconciliation, ref po_test_1') + expect(detail.body).not.toContain('ref Stripe payout') + expect(detail.body).toContain( + 'No deposit was created for invoices INV-A, INV-B', + ) + }) + + it('omits the invoice list from the payout body when no invoice numbers are present', () => { + const ctx: NotificationContext = { + entityType: 'payout', + eventType: 'settled', + entityKey: 'po_test_1', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_PAYOUT_MIXED_INTENT, + ctx, + ) + expect(detail.body).toContain( + 'No deposit was created. The payments are already recorded', + ) + expect(detail.body).not.toContain('for invoices') + expect(detail.body).not.toContain('already recorded as expenses') + }) + + it('warns which invoice fees are already recorded so they are not booked twice', () => { + const ctx: NotificationContext = { + entityType: 'payout', + eventType: 'settled', + entityKey: 'po_test_1', + invoiceNumbers: 'INV-A, INV-B', + invoiceNumbersWithFee: 'INV-A', + } + const detail = getInProductNotificationDetail( + NotificationActions.QB_PAYOUT_MIXED_INTENT, + ctx, + ) + expect(detail.body).toContain( + 'No deposit was created for invoices INV-A, INV-B', + ) + expect(detail.body).toContain( + 'The Stripe fees for INV-A are already recorded as expenses in QuickBooks, so do not record those fees again', + ) + }) + it('5010 (invoice-only after suppression) warns that the failure is final', () => { const ctx: NotificationContext = { entityType: 'invoice', diff --git a/test/unit/payout/payout.service.bankDepositGate.test.ts b/test/unit/payout/payout.service.bankDepositGate.test.ts new file mode 100644 index 00000000..2ee91d82 --- /dev/null +++ b/test/unit/payout/payout.service.bankDepositGate.test.ts @@ -0,0 +1,76 @@ +/** + * AB-gate coverage for PayoutService#reconcile — the deposit-creating step, + * shared by the payout webhook and the resync cron. An excluded portal must + * short-circuit to { depositId: null } before any token check or QBO call. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { QBPayoutSyncSelectSchemaType } from '@/db/schema/qbPayoutSync' +import type { IntuitAPITokensType } from '@/utils/intuitAPI' + +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + addBreadcrumb: vi.fn(), + init: vi.fn(), +})) +// BaseService imports `@/db`, which initialises postgres at module load. +vi.mock('@/db', () => ({ db: {}, client: {} })) +vi.mock('@/utils/copilotAPI', () => ({ CopilotAPI: vi.fn() })) +vi.mock('@/utils/intuitAPI', () => ({ default: vi.fn() })) +vi.mock('@/app/api/quickbooks/syncLog/syncLog.service', () => ({ + SyncLogService: vi.fn(function () { + return {} + }), +})) + +const { validateAccessToken, isPortalInBankDepositABTest } = vi.hoisted(() => ({ + validateAccessToken: vi.fn(), + isPortalInBankDepositABTest: vi.fn(), +})) +vi.mock('@/utils/auth', () => ({ validateAccessToken })) +vi.mock('@/utils/abTesting', () => ({ isPortalInBankDepositABTest })) + +import { PayoutService } from '@/app/api/quickbooks/payout/payout.service' +import User from '@/app/api/core/models/User.model' + +const stubUser = { workspaceId: 'test-portal-00000001' } as unknown as User +const stubRow = { + payoutId: 'po_123', +} as unknown as QBPayoutSyncSelectSchemaType +const stubTokens = {} as IntuitAPITokensType + +describe('PayoutService#reconcile — AB gate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('short-circuits to no deposit for an excluded portal without checking the token', async () => { + isPortalInBankDepositABTest.mockReturnValue(false) + + const result = await new PayoutService(stubUser).reconcile( + stubRow, + stubTokens, + { runIdempotencyCheck: true }, + ) + + expect(result).toEqual({ depositId: null }) + expect(validateAccessToken).not.toHaveBeenCalled() + }) + + it('proceeds past the gate for an allowlisted portal', async () => { + isPortalInBankDepositABTest.mockReturnValue(true) + // Force a stop right after the gate so we assert only that it advanced. + validateAccessToken.mockImplementation(() => { + throw new Error('advanced past gate') + }) + + await expect( + new PayoutService(stubUser).reconcile(stubRow, stubTokens, { + runIdempotencyCheck: true, + }), + ).rejects.toThrow('advanced past gate') + expect(validateAccessToken).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/payout/payoutErrors.test.ts b/test/unit/payout/payoutErrors.test.ts new file mode 100644 index 00000000..2d59dd17 --- /dev/null +++ b/test/unit/payout/payoutErrors.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import httpStatus from 'http-status' + +import APIError from '@/app/api/core/exceptions/api' +import { refreshTokenExpireMessage } from '@/utils/auth' +import { + TerminalPayoutError, + MixedPayoutIntentError, + getShouldRetryForPayout, +} from '@/app/api/quickbooks/payout/payout.errors' + +describe('getShouldRetryForPayout', () => { + it('is terminal for deterministic payout errors', () => { + expect( + getShouldRetryForPayout(new TerminalPayoutError('refund lines')), + ).toBe(false) + expect(getShouldRetryForPayout(new MixedPayoutIntentError('mixed'))).toBe( + false, + ) + }) + + it('is retryable for an unresolved-invoice NOT_FOUND (webhook ordering)', () => { + expect( + getShouldRetryForPayout( + new APIError(httpStatus.NOT_FOUND, 'no PAID log'), + ), + ).toBe(true) + }) + + it('is terminal for a dead refresh token (AUTH)', () => { + expect(getShouldRetryForPayout(new Error(refreshTokenExpireMessage))).toBe( + false, + ) + }) + + it('treats a mixed-intent error as a terminal payout error', () => { + expect(new MixedPayoutIntentError('x') instanceof TerminalPayoutError).toBe( + true, + ) + }) +}) diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts index deb6c752..bd74fef7 100644 --- a/test/unit/quickbooks/syncErrorNotifier.test.ts +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -36,15 +36,31 @@ vi.mock('@/db/service/token.service', () => ({ getPortalConnection: () => getPortalConnectionMock(), })) +const getInvoiceNumbersWithRecordedFeeMock = vi + .fn() + .mockResolvedValue(new Set()) +vi.mock('@/db/service/syncLog.service', () => ({ + getInvoiceNumbersWithRecordedFee: ( + portalId: string, + invoiceNumbers: string[], + ) => getInvoiceNumbersWithRecordedFeeMock(portalId, invoiceNumbers), +})) + import { SyncErrorNotifier, getActionForErrorCode, getEntityKey, } from '@/app/api/quickbooks/syncLog/syncErrorNotifier' import { + AppActionableErrorCodes, + PAYOUT_MIXED_INTENT_CODE, QBOErrorCodes, UserActionableErrorCodes, } from '@/constant/intuitErrorCode' +import { + getIEmailNotificationDetail, + getInProductNotificationDetail, +} from '@/app/api/notification/notification.helper' const baseLog: QBSyncLogSelectSchemaType = { id: 'log-1', @@ -70,6 +86,7 @@ const baseLog: QBSyncLogSelectSchemaType = { errorCode: String(QBOErrorCodes.CLOSED_PERIOD), category: 'qb_api_error' as never, attempt: 0, + shouldRetry: true, createdAt: new Date(), updatedAt: new Date(), deletedAt: null, @@ -86,6 +103,14 @@ describe('getActionForErrorCode', () => { }, ) + // Self-extending over the app-level sentinel registry, mirroring the QBO one. + it.each(Object.entries(AppActionableErrorCodes))( + 'maps app sentinel code %s to action %s', + (code, expectedAction) => { + expect(getActionForErrorCode(code)).toBe(expectedAction) + }, + ) + it('returns null for unknown / transient / auth codes', () => { expect(getActionForErrorCode('429')).toBeNull() expect(getActionForErrorCode('500')).toBeNull() @@ -174,6 +199,8 @@ describe('SyncErrorNotifier#notify', () => { beforeEach(() => { sendNotificationToIU.mockReset() + getInvoiceNumbersWithRecordedFeeMock.mockReset() + getInvoiceNumbersWithRecordedFeeMock.mockResolvedValue(new Set()) }) it('skips when status is not FAILED', async () => { @@ -223,6 +250,127 @@ describe('SyncErrorNotifier#notify', () => { }, ) + it('dispatches the mixed-payout notification for a FAILED payout with the sentinel code', async () => { + const notifier = new SyncErrorNotifier(user) + + await notifier.notify({ + ...baseLog, + entityType: 'payout' as never, + eventType: 'settled' as never, + errorCode: PAYOUT_MIXED_INTENT_CODE, + quickbooksId: null, + invoiceNumber: null, + copilotId: 'po_test_1', + // Webhook stashes the affected invoice numbers in remark for this action. + remark: 'INV-A, INV-B', + errorMessage: + 'Payout po_test_1 mixes batched and non-batched invoices; unsupported', + }) + + expect(sendNotificationToIU).toHaveBeenCalledTimes(1) + const [, action, ctx] = sendNotificationToIU.mock.calls[0] + expect(action).toBe(NotificationActions.QB_PAYOUT_MIXED_INTENT) + // copilotId stays the ref; the invoice list rides in invoiceNumbers. + expect(ctx).toMatchObject({ + entityType: 'payout', + entityKey: 'po_test_1', + invoiceNumbers: 'INV-A, INV-B', + }) + // No recorded-fee rows this run, so the warning field stays absent. + expect(ctx.invoiceNumbersWithFee).toBeUndefined() + + // Close the seam: the ctx extracted from `remark` must render the invoice + // list in the real copy (both channels), with the payout id as the ref. + const inProduct = getInProductNotificationDetail(action, ctx) + const email = getIEmailNotificationDetail(action, ctx) + for (const body of [inProduct.body, email.body]) { + expect(body).toContain('ref po_test_1') + expect(body).toContain('No deposit was created for invoices INV-A, INV-B') + } + }) + + it('flags the invoices whose fees are already recorded so IUs do not book them twice', async () => { + // Only INV-A has a recorded absorbed-fee expense; INV-B was deferred. + getInvoiceNumbersWithRecordedFeeMock.mockResolvedValueOnce( + new Set(['INV-A']), + ) + const notifier = new SyncErrorNotifier(user) + + await notifier.notify({ + ...baseLog, + entityType: 'payout' as never, + eventType: 'settled' as never, + errorCode: PAYOUT_MIXED_INTENT_CODE, + quickbooksId: null, + invoiceNumber: null, + copilotId: 'po_test_1', + remark: 'INV-A, INV-B', + errorMessage: + 'Payout po_test_1 mixes batched and non-batched invoices; unsupported', + }) + + expect(getInvoiceNumbersWithRecordedFeeMock).toHaveBeenCalledWith( + 'portal-1', + ['INV-A', 'INV-B'], + ) + const [, action, ctx] = sendNotificationToIU.mock.calls[0] + expect(ctx).toMatchObject({ + invoiceNumbers: 'INV-A, INV-B', + invoiceNumbersWithFee: 'INV-A', + }) + + const inProduct = getInProductNotificationDetail(action, ctx) + const email = getIEmailNotificationDetail(action, ctx) + for (const body of [inProduct.body, email.body]) { + expect(body).toContain( + 'The Stripe fees for INV-A are already recorded as expenses in QuickBooks, so do not record those fees again', + ) + } + }) + + it('lists recorded-fee invoices in remark order, not lookup order', async () => { + // Lookup returns them reversed; output must still follow the remark order. + getInvoiceNumbersWithRecordedFeeMock.mockResolvedValueOnce( + new Set(['INV-B', 'INV-A']), + ) + const notifier = new SyncErrorNotifier(user) + + await notifier.notify({ + ...baseLog, + entityType: 'payout' as never, + eventType: 'settled' as never, + errorCode: PAYOUT_MIXED_INTENT_CODE, + copilotId: 'po_test_1', + remark: 'INV-A, INV-B', + }) + + const [, , ctx] = sendNotificationToIU.mock.calls[0] + expect(ctx.invoiceNumbersWithFee).toBe('INV-A, INV-B') + }) + + it('still dispatches the mixed-payout notification when the recorded-fee lookup throws', async () => { + // A lookup blip must not swallow this terminal, never-retried notification. + getInvoiceNumbersWithRecordedFeeMock.mockRejectedValueOnce( + new Error('db blip'), + ) + const notifier = new SyncErrorNotifier(user) + + await notifier.notify({ + ...baseLog, + entityType: 'payout' as never, + eventType: 'settled' as never, + errorCode: PAYOUT_MIXED_INTENT_CODE, + copilotId: 'po_test_1', + remark: 'INV-A, INV-B', + }) + + expect(sendNotificationToIU).toHaveBeenCalledTimes(1) + const [, action, ctx] = sendNotificationToIU.mock.calls[0] + expect(action).toBe(NotificationActions.QB_PAYOUT_MIXED_INTENT) + expect(ctx).toMatchObject({ invoiceNumbers: 'INV-A, INV-B' }) + expect(ctx.invoiceNumbersWithFee).toBeUndefined() + }) + it('dispatches a notification for a FAILED row with a user-actionable code', async () => { const notifier = new SyncErrorNotifier(user) diff --git a/test/unit/setting/setting.controller.test.ts b/test/unit/setting/setting.controller.test.ts new file mode 100644 index 00000000..70092888 --- /dev/null +++ b/test/unit/setting/setting.controller.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { NextRequest } from 'next/server' + +// Spies are declared via vi.hoisted so the vi.mock factories below (which are +// hoisted above the imports) can reference them. +const { + updateQBSettings, + updateQBPortalConnection, + getOneByPortalId, + getPortalConnection, + isPortalInBankDepositABTest, + transaction, +} = vi.hoisted(() => ({ + updateQBSettings: vi.fn(), + updateQBPortalConnection: vi.fn(), + getOneByPortalId: vi.fn(), + getPortalConnection: vi.fn(), + isPortalInBankDepositABTest: vi.fn(), + transaction: vi.fn(), +})) + +vi.mock('@/db', () => ({ db: { transaction }, client: {} })) +vi.mock('@/db/service/token.service', () => ({ getPortalConnection })) +vi.mock('@/app/api/core/utils/authenticate', () => ({ + default: vi.fn(async () => ({ workspaceId: 'portal-1', token: 'token' })), +})) +vi.mock('@/app/api/quickbooks/setting/setting.service', () => ({ + SettingService: vi.fn(function () { + return { + setTransaction: vi.fn(), + unsetTransaction: vi.fn(), + updateQBSettings, + getOneByPortalId, + } + }), +})) +vi.mock('@/app/api/quickbooks/token/token.service', () => ({ + TokenService: vi.fn(function () { + return { + setTransaction: vi.fn(), + unsetTransaction: vi.fn(), + updateQBPortalConnection, + } + }), +})) +vi.mock('@/utils/abTesting', () => ({ isPortalInBankDepositABTest })) + +import { + getSettings, + updateSettings, +} from '@/app/api/quickbooks/setting/setting.controller' + +// Minimal request stub: the controller only reads the `type` search param and +// the JSON body. +function invoiceSettingsRequest(body: Record): NextRequest { + return { + nextUrl: { searchParams: new URLSearchParams({ type: 'invoice' }) }, + json: async () => ({ type: 'invoice', ...body }), + } as unknown as NextRequest +} + +function getSettingsRequest(type: string): NextRequest { + return { + nextUrl: { searchParams: new URLSearchParams({ type }) }, + } as unknown as NextRequest +} + +const baseInvoiceBody = { + absorbedFeeFlag: true, + useCompanyNameFlag: false, +} + +describe('updateSettings — bank deposit AB gate', () => { + beforeEach(() => { + vi.clearAllMocks() + updateQBSettings.mockImplementation(async (payload) => ({ + id: 'setting-1', + ...payload, + })) + transaction.mockImplementation(async (cb) => cb({})) + }) + + it('drops the bank deposit flag for a portal that is not in the AB test', async () => { + isPortalInBankDepositABTest.mockReturnValue(false) + + await updateSettings( + invoiceSettingsRequest({ + ...baseInvoiceBody, + bankDepositFeeFlag: true, + bankAccountRef: 'account-1', + }), + ) + + expect(updateQBSettings).toHaveBeenCalledTimes(1) + const savedPayload = updateQBSettings.mock.calls[0][0] + expect(savedPayload).not.toHaveProperty('bankDepositFeeFlag') + // Bank account ref is never written for a non-AB portal, even when supplied. + expect(updateQBPortalConnection).not.toHaveBeenCalled() + }) + + it('saves the flag and the bank account ref for an AB-test portal', async () => { + isPortalInBankDepositABTest.mockReturnValue(true) + + await updateSettings( + invoiceSettingsRequest({ + ...baseInvoiceBody, + bankDepositFeeFlag: true, + bankAccountRef: 'account-9', + }), + ) + + const savedPayload = updateQBSettings.mock.calls[0][0] + expect(savedPayload.bankDepositFeeFlag).toBe(true) + expect(updateQBPortalConnection).toHaveBeenCalledWith( + { bankAccountRef: 'account-9' }, + expect.anything(), + ) + }) + + it('lets an AB-test portal turn the flag off without a bank account', async () => { + isPortalInBankDepositABTest.mockReturnValue(true) + + await updateSettings( + invoiceSettingsRequest({ + ...baseInvoiceBody, + bankDepositFeeFlag: false, + }), + ) + + const savedPayload = updateQBSettings.mock.calls[0][0] + expect(savedPayload.bankDepositFeeFlag).toBe(false) + expect(updateQBPortalConnection).not.toHaveBeenCalled() + }) +}) + +describe('getSettings — bankDepositEnabled signal', () => { + beforeEach(() => { + vi.clearAllMocks() + getOneByPortalId.mockResolvedValue({ id: 'setting-1' }) + getPortalConnection.mockResolvedValue({ bankAccountRef: 'account-1' }) + }) + + it('reports the AB gate as the bankDepositEnabled flag for invoice settings', async () => { + isPortalInBankDepositABTest.mockReturnValue(true) + + const response = await getSettings(getSettingsRequest('invoice')) + + expect(await response.json()).toMatchObject({ bankDepositEnabled: true }) + }) + + it('reports bankDepositEnabled false for an excluded portal', async () => { + isPortalInBankDepositABTest.mockReturnValue(false) + + const response = await getSettings(getSettingsRequest('invoice')) + + expect(await response.json()).toMatchObject({ bankDepositEnabled: false }) + }) + + it('never enables the signal for non-invoice settings', async () => { + isPortalInBankDepositABTest.mockReturnValue(true) + + const response = await getSettings(getSettingsRequest('product')) + + expect(await response.json()).toMatchObject({ bankDepositEnabled: false }) + // The gate is not even consulted outside invoice settings. + expect(isPortalInBankDepositABTest).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/type/settingRequestSchema.test.ts b/test/unit/type/settingRequestSchema.test.ts new file mode 100644 index 00000000..68403e43 --- /dev/null +++ b/test/unit/type/settingRequestSchema.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { SettingRequestSchema, SettingType } from '@/type/common' + +describe('SettingRequestSchema — invoice bank deposit validation', () => { + const base = { + type: SettingType.INVOICE, + absorbedFeeFlag: false, + useCompanyNameFlag: false, + } + + it('accepts bank deposit off without a bank account', () => { + const r = SettingRequestSchema.safeParse({ + ...base, + bankDepositFeeFlag: false, + }) + expect(r.success).toBe(true) + }) + + it('rejects bank deposit on without a bank account', () => { + const r = SettingRequestSchema.safeParse({ + ...base, + bankDepositFeeFlag: true, + }) + expect(r.success).toBe(false) + if (!r.success) { + expect( + r.error.issues.some((i) => i.path.includes('bankAccountRef')), + ).toBe(true) + } + }) + + it('accepts bank deposit on with a bank account', () => { + const r = SettingRequestSchema.safeParse({ + ...base, + bankDepositFeeFlag: true, + bankAccountRef: '123', + }) + expect(r.success).toBe(true) + }) + + it('rejects invoice missing bankDepositFeeFlag', () => { + const r = SettingRequestSchema.safeParse({ ...base }) + expect(r.success).toBe(false) + }) +}) diff --git a/test/unit/utils/abTesting.test.ts b/test/unit/utils/abTesting.test.ts new file mode 100644 index 00000000..057b08ac --- /dev/null +++ b/test/unit/utils/abTesting.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' + +// abFeatureTestingPortals is parsed from the env var at module load, so each +// case stubs the env, resets the module registry, and re-imports to pick up +// the fresh parse. +async function loadGate(envValue?: string) { + vi.resetModules() + if (envValue === undefined) { + vi.stubEnv('AB_FEATURE_TESTING_PORTALS', '') + } else { + vi.stubEnv('AB_FEATURE_TESTING_PORTALS', envValue) + } + const { isPortalInBankDepositABTest } = await import('@/utils/abTesting') + return isPortalInBankDepositABTest +} + +describe('isPortalInBankDepositABTest', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('allows every portal when the allowlist is unset', async () => { + const isInTest = await loadGate(undefined) + expect(isInTest('portal-abc')).toBe(true) + }) + + it('allows every portal when the allowlist is empty', async () => { + const isInTest = await loadGate('') + expect(isInTest('portal-abc')).toBe(true) + }) + + it('allows a portal that is on the allowlist', async () => { + const isInTest = await loadGate('portal-abc,portal-def') + expect(isInTest('portal-abc')).toBe(true) + expect(isInTest('portal-def')).toBe(true) + }) + + it('blocks a portal that is not on the allowlist', async () => { + const isInTest = await loadGate('portal-abc,portal-def') + expect(isInTest('portal-xyz')).toBe(false) + }) + + it('ignores surrounding whitespace and empty entries in the allowlist', async () => { + const isInTest = await loadGate(' portal-abc , , portal-def ,') + expect(isInTest('portal-abc')).toBe(true) + expect(isInTest('portal-def')).toBe(true) + expect(isInTest('portal-xyz')).toBe(false) + }) +}) 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.getDepositsByTxnDate.test.ts b/test/unit/utils/intuitAPI.getDepositsByTxnDate.test.ts new file mode 100644 index 00000000..a58333d7 --- /dev/null +++ b/test/unit/utils/intuitAPI.getDepositsByTxnDate.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for `IntuitAPI._getDepositsByTxnDate` — the idempotency lookup + * behind the payout resync path. Coverage focus: pagination correctness. + * A portal can have >1 page of deposits on the same TxnDate; missing a + * match past position 1000 would let a resync create a duplicate deposit + * (QBO has no deleteDeposit, so this is a real double-book vector). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn(), + captureMessage: vi.fn(), + captureException: vi.fn(), +})) + +vi.mock('@/utils/logger', () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/helper/fetch.helper', () => ({ + getFetcher: vi.fn(), + postFetcher: vi.fn(), +})) + +import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' + +const baseTokens: IntuitAPITokensType = { + accessToken: 'access', + refreshToken: 'refresh', + intuitRealmId: 'realm-1', + incomeAccountRef: 'income', + expenseAccountRef: 'expense', + assetAccountRef: 'asset', + serviceItemRef: 'service', + clientFeeRef: 'client-fee', + bankAccountRef: 'bank', +} + +// Builds a deposit row in the shape QBO returns inside `QueryResponse.Deposit`. +const row = (id: string, privateNote?: string) => ({ + Id: id, + ...(privateNote ? { PrivateNote: privateNote } : {}), + TxnDate: '2026-07-29', +}) + +// `customQuery` is a public field on IntuitAPI (`this.wrapWithRetry(this._customQuery)`). +// Replace it on the instance after construction, matching the pattern in +// intuitAPI.test.ts / intuitAPI.accounts.test.ts. +function makeApi(pages: Array) { + const api = new IntuitAPI(baseTokens) + const customQuery = vi.fn() + for (const page of pages) { + customQuery.mockResolvedValueOnce(page) + } + customQuery.mockImplementation(() => { + throw new Error('customQuery called more times than test configured') + }) + ;(api as unknown as { customQuery: unknown }).customQuery = customQuery + return { api, customQuery } +} + +describe('IntuitAPI#getDepositsByTxnDate', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns all rows from a single short page without a second call', async () => { + const { api, customQuery } = makeApi([ + { Deposit: [row('dep-1', 'Stripe payout po_1'), row('dep-2')] }, + ]) + + const result = await api.getDepositsByTxnDate('2026-07-29') + + expect(result).toHaveLength(2) + expect(customQuery).toHaveBeenCalledTimes(1) + }) + + it('paginates across a full page and a short page, advancing STARTPOSITION 1 -> 1001', async () => { + const page1 = { + Deposit: Array.from({ length: 1000 }, (_, i) => row(`p1-${i}`)), + } + const page2 = { + Deposit: [row('p2-0', 'Stripe payout po_target'), row('p2-1')], + } + const { api, customQuery } = makeApi([page1, page2]) + + const result = await api.getDepositsByTxnDate('2026-07-29') + + expect(customQuery).toHaveBeenCalledTimes(2) + const firstQuery = customQuery.mock.calls[0][0] as string + const secondQuery = customQuery.mock.calls[1][0] as string + expect(firstQuery).toContain('STARTPOSITION 1 ') + expect(secondQuery).toContain('STARTPOSITION 1001 ') + + expect(result).toHaveLength(1002) + expect( + result.some((d) => d.PrivateNote === 'Stripe payout po_target'), + ).toBe(true) + }) +}) 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`. diff --git a/test/unit/utils/tokenRefresh.test.ts b/test/unit/utils/tokenRefresh.test.ts index 91be92a9..62986d04 100644 --- a/test/unit/utils/tokenRefresh.test.ts +++ b/test/unit/utils/tokenRefresh.test.ts @@ -119,6 +119,7 @@ const basePortalRow = { assetAccountRef: 'asset-ref', serviceItemRef: 'service-ref', clientFeeRef: 'client-fee-ref', + bankAccountRef: 'bank-acc-ref-123', isSuspended: false, createdAt: new Date(), updatedAt: new Date(), @@ -162,6 +163,10 @@ describe('getValidQbTokens', () => { const tokens = await getValidQbTokens('portal-abc') expect(tokens.accessToken).toBe('stored-access') + // Regression guard: extractTokens() (the fresh-token path) once omitted + // bankAccountRef entirely, so the payout batched-deposit handler threw + // "Bank account ref is not configured" on every non-refreshing request. + expect(tokens.bankAccountRef).toBe('bank-acc-ref-123') expect(getRefreshedQBToken).not.toHaveBeenCalled() expect(dbUpdates).toHaveLength(0) }) @@ -187,6 +192,9 @@ describe('getValidQbTokens', () => { expect(tokens.accessToken).toBe('fresh-access') expect(tokens.refreshToken).toBe('fresh-refresh') + // The refresh path already carried bankAccountRef through; pinned here + // alongside the fresh-token-path assertion above so both paths are guarded. + expect(tokens.bankAccountRef).toBe('bank-acc-ref-123') expect(getPortalConnection).toHaveBeenCalledExactlyOnceWith('portal-abc') expect(getRefreshedQBToken).toHaveBeenCalledExactlyOnceWith( 'stored-refresh', @@ -248,6 +256,7 @@ describe('getRefreshedQbTokenInfo', () => { const tokens = await getRefreshedQbTokenInfo('portal-abc') expect(tokens.accessToken).toBe('fresh-access') + expect(tokens.bankAccountRef).toBe('bank-acc-ref-123') expect(dbUpdates).toEqual([ expect.objectContaining({ table: QBPortalConnection }), ])
{description}
+ Could not load bank accounts. Reload to retry. +
+ Select a deposit bank account to enable bank deposits. +
+ {description} +