From 065b6fad948ca643ecba5d04470da9fdd4c33c70 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 22 Jul 2026 20:44:51 +0545 Subject: [PATCH 01/49] feat(OUT-3604): apply migrations one-per-transaction via db:migrate drizzle's migrate() runs all pending files in a single transaction, which fails when one migration adds an enum value and a later one uses it in DDL ("unsafe use of new value"). Add migratePerFile (one commit per journal entry) and a db:migrate runner, and switch build.sh to use it. Co-Authored-By: Claude Opus 4.8 --- package.json | 1 + scripts/build.sh | 4 ++-- src/db/migrate.ts | 39 ++++++++++++++++++++++++++++++ src/db/migratePerFile.ts | 52 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 src/db/migrate.ts create mode 100644 src/db/migratePerFile.ts diff --git a/package.json b/package.json index 158fcb24..5cd9f720 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", 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/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..0f7d9622 --- /dev/null +++ b/src/db/migratePerFile.ts @@ -0,0 +1,52 @@ +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' + +type Journal = { + version: string + dialect: string + entries: { idx: number; when: number; tag: string; breakpoints: boolean }[] +} + +/** + * Applies each migration in its own transaction, not batched. + * + * drizzle's `migrate()` runs all pending files in one transaction, which + * breaks when one adds an enum value and a later one uses it (Postgres: + * "unsafe use of new value"). Replaying per journal entry commits one file + * at a time. Used by both 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 + + const tempFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'drizzle-migrate-')) + 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`), + ) + } + + try { + 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 }) + } +} From 5fc44344ac5a88e9cbe2f91f71c5df2ad5227be1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 22 Jul 2026 22:27:17 +0545 Subject: [PATCH 02/49] feat(OUT-3604): add payout/settled enums, bank-deposit columns, idempotency index Add PAYOUT entity + SETTLED event, the bank_deposit_fee_flag and bank_account_ref columns (schema + migration together), extend the one-shot unique index and claimWebhookEvent predicate to cover payout/settled (byte-equivalent), and add getSuccessfulPaidPaymentIds. Stale payout claims flip terminal (no resync path). Retire the unused DEPOSITED enum value. Co-Authored-By: Claude Opus 4.8 --- src/app/api/core/types/log.ts | 2 + src/app/api/core/types/webhook.ts | 1 + .../api/quickbooks/syncLog/syncLog.service.ts | 44 +- ...0717110112_add_bank_deposit_fee_column.sql | 2 + ...0260721083213_add_payout_settled_enums.sql | 2 + ...0721100005_extend_oneshot_index_payout.sql | 6 + .../meta/20260717110112_snapshot.json | 1144 ++++++++++++++++ .../meta/20260721083213_snapshot.json | 1146 +++++++++++++++++ .../meta/20260721100005_snapshot.json | 1146 +++++++++++++++++ src/db/migrations/meta/_journal.json | 21 + src/db/schema/qbPortalConnections.ts | 1 + src/db/schema/qbSettings.ts | 4 + src/db/schema/qbSyncLogs.ts | 2 + 13 files changed, 3518 insertions(+), 3 deletions(-) create mode 100644 src/db/migrations/20260717110112_add_bank_deposit_fee_column.sql create mode 100644 src/db/migrations/20260721083213_add_payout_settled_enums.sql create mode 100644 src/db/migrations/20260721100005_extend_oneshot_index_payout.sql create mode 100644 src/db/migrations/meta/20260717110112_snapshot.json create mode 100644 src/db/migrations/meta/20260721083213_snapshot.json create mode 100644 src/db/migrations/meta/20260721100005_snapshot.json 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/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/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index e79ae37b..ba866a57 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -21,7 +21,7 @@ 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 +225,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 +264,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 +286,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 +380,40 @@ export class SyncLogService extends BaseService { return log || null } + /** + * Maps Copilot invoice IDs → QBO Payment IDs from this portal's + * INVOICE/PAID/SUCCESS rows (quickbooksId holds the Payment ID there). + */ + async getSuccessfulPaidPaymentIds( + copilotInvoiceIds: string[], + ): Promise> { + if (copilotInvoiceIds.length === 0) return new Map() + + const rows = await this.db + .select({ + copilotId: QBSyncLog.copilotId, + quickbooksId: QBSyncLog.quickbooksId, + }) + .from(QBSyncLog) + .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() + for (const row of rows) { + if (row.quickbooksId) + paymentIdByInvoice.set(row.copilotId, row.quickbooksId) + } + return paymentIdByInvoice + } + async prepareSyncLogsForDownload() { const logs = await this.db.query.QBSyncLog.findMany({ where: eq(QBSyncLog.portalId, this.user.workspaceId), 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/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/_journal.json b/src/db/migrations/meta/_journal.json index 82075dc5..6c6e4b0f 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -169,6 +169,27 @@ "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 } ] } \ No newline at end of file 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') )`, ), ], From b4d6860fbd98ec624d260498aa741a29e833b52d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 22 Jul 2026 22:27:28 +0545 Subject: [PATCH 03/49] feat(OUT-3604): type the QBO createDeposit response Add QBDepositResponseSchema and refactor _createDeposit to the standard assertNotQBFault + Zod-parse pattern (returning a typed response), and parse the Undeposited Funds lookup, removing untyped {} property access. Co-Authored-By: Claude Opus 4.8 --- src/type/dto/intuitAPI.dto.ts | 42 +++++++++++++++++++++++++ src/utils/intuitAPI.ts | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index f87d770d..b54a1244 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -240,6 +240,48 @@ 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 QBDeletePayloadSchema = z.object({ SyncToken: z.string(), Id: z.string(), diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index a48b84e3..a0076008 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -13,6 +13,9 @@ import { QBPaymentCreatePayloadType, QBAccountCreatePayloadType, QBPurchaseCreatePayloadType, + QBDepositCreatePayloadType, + QBDepositResponseSchema, + QBDepositResponseType, QBDeletePayloadType, QBDestructiveInvoicePayloadSchema, QBItemRowType, @@ -64,6 +67,7 @@ export type IntuitAPITokensType = Pick< | 'assetAccountRef' | 'serviceItemRef' | 'clientFeeRef' + | 'bankAccountRef' > & { isSuspended?: boolean } export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#' @@ -976,6 +980,32 @@ 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 + } + async _deletePurchase( payload: QBDeletePayloadType, ): Promise { @@ -1016,6 +1046,33 @@ 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 { + const rawResult = await this.customQuery( + `SELECT Id FROM Account WHERE AccountSubType = 'UndepositedFunds' AND Active = true maxresults 1`, + ) + const undepositedAccount = QBAccountQueryResponseSchema.parse( + rawResult ?? {}, + ).Account?.[0] + if (undepositedAccount?.Id) { + return undepositedAccount.Id + } + + const byName = await this.getAnAccount('Undeposited Funds') + if (byName?.Id) { + 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 +1119,6 @@ export default class IntuitAPI { createPurchase = this.wrapWithRetry(this._createPurchase) deletePayment = this.wrapWithRetry(this._deletePayment) deletePurchase = this.wrapWithRetry(this._deletePurchase) + createDeposit = this.wrapWithRetry(this._createDeposit) getCompanyInfo = this._getCompanyInfo.bind(this) } From 8439c5f89eba75a9a264491373a8cb9f2f6d8813 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 22 Jul 2026 23:33:04 +0545 Subject: [PATCH 04/49] feat(OUT-3604): resolve the bank account ref for payout deposits Add AccountTypeObj.Bank so checkAndUpdateAccountStatus reactivates an archived bank account; a deleted one throws (never auto-restore a deposit destination). Thread bankAccountRef through every IntuitAPITokensType construction site (extractTokens, getRefreshedQbTokenInfo, auth exchange + emptyTokens, getPortalTokens, rename-accounts cmd) so the now-required field is always populated. Co-Authored-By: Claude Opus 4.8 --- src/app/api/quickbooks/auth/auth.service.ts | 2 ++ src/app/api/quickbooks/token/token.service.ts | 10 ++++++++++ src/cmd/renameQbAccount/renameQbAccount.service.ts | 1 + src/constant/qbConnection.ts | 1 + src/db/service/token.service.ts | 1 + src/utils/tokenRefresh.ts | 2 ++ 6 files changed, 17 insertions(+) diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index ac555df4..404fa8a9 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -140,6 +140,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( @@ -247,6 +248,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/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/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/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/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/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 = { From b10096690aafd34cb4c13a442408d6748aa321f9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 22 Jul 2026 23:33:19 +0545 Subject: [PATCH 05/49] feat(OUT-3604): create one batched bank deposit per Stripe payout Handle payout.reconciliation_completed: resolve each invoice to its QBO Payment, assert sum(gross)-sum(fee)==netAmount in cents, and create one Bank Deposit (N payment lines + one fee line). Abort with a FAILED log on refund lines, negative aggregate fee, duplicate/unresolved invoices, or a mismatch. Reshape createBankDepositForPayment to the batched N-line form, drop the never-shipped per-payment deposit path (payment.succeeded no-ops in batched mode), and skip payouts in the resync dispatcher for now. On invoice.paid, route the QBO Payment through Undeposited Funds when batched mode is on (DepositToAccountRef) so the payout deposit can link and sweep it. Co-Authored-By: Claude Opus 4.8 --- .../api/quickbooks/invoice/invoice.service.ts | 23 +- .../api/quickbooks/payment/payment.service.ts | 66 ++++++ src/app/api/quickbooks/sync/sync.service.ts | 11 + .../api/quickbooks/webhook/webhook.service.ts | 206 +++++++++++++++++- src/type/dto/intuitAPI.dto.ts | 5 + src/type/dto/webhook.dto.ts | 27 +++ 6 files changed, 334 insertions(+), 4 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index f49ac63f..7987c0fd 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -914,11 +914,33 @@ export class InvoiceService extends BaseService { ) const invoiceAmount = Number(z.string().parse(invoiceLog.amount)) / 100 + + // Batched-deposit mode routes the payment through Undeposited Funds so the + // payout deposit can later link and sweep it into the bank. + const settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId([ + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + ]) + const useBankDepositFlow = + setting?.absorbedFeeFlag && setting?.bankDepositFeeFlag + + const intuitApi = new IntuitAPI(qbTokenInfo) + + let depositToAccountRef: { value: string } | undefined + if (useBankDepositFlow) { + const undepositedFundsRef = await intuitApi.getUndepositedFundsAccountId() + depositToAccountRef = { value: undepositedFundsRef } + } + const qbPaymentPayload = { TotalAmt: invoiceAmount, CustomerRef: { value: existingCustomer.qbCustomerId, }, + ...(depositToAccountRef && { + DepositToAccountRef: depositToAccountRef, + }), Line: [ { Amount: invoiceAmount, @@ -931,7 +953,6 @@ export class InvoiceService extends BaseService { }, ], } - const intuitApi = new IntuitAPI(qbTokenInfo) const paymentService = new PaymentService(this.user) const customerDisplayName = diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index fd608761..1db4b893 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,69 @@ 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 = opts.lines.map((line) => ({ + Amount: line.amount, + LinkedTxn: [ + { + TxnId: line.qbPaymentId, + TxnType: 'Payment' as const, + TxnLineId: '0', + }, + ], + })) + + const feeLine = { + 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, + // feeTotal is always >= 0 (caller rejects negative): 0 = no fee line. + Line: opts.feeTotal > 0 ? [...paymentLines, feeLine] : 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/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index efd93239..e83746e2 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -401,6 +401,17 @@ export class SyncService extends BaseService { const authService = new AuthService(this.user) for (const log of logs) { + // TODO: no PAYOUT resync path yet — skip so terminal payout rows don't + // burn attempts to a misleading alert. Auto-recovery is a follow-up. + if (log.entityType === EntityType.PAYOUT) { + CustomLogger.info({ + message: + 'SyncService#intiateSync | Skipping payout log (no resync path)', + obj: { copilotId: log.copilotId, workspaceId: this.user.workspaceId }, + }) + continue + } + // check and update attempt for failed logs const resyncAttemtps = await this.checkAndUpdateAttempt(log) if (resyncAttemtps.maxAttempts) { diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index d29e3c08..a4760b1d 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -14,6 +14,7 @@ import { InvoiceDeletedResponseSchema, InvoiceResponseSchema, PaymentSucceededResponseSchema, + PayoutReconciliationCompletedSchema, ProductCreatedResponseSchema, ProductUpdatedResponseSchema, WebhookEventResponseSchema, @@ -22,13 +23,15 @@ import { import { validateAccessToken } from '@/utils/auth' import { CopilotAPI } from '@/utils/copilotAPI' import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error' -import { IntuitAPITokensType } from '@/utils/intuitAPI' +import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' import CustomLogger from '@/utils/logger' import { sleep } from '@/utils/sleep' import { getCategory, getShouldRetryForCategory } from '@/utils/synclog' import { addSyncBreadcrumb } from '@/utils/sentry' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' +import { AccountTypeObj } from '@/constant/qbConnection' +import { TokenService } from '@/app/api/quickbooks/token/token.service' export class WebhookService extends BaseService { async handleWebhookEvent( @@ -109,6 +112,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') } @@ -485,7 +494,10 @@ export class WebhookService extends BaseService { 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']) + const setting = await settingService.getOneByPortalId([ + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + ]) if (!setting?.absorbedFeeFlag) { console.info( @@ -494,13 +506,22 @@ export class WebhookService extends BaseService { return } + if (setting.bankDepositFeeFlag) { + // Batched mode: deposit happens on payout.reconciliation_completed. + // Return before claiming so no stale PENDING row is left behind. + console.info( + 'WebhookService#handlePaymentSucceeded | Batched-deposit mode; deferring deposit to payout event', + ) + return + } + if (opts.delayMs) await sleep(opts.delayMs) const syncLogService = new SyncLogService(this.user) const { claimed } = await syncLogService.claimWebhookEvent({ copilotId: parsedPaymentSucceedResource.data.id, - entityType: EntityType.PAYMENT, eventType: EventType.SUCCEEDED, + entityType: EntityType.PAYMENT, }) if (!claimed) { console.info( @@ -565,4 +586,183 @@ export class WebhookService extends BaseService { } } } + + 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 settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId([ + 'bankDepositFeeFlag', + ]) + if (!setting?.bankDepositFeeFlag) { + console.info( + 'WebhookService#handlePayoutReconciliationCompleted | Batching disabled (bankDepositFeeFlag off)', + ) + return + } + + const syncLogService = new SyncLogService(this.user) + const { claimed } = await syncLogService.claimWebhookEvent({ + copilotId: payout.id, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + }) + if (!claimed) { + console.info( + `WebhookService#handlePayoutReconciliationCompleted | Already claimed (payout/${EventType.SETTLED}, copilotId=${payout.id}), skipping`, + ) + return + } + + // Computed before the try so the FAILED-log path can record the amounts. + const grossCents = lineItems.reduce((sum, l) => sum + l.grossAmount, 0) + const feeCents = lineItems.reduce((sum, l) => sum + l.feeAmount, 0) + + try { + validateAccessToken(qbTokenInfo) + + // v1: refunds unsupported — a negative line means QBO cannot link to a Payment. + if (lineItems.some((line) => line.grossAmount < 0)) { + throw new APIError( + httpStatus.BAD_REQUEST, + `Payout ${payout.id} contains refund lines; batched deposit unsupported in v1`, + ) + } + + // A negative total fee would drop the fee line and unbalance the + // deposit. Abort instead (fee credits arrive with refund support). + if (feeCents < 0) { + throw new APIError( + httpStatus.BAD_REQUEST, + `Payout ${payout.id} has a negative aggregate fee (${feeCents}); unsupported in v1`, + ) + } + + const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) + if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) { + throw new APIError( + httpStatus.BAD_REQUEST, + `Payout ${payout.id} contains duplicate invoice line items`, + ) + } + const paymentIdByInvoice = + await syncLogService.getSuccessfulPaidPaymentIds(copilotInvoiceIds) + const unresolved = copilotInvoiceIds.filter( + (id) => !paymentIdByInvoice.has(id), + ) + if (unresolved.length > 0) { + throw new APIError( + httpStatus.NOT_FOUND, + `Payout ${payout.id}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`, + ) + } + + if (grossCents - feeCents !== payout.netAmount) { + throw new APIError( + httpStatus.BAD_REQUEST, + `Payout ${payout.id}: deposit total ${grossCents - feeCents} != payout net ${payout.netAmount}`, + ) + } + + // Fail fast on the free local check before any QBO round-trip. + const bankAccountRef = qbTokenInfo.bankAccountRef + if (!bankAccountRef) { + 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) + // Reactivates an archived bank account; a deleted one throws. + 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, + ) as string, + amount: line.grossAmount / 100, + })), + feeTotal: feeCents / 100, + bankAccountRef: verifiedBankAccountRef, + expenseAccountRef, + txnDate: new Date(payout.arrivalDate * 1000) + .toISOString() + .split('T')[0], + privateNote: `Stripe payout ${payout.id}`, + }, + ) + + await syncLogService.updateOrCreateQBSyncLog({ + portalId: this.user.workspaceId, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.SUCCESS, + copilotId: payout.id, + quickbooksId: depositId, + 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 errorWithCode = getMessageAndCodeFromError(error) + await syncLogService.updateOrCreateQBSyncLog({ + portalId: this.user.workspaceId, + entityType: EntityType.PAYOUT, + eventType: EventType.SETTLED, + status: LogStatus.FAILED, + copilotId: payout.id, + amount: payout.netAmount.toFixed(2), + feeAmount: feeCents.toFixed(2), + remark: 'Stripe payout batched deposit', + qbItemName: 'Stripe payout', + errorMessage: errorWithCode.message, + errorCode: errorWithCode.code?.toString(), + // Terminal: no PAYOUT resync path yet, so retrying only burns + // attempts to a misleading alert. Recovery is manual for now. + shouldRetry: false, + category: getCategory(errorWithCode), + }) + console.error( + `WebhookService#handlePayoutReconciliationCompleted :: Error | Portal Id: ${this.user.workspaceId} | Payout: ${payout.id}`, + ) + return + } + } } diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index b54a1244..3620b989 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(), diff --git a/src/type/dto/webhook.dto.ts b/src/type/dto/webhook.dto.ts index 8e2c3f56..9f4d3e54 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,29 @@ export const PaymentSucceededResponseSchema = z.object({ export type PaymentSucceededResponseType = z.infer< typeof PaymentSucceededResponseSchema > + +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( + z.object({ + copilotInvoiceId: z.string(), + grossAmount: z.number(), + feeAmount: z.number(), + }), + ) + .min(1), + }), +}) +export type PayoutReconciliationCompletedType = z.infer< + typeof PayoutReconciliationCompletedSchema +> From 28a74e31f5db6274c2c73861ea123f1a8624266b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 10:33:14 +0545 Subject: [PATCH 06/49] fix(OUT-3604): serialize per-file migrations and use them in test setup Greptile PR #266 review: - migratePerFile now wraps its loop in a Postgres advisory lock so concurrent deploy runners can't race the same pending migration (e.g. one dropping an index before the other's DROP). - globalSetup now applies migrations via migratePerFile instead of drizzle's batched migrate(), so a fresh integration DB can apply the enum-add-then-use sequence (was failing at setup). Co-Authored-By: Claude Opus 4.8 --- src/db/migratePerFile.ts | 35 +++++++++++++++++++++------------ test/integration/globalSetup.ts | 7 ++++--- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/db/migratePerFile.ts b/src/db/migratePerFile.ts index 0f7d9622..cae4e7f2 100644 --- a/src/db/migratePerFile.ts +++ b/src/db/migratePerFile.ts @@ -3,6 +3,7 @@ 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 @@ -10,13 +11,16 @@ type Journal = { 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 each migration in its own transaction, not batched. + * Applies migrations one-per-transaction under a session advisory lock. * - * drizzle's `migrate()` runs all pending files in one transaction, which - * breaks when one adds an enum value and a later one uses it (Postgres: - * "unsafe use of new value"). Replaying per journal entry commits one file - * at a time. Used by both globalSetup and the prod runner (src/db/migrate.ts). + * 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, @@ -26,16 +30,18 @@ export async function migratePerFile>( fs.readFileSync(path.join(migrationsFolder, 'meta/_journal.json'), 'utf-8'), ) as Journal - const tempFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'drizzle-migrate-')) - 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`), - ) - } + 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'), @@ -48,5 +54,8 @@ export async function migratePerFile>( } } finally { fs.rmSync(tempFolder, { recursive: true, force: true }) + await db.execute( + sql`SELECT pg_advisory_unlock(${MIGRATION_ADVISORY_LOCK_KEY})`, + ) } } 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() } From 93bdd8ba7c8ae3b7752d52a11201b7feebd35779 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 13:07:29 +0545 Subject: [PATCH 07/49] refactor(OUT-3604): address PR #266 review nits - invoice.service: simplify Undeposited-Funds ref to a ternary. - payment.service: type paymentLines and build the fee line via push when feeTotal > 0 instead of a spread. - webhook.service: accumulate gross/fee cents in a single reduce. Co-Authored-By: Claude Opus 4.8 --- .../api/quickbooks/invoice/invoice.service.ts | 10 ++--- .../api/quickbooks/payment/payment.service.ts | 41 ++++++++++--------- .../api/quickbooks/webhook/webhook.service.ts | 10 ++++- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 7987c0fd..361c5d9e 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -927,11 +927,9 @@ export class InvoiceService extends BaseService { const intuitApi = new IntuitAPI(qbTokenInfo) - let depositToAccountRef: { value: string } | undefined - if (useBankDepositFlow) { - const undepositedFundsRef = await intuitApi.getUndepositedFundsAccountId() - depositToAccountRef = { value: undepositedFundsRef } - } + const depositToAccountRef = useBankDepositFlow + ? await intuitApi.getUndepositedFundsAccountId() + : undefined const qbPaymentPayload = { TotalAmt: invoiceAmount, @@ -939,7 +937,7 @@ export class InvoiceService extends BaseService { value: existingCustomer.qbCustomerId, }, ...(depositToAccountRef && { - DepositToAccountRef: depositToAccountRef, + DepositToAccountRef: { value: depositToAccountRef }, }), Line: [ { diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index 1db4b893..83a32618 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -215,32 +215,35 @@ export class PaymentService extends BaseService { feeTotal: opts.feeTotal, }) - const paymentLines = opts.lines.map((line) => ({ - Amount: line.amount, - LinkedTxn: [ - { - TxnId: line.qbPaymentId, - TxnType: 'Payment' as const, - TxnLineId: '0', - }, - ], - })) + const paymentLines: Required['Line'] = + opts.lines.map((line) => ({ + Amount: line.amount, + LinkedTxn: [ + { + TxnId: line.qbPaymentId, + TxnType: 'Payment' as const, + TxnLineId: '0', + }, + ], + })) - const feeLine = { - Amount: -opts.feeTotal, - DetailType: 'DepositLineDetail' as const, - DepositLineDetail: { - AccountRef: { value: opts.expenseAccountRef }, - }, - Description: 'Stripe processing fees', + // 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, - // feeTotal is always >= 0 (caller rejects negative): 0 = no fee line. - Line: opts.feeTotal > 0 ? [...paymentLines, feeLine] : paymentLines, + Line: paymentLines, } const parsedPayload = QBDepositCreatePayloadSchema.parse(depositPayload) diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index a4760b1d..0f027cb0 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -628,8 +628,14 @@ export class WebhookService extends BaseService { } // Computed before the try so the FAILED-log path can record the amounts. - const grossCents = lineItems.reduce((sum, l) => sum + l.grossAmount, 0) - const feeCents = lineItems.reduce((sum, l) => sum + l.feeAmount, 0) + const { grossCents, feeCents } = lineItems.reduce( + (acc, line) => { + acc.grossCents += line.grossAmount + acc.feeCents += line.feeAmount + return acc + }, + { grossCents: 0, feeCents: 0 }, + ) try { validateAccessToken(qbTokenInfo) From 9d4724a0b758324dee71561cd080c3b8a8e178e4 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 10:13:42 +0545 Subject: [PATCH 08/49] feat(OUT-4003): add bankDepositFeeFlag + bankAccountRef to settings schema SettingRequestSchema gains bankDepositFeeFlag and bankAccountRef; for invoice type the flag is required and a non-empty bankAccountRef is required when the flag is enabled. InvoiceSettingType carries both fields. Co-Authored-By: Claude Opus 4.8 --- src/type/common.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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 From 47184f7139ef7cb141f10d0be86db408f34c3486 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 10:13:56 +0545 Subject: [PATCH 09/49] feat(OUT-4003): GET /setting/bank-account lists QBO bank accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoint backed by BankAccountService (route → controller → service). Lists active QBO Bank-type accounts to populate the deposit-account dropdown; selects the full account column set so the response parses against real QBO output, capped at maxresults 100. Co-Authored-By: Claude Opus 4.8 --- .../bank-account/bank-account.controller.ts | 21 +++++++++++++++++++ .../bank-account/bank-account.service.ts | 12 +++++++++++ .../quickbooks/setting/bank-account/route.ts | 4 ++++ 3 files changed, 37 insertions(+) create mode 100644 src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts create mode 100644 src/app/api/quickbooks/setting/bank-account/bank-account.service.ts create mode 100644 src/app/api/quickbooks/setting/bank-account/route.ts 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..bd47c4a0 --- /dev/null +++ b/src/app/api/quickbooks/setting/bank-account/bank-account.service.ts @@ -0,0 +1,12 @@ +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) { + const rawResult = await intuitApi.customQuery( + `SELECT ${QB_ACCOUNT_COLUMNS.join(', ')} FROM Account WHERE AccountType = 'Bank' AND Active = true maxresults 100`, + ) + 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) From b4a60fda7483786e42c556810e03756e6e7683dc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 10:14:06 +0545 Subject: [PATCH 10/49] feat(OUT-4003): return + persist bankAccountRef in invoice settings getSettings returns bankAccountRef and bankDepositFeeFlag for invoice type; updateSettings persists bankAccountRef transactionally with the settings write (empty coerced to null), with setTransaction/unsetTransaction paired for both services. Co-Authored-By: Claude Opus 4.8 --- .../quickbooks/setting/setting.controller.ts | 57 ++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index 63f911a1..ff168e55 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,6 +1,10 @@ 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 { 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 +26,23 @@ 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 }) + + let bankAccountRef: string | null = null + if (parsedType.success && parsedType.data === SettingType.INVOICE) { + const portalConnection = await getPortalConnection(user.workspaceId) + bankAccountRef = portalConnection?.bankAccountRef || null + } + + return NextResponse.json({ setting, bankAccountRef }) } export async function updateSettings(req: NextRequest) { @@ -39,15 +54,43 @@ export async function updateSettings(req: NextRequest) { const parsedType = z.nativeEnum(SettingType).parse(type) + const parsed = SettingRequestSchema.parse(body) + const { bankAccountRef, ...settingFields } = parsed + const payload = { - ...SettingRequestSchema.parse(body), + ...settingFields, ...(parsedType === SettingType.INVOICE ? { initialInvoiceSettingMap: true } : { initialProductSettingMap: true }), } - const setting = await settingService.updateQBSettings( - payload, - eq(QBSetting.portalId, user.workspaceId), - ) + + const writeBankAccountRef = + parsedType === SettingType.INVOICE && 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 }) } From 9d1e2584ed54048cdf7f4eef66c19c959042d081 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 10:14:16 +0545 Subject: [PATCH 11/49] feat(OUT-4003): bank-deposit toggle + deposit bank account dropdown Extract the shared AccountSelect dropdown; add an independent bankDepositFeeFlag toggle and a "Deposit bank account" dropdown in InvoiceDetail (fed by the bank-account endpoint), with an inline hint / error and a canSave gate that disables the Update button until an account is chosen. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/settings/SettingAccordion.tsx | 6 + .../sections/account/AccountMapping.tsx | 104 +----------------- .../sections/account/AccountSelect.tsx | 102 +++++++++++++++++ .../sections/invoice/InvoiceDetail.tsx | 50 ++++++++- src/hook/useSettings.ts | 43 ++++++-- 5 files changed, 193 insertions(+), 112 deletions(-) create mode 100644 src/components/dashboard/settings/sections/account/AccountSelect.tsx diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index 33bb6a90..4105c997 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -44,6 +44,9 @@ export default function SettingAccordion({ isLoading, changeSettings, showButton: showInvoiceButton, + bankAccountOptions, + bankAccountsError, + canSave, } = useInvoiceDetailSettings() const { @@ -86,6 +89,8 @@ export default function SettingAccordion({ settingState={settingState} changeSettings={changeSettings} isLoading={isLoading} + bankAccountOptions={bankAccountOptions} + bankAccountsError={bankAccountsError} /> ), }, @@ -166,6 +171,7 @@ export default function SettingAccordion({ } variant="primary" prefixIcon="Check" + disabled={!canSave} onClick={submitInvoiceSettings} /> 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 ( -
- -

{description}

-
- - {isOpen && !disabled && ( -
-
- {options?.map((o) => ( - - ))} -
-
- )} -
-
- ) -} - 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..093fc57a --- /dev/null +++ b/src/components/dashboard/settings/sections/account/AccountSelect.tsx @@ -0,0 +1,102 @@ +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 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 ( +
+ +

{description}

+
+ + {isOpen && !disabled && ( +
+
+ {options?.map((o) => ( + + ))} +
+
+ )} +
+
+ ) +} diff --git a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx index 0eb8701f..011db0da 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -1,18 +1,26 @@ 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: keyof InvoiceSettingType, + value: boolean | string, + ) => void isLoading: boolean + bankAccountOptions: AccountOption[] | undefined + bankAccountsError: unknown } export default function InvoiceDetail({ settingState, changeSettings, isLoading, + bankAccountOptions, + bankAccountsError, }: InvoiceDetailProps) { const { workspace } = useApp() @@ -33,6 +41,44 @@ export default function InvoiceDetail({ } /> +
+ + changeSettings( + 'bankDepositFeeFlag', + !settingState.bankDepositFeeFlag, + ) + } + /> +
+ {settingState.bankDepositFeeFlag && ( +
+ {bankAccountsError ? ( +

+ Could not load bank accounts. Reload to retry. +

+ ) : ( + <> + changeSettings('bankAccountRef', id)} + /> + {!settingState.bankAccountRef && ( +

+ Select a deposit bank account to enable bank deposits. +

+ )} + + )} +
+ )}
{ 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, ) @@ -448,16 +453,31 @@ export const useInvoiceDetailSettings = () => { isLoading, } = useSwrHelper(`/api/quickbooks/setting?type=invoice&token=${token}`) + const { data: bankAccountsData, error: bankAccountsError } = useSwrHelper<{ + accounts: { Id: string; Name: string }[] + }>( + isDisconnected + ? 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: keyof InvoiceSettingType, - state: boolean, + value: boolean | string, ) => { - setSettingState((prev) => ({ - ...prev, - [flag]: state, - })) + setSettingState((prev) => ({ ...prev, [flag]: value })) } + const canSave = !( + settingState.bankDepositFeeFlag && !settingState.bankAccountRef + ) + useEffect(() => { if (!settingState || !intialSettingState) return const showButton = !equal(intialSettingState, settingState) @@ -466,8 +486,12 @@ export const useInvoiceDetailSettings = () => { 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, @@ -508,6 +532,9 @@ export const useInvoiceDetailSettings = () => { error, isLoading, showButton, + bankAccountOptions, + bankAccountsError, + canSave, } } From d2c23b298fb79f48d067096ac85b3490fc4c2dcc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 11:23:33 +0545 Subject: [PATCH 12/49] fix(OUT-4003): address PR #267 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountSelect distinguishes the loading state (options undefined → "Loading accounts…") from the genuinely-empty state ("No matching accounts"), and InvoiceDetail only shows the "select an account" hint once options have loaded — so enabling the toggle mid-fetch no longer shows a misleading empty message. - changeSettings is now generic over the field key ((flag: K, value: InvoiceSettingType[K])), so the value type is tied to the field and e.g. a string can't be passed for a boolean flag. Co-Authored-By: Claude Opus 4.8 --- .../settings/sections/account/AccountSelect.tsx | 7 ++++++- .../settings/sections/invoice/InvoiceDetail.tsx | 17 +++++++++-------- src/hook/useSettings.ts | 6 +++--- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/components/dashboard/settings/sections/account/AccountSelect.tsx b/src/components/dashboard/settings/sections/account/AccountSelect.tsx index 093fc57a..32f3e471 100644 --- a/src/components/dashboard/settings/sections/account/AccountSelect.tsx +++ b/src/components/dashboard/settings/sections/account/AccountSelect.tsx @@ -24,6 +24,7 @@ export default function AccountSelect({ 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 — @@ -57,7 +58,11 @@ export default function AccountSelect({ ) : ( - {disabled ? 'No matching accounts in QuickBooks' : placeholder} + {loading + ? 'Loading accounts…' + : disabled + ? 'No matching accounts in QuickBooks' + : placeholder} )}
diff --git a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx index 011db0da..606883d7 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -6,9 +6,9 @@ import { Checkbox, Spinner } from 'copilot-design-system' type InvoiceDetailProps = { settingState: InvoiceSettingType - changeSettings: ( - flag: keyof InvoiceSettingType, - value: boolean | string, + changeSettings: ( + flag: K, + value: InvoiceSettingType[K], ) => void isLoading: boolean bankAccountOptions: AccountOption[] | undefined @@ -70,11 +70,12 @@ export default function InvoiceDetail({ placeholder="Select a deposit bank account" onChange={(id) => changeSettings('bankAccountRef', id)} /> - {!settingState.bankAccountRef && ( -

- Select a deposit bank account to enable bank deposits. -

- )} + {bankAccountOptions !== undefined && + !settingState.bankAccountRef && ( +

+ Select a deposit bank account to enable bank deposits. +

+ )} )} diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index 3957afa6..244aee9f 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -467,9 +467,9 @@ export const useInvoiceDetailSettings = () => { name: account.Name, })) - const changeSettings = async ( - flag: keyof InvoiceSettingType, - value: boolean | string, + const changeSettings = async ( + flag: K, + value: InvoiceSettingType[K], ) => { setSettingState((prev) => ({ ...prev, [flag]: value })) } From 6268fabe91fb68190091a9689e723630f483dab6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:28:20 +0545 Subject: [PATCH 13/49] fix(OUT-4003): resolve exhaustive-deps warnings in useSettings Add the missing effect dependencies flagged by react-hooks/exhaustive-deps, hoist emptyMappedItem to a module singleton, and memoize the formatted QuickBooks item list so useMapItem's effect keeps a stable reference. Co-Authored-By: Claude Opus 4.8 --- src/hook/useSettings.ts | 66 ++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index 244aee9f..5d0ceaf0 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, @@ -482,7 +480,7 @@ export const useInvoiceDetailSettings = () => { if (!settingState || !intialSettingState) return const showButton = !equal(intialSettingState, settingState) setShowButton(showButton) - }, [settingState]) + }, [settingState, intialSettingState]) useEffect(() => { if (setting && setting?.setting) { @@ -501,7 +499,7 @@ export const useInvoiceDetailSettings = () => { setting.setting.initialProductSettingMap, })) } - }, [setting]) + }, [setting, setAppParams]) const submitInvoiceSettings = async () => { setShowButton(false) From 6dc3b179bc76d2ec2445ee1cf068f1a2706aaeb6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:28:20 +0545 Subject: [PATCH 14/49] fix(OUT-4003): raise bank account query cap to 1000 100 could miss active bank accounts; 1000 is QBO's max single-page size and returns them all in one query. Co-Authored-By: Claude Opus 4.8 --- .../quickbooks/setting/bank-account/bank-account.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index bd47c4a0..d55d303f 100644 --- a/src/app/api/quickbooks/setting/bank-account/bank-account.service.ts +++ b/src/app/api/quickbooks/setting/bank-account/bank-account.service.ts @@ -4,8 +4,10 @@ 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 100`, + `SELECT ${QB_ACCOUNT_COLUMNS.join(', ')} FROM Account WHERE AccountType = 'Bank' AND Active = true maxresults 1000`, ) return QBAccountQueryResponseSchema.parse(rawResult ?? {}).Account ?? [] } From f6f3cfef5635544510b5fc2d4a70beeb5aea864a Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:35:02 +0545 Subject: [PATCH 15/49] fix(OUT-4003): fold getSettings bankAccountRef into a single expression Collapse the let + if into a ternary that reads bankAccountRef directly off the portal connection for INVOICE settings, and drop the now-unused NotNull import. Co-Authored-By: Claude Opus 4.8 --- src/app/api/quickbooks/setting/setting.controller.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index ff168e55..5c69f940 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -36,11 +36,10 @@ export async function getSettings(req: NextRequest) { } const setting = await settingService.getOneByPortalId(returningFields) - let bankAccountRef: string | null = null - if (parsedType.success && parsedType.data === SettingType.INVOICE) { - const portalConnection = await getPortalConnection(user.workspaceId) - bankAccountRef = portalConnection?.bankAccountRef || null - } + const bankAccountRef = + parsedType.success && parsedType.data === SettingType.INVOICE + ? (await getPortalConnection(user.workspaceId))?.bankAccountRef || null + : null return NextResponse.json({ setting, bankAccountRef }) } From cf7f0c84bd9e040e6de73cf01d371561de0e900f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 24 Jul 2026 14:09:43 +0545 Subject: [PATCH 16/49] fix(OUT-3604): select full account columns in getUndepositedFundsAccountId QBAccountQueryResponseSchema requires Id/Name/SyncToken/Active/AccountType, so selecting only Id failed the parse. Query QB_ACCOUNT_COLUMNS and add lookup logging. Co-Authored-By: Claude Opus 4.8 --- src/utils/intuitAPI.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index a0076008..6e6cb698 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -1052,18 +1052,32 @@ export default class IntuitAPI { * 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 Id FROM Account WHERE AccountSubType = 'UndepositedFunds' AND Active = true maxresults 1`, + `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 } From fbd4c7ffab739eef389ef77cb2d514d519e95d48 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 24 Jul 2026 14:09:47 +0545 Subject: [PATCH 17/49] fix(OUT-3604): route paid-on-create payments through Undeposited Funds webhookInvoiceCreated's paid branch created the payment without DepositToAccountRef, so in batched-deposit mode it deposited straight to the bank and the later payout deposit could not link it. Extract resolveDepositToAccountRef, use it in both paid paths, and decouple the routing from absorbedFeeFlag. Co-Authored-By: Claude Opus 4.8 --- .../api/quickbooks/invoice/invoice.service.ts | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 361c5d9e..5a9f06de 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -480,6 +480,21 @@ export class InvoiceService extends BaseService { return { value: serviceItemRef } } + // Batched-deposit mode routes the payment through Undeposited Funds so the + // payout deposit can later link and sweep it into the bank. Returns + // undefined when batching is off, letting QBO use its default account. + private async resolveDepositToAccountRef( + intuitApi: IntuitAPI, + ): Promise { + const settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId([ + 'bankDepositFeeFlag', + ]) + return setting?.bankDepositFeeFlag + ? await intuitApi.getUndepositedFundsAccountId() + : undefined + } + /** * Pre-flights QBO for invoices whose DocNumber starts with the Assembly * invoice number and returns the lowest free slot (``, `-1`, …). @@ -815,11 +830,20 @@ export class InvoiceService extends BaseService { */ if (invoiceResource.status === InvoiceStatus.PAID) { const paymentService = new PaymentService(this.user) + // Same batched-deposit routing as invoice.paid: a paid-on-create + // payment must land in Undeposited Funds so the payout deposit can + // sweep it, otherwise it deposits straight to the bank and the batched + // deposit can't link it. + const depositToAccountRef = + await this.resolveDepositToAccountRef(intuitApiService) const qbPaymentPayload = { TotalAmt: totalWithTax, CustomerRef: { value: customerRefValue, }, + ...(depositToAccountRef && { + DepositToAccountRef: { value: depositToAccountRef }, + }), Line: [ { Amount: totalWithTax, @@ -915,21 +939,8 @@ export class InvoiceService extends BaseService { const invoiceAmount = Number(z.string().parse(invoiceLog.amount)) / 100 - // Batched-deposit mode routes the payment through Undeposited Funds so the - // payout deposit can later link and sweep it into the bank. - const settingService = new SettingService(this.user) - const setting = await settingService.getOneByPortalId([ - 'absorbedFeeFlag', - 'bankDepositFeeFlag', - ]) - const useBankDepositFlow = - setting?.absorbedFeeFlag && setting?.bankDepositFeeFlag - const intuitApi = new IntuitAPI(qbTokenInfo) - - const depositToAccountRef = useBankDepositFlow - ? await intuitApi.getUndepositedFundsAccountId() - : undefined + const depositToAccountRef = await this.resolveDepositToAccountRef(intuitApi) const qbPaymentPayload = { TotalAmt: invoiceAmount, From 1b45d7fa43be6528d4b78c539cb79433ce24c077 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:46:36 +0545 Subject: [PATCH 18/49] test(OUT-4006): add shared payout test infra Mock QB_ACCOUNT_COLUMNS on the intuitAPI mock, add createDeposit and account-status (getAnAccount/updateAccount) mocks, and seed TEST_BANK_ACCOUNT_REF on the portal connection. Co-Authored-By: Claude Opus 4.8 --- test/helpers/mocks.ts | 3 +++ test/helpers/seed.ts | 1 + test/integration/setup.ts | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index b11d0d79..a4010725 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -145,6 +145,9 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { createPurchase: vi.fn().mockResolvedValue({ Purchase: { Id: TEST_QB_PURCHASE_ID, SyncToken: '0' }, }), + createDeposit: vi.fn().mockResolvedValue({ + Deposit: { Id: 'qb-deposit-1', SyncToken: '0' }, + }), deletePurchase: vi.fn().mockResolvedValue({ Purchase: { Id: TEST_QB_PURCHASE_ID, status: 'Deleted' }, }), diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index 8d5c9d63..a4faf15a 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -20,6 +20,7 @@ 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_INTERNAL_USER_ID = 'test-internal-user-id' export const TEST_WEBHOOK_TOKEN = 'test-token-xyz' diff --git a/test/integration/setup.ts b/test/integration/setup.ts index 2e210acd..e7bdd268 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`, From 542a108ceeaa5a84de7311801e9171e0b43a4469 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:46:38 +0545 Subject: [PATCH 19/49] test(OUT-4006): cover payout reconciliation flow Happy path plus abort guards (refund line, negative fee, sum mismatch, duplicate/empty/unresolved line items), idempotent redelivery, flag-off no-op, inactive/deleted bank account, claim idempotency, and invoice->QBO-payment resolution. Co-Authored-By: Claude Opus 4.8 --- test/fixtures/payout.webhook.ts | 25 ++++ .../bankAccountDeleted.test.ts | 87 ++++++++++++++ .../bankAccountInactive.test.ts | 112 ++++++++++++++++++ .../claimIdempotency.test.ts | 32 +++++ .../duplicateLineItems.test.ts | 84 +++++++++++++ .../emptyLineItems.test.ts | 47 ++++++++ .../payoutReconciliation/flagOff.test.ts | 50 ++++++++ .../payoutReconciliation/happyPath.test.ts | 89 ++++++++++++++ .../idempotentRedelivery.test.ts | 68 +++++++++++ .../payoutReconciliation/negativeFee.test.ts | 96 +++++++++++++++ .../refundPresent.test.ts | 98 +++++++++++++++ .../resolvePayments.test.ts | 58 +++++++++ .../payoutReconciliation/sumMismatch.test.ts | 81 +++++++++++++ .../unresolvedLine.test.ts | 67 +++++++++++ 14 files changed, 994 insertions(+) create mode 100644 test/fixtures/payout.webhook.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/claimIdempotency.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/flagOff.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/happyPath.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts 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/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts new file mode 100644 index 00000000..dfe74aff --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts @@ -0,0 +1,87 @@ +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, + 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 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', + }, + ]) + + 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, + shouldRetry: false, + }) + // 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..c220ffb4 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts @@ -0,0 +1,112 @@ +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, + 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 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', + }, + ]) + + 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..1620d684 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts @@ -0,0 +1,84 @@ +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() + + 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, + }) + // 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..cd933091 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/emptyLineItems.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 { 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() + + // 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/flagOff.test.ts b/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts new file mode 100644 index 00000000..20b07237 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts @@ -0,0 +1,50 @@ +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_COPILOT_INVOICE_ID } from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payout — bank deposit fee flag is off', () => { + const apis = setupPaymentSucceededTest() + + it('no-ops: no deposit is created and no payout/settled log is written', async () => { + const { portal } = await seedHealthyPortal({ + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: false }, + }) + await db.insert(QBSyncLog).values([ + { + portalId: portal.portalId, + copilotId: TEST_COPILOT_INVOICE_ID, + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_A', + }, + { + portalId: portal.portalId, + copilotId: 'inv-cop-0002', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_B', + }, + ]) + + 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(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..1eb65d87 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts @@ -0,0 +1,89 @@ +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('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 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', + }, + ]) + + 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, + quickbooksId: expect.any(String), + }) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts b/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts new file mode 100644 index 00000000..8474bf79 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.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, + 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 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', + }, + ]) + + 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/negativeFee.test.ts b/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts new file mode 100644 index 00000000..fa7b4ffc --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts @@ -0,0 +1,96 @@ +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() + + 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, + }) + // 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..24ff2e0b --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts @@ -0,0 +1,98 @@ +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() + + 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, + }) + // 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..12cae41d --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts @@ -0,0 +1,58 @@ +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, TEST_PORTAL_ID } from '@test/helpers/seed' +import { truncateAllTestTables } from '@test/helpers/testDb' + +describe('SyncLogService.getSuccessfulPaidPaymentIds', () => { + it('returns only SUCCESS INVOICE/PAID rows for this portal', async () => { + await truncateAllTestTables() + await seedHealthyPortal() + + await db.insert(QBSyncLog).values([ + { + portalId: TEST_PORTAL_ID, + copilotId: 'inv_a', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + quickbooksId: 'qbpay_a', + }, + { + portalId: TEST_PORTAL_ID, + copilotId: 'inv_b', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.FAILED, + quickbooksId: 'qbpay_b', + }, + { + portalId: 'other-portal', + copilotId: 'inv_c', + entityType: EntityType.INVOICE, + eventType: EventType.PAID, + status: LogStatus.SUCCESS, + 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')).toBe('qbpay_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..571d66ee --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts @@ -0,0 +1,81 @@ +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 — 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 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', + }, + ]) + + 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() + + 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, + }) + // 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/unresolvedLine.test.ts b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts new file mode 100644 index 00000000..dc0708a3 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts @@ -0,0 +1,67 @@ +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 — 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 has a PAID sync log; inv-cop-0002 is missing one, + // so the handler can't resolve it to a QBO payment id. + 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', + }, + ]) + + 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, + }) + // 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]', + ) + }) +}) From e57966b842dceb30b6f92d67c8c39e5630495c3b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:46:39 +0545 Subject: [PATCH 20/49] test(OUT-4006): cover batched-mode no-op and stale payout reaping payment.succeeded creates no per-payment deposit in batched mode; stale PENDING payout claims flip to non-retryable. Co-Authored-By: Claude Opus 4.8 --- .../bankDepositFlagNoOp.test.ts | 35 ++++++++++ .../syncLog/staleReaperPayoutTerminal.test.ts | 66 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts create mode 100644 test/integration/quickbooks/syncLog/staleReaperPayoutTerminal.test.ts diff --git a/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts b/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts new file mode 100644 index 00000000..b7ef4ea0 --- /dev/null +++ b/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { eq } from 'drizzle-orm' + +import { db } from '@/db' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' + +import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' +import { seedHealthyPortal, TEST_COPILOT_PAYMENT_ID } from '@test/helpers/seed' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('payment.succeeded with bankDepositFeeFlag on — no per-payment deposit', () => { + const apis = setupPaymentSucceededTest() + + it('creates neither a deposit nor a purchase', async () => { + // handlePaymentSucceeded returns immediately once bankDepositFeeFlag is + // true (the deposit is deferred to payout.reconciliation_completed), so + // no invoice sync, bank account ref, or QBO calls are ever reached here. + await seedHealthyPortal({ + setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, + }) + + const res = await postWebhook(paymentSucceededPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + + const logs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) + expect(logs.filter((l) => l.status === 'success')).toHaveLength(0) + }) +}) 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) + }) +}) From 9df35a816daa9f2efa33e399de22f00da69b0fa3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:46:41 +0545 Subject: [PATCH 21/49] test(OUT-4006): assert bankAccountRef carried on every token path Co-Authored-By: Claude Opus 4.8 --- test/unit/utils/tokenRefresh.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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 }), ]) From 2dbbe5477f11e8bda7a803f6d72932a603208a10 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 23 Jul 2026 14:46:42 +0545 Subject: [PATCH 22/49] test(OUT-4003): cover bank-account listing, invoice settings, and request schema Co-Authored-By: Claude Opus 4.8 --- .../quickbooks/setting/bankAccounts.test.ts | 67 +++++++++++ .../setting/invoiceSettings.test.ts | 111 ++++++++++++++++++ test/unit/type/settingRequestSchema.test.ts | 45 +++++++ 3 files changed, 223 insertions(+) create mode 100644 test/integration/quickbooks/setting/bankAccounts.test.ts create mode 100644 test/integration/quickbooks/setting/invoiceSettings.test.ts create mode 100644 test/unit/type/settingRequestSchema.test.ts 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/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) + }) +}) From 553b8e4098007046d2045faf40efb79c0b9d3603 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 27 Jul 2026 15:13:39 +0545 Subject: [PATCH 23/49] test(OUT-4006): strengthen payout/payment webhook assertions Assertions that passed but were strictly weaker than the code's guarantees: - assert shouldRetry:false on payout FAILED rows (hardcoded terminal invariant) - assert exact deposit id instead of expect.any(String) - assert getAnAccount not called when a guard trips before the QBO round-trip - assert the full sync-log set is empty in the batched no-op path Co-Authored-By: Claude Opus 4.8 --- .../quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts | 3 ++- .../paymentSucceeded/copilotInvoiceNotFound.test.ts | 2 ++ .../quickbooks/paymentSucceeded/invoiceSyncNotFound.test.ts | 2 ++ .../payoutReconciliation/duplicateLineItems.test.ts | 4 ++++ .../quickbooks/payoutReconciliation/emptyLineItems.test.ts | 2 ++ .../quickbooks/payoutReconciliation/flagOff.test.ts | 2 ++ .../quickbooks/payoutReconciliation/happyPath.test.ts | 3 ++- .../quickbooks/payoutReconciliation/negativeFee.test.ts | 4 ++++ .../quickbooks/payoutReconciliation/refundPresent.test.ts | 4 ++++ .../quickbooks/payoutReconciliation/sumMismatch.test.ts | 4 ++++ .../quickbooks/payoutReconciliation/unresolvedLine.test.ts | 4 ++++ 11 files changed, 32 insertions(+), 2 deletions(-) diff --git a/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts b/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts index b7ef4ea0..bef3544b 100644 --- a/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts +++ b/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts @@ -26,10 +26,11 @@ describe('payment.succeeded with bankDepositFeeFlag on — no per-payment deposi expect(apis.intuit.createDeposit).not.toHaveBeenCalled() expect(apis.intuit.createPurchase).not.toHaveBeenCalled() + // Handler returns before claiming, so no claim row exists at all. const logs = await db .select() .from(QBSyncLog) .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) - expect(logs.filter((l) => l.status === 'success')).toHaveLength(0) + expect(logs).toHaveLength(0) }) }) diff --git a/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts index 37bfd7f5..4ff0dbbb 100644 --- a/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts +++ b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts @@ -47,5 +47,7 @@ describe('POST /api/quickbooks/webhook — payment.succeeded (Copilot returns no 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/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/duplicateLineItems.test.ts b/test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts index 1620d684..26568d21 100644 --- a/test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/duplicateLineItems.test.ts @@ -65,6 +65,8 @@ describe('payout — two line items share the same invoice', () => { 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() @@ -76,6 +78,8 @@ describe('payout — two line items share the same invoice', () => { 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. diff --git a/test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts b/test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts index cd933091..f9743366 100644 --- a/test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/emptyLineItems.test.ts @@ -34,6 +34,8 @@ describe('payout — no line items on the payload', () => { 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 — diff --git a/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts b/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts index 20b07237..6750412c 100644 --- a/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts @@ -40,6 +40,8 @@ describe('payout — bank deposit fee flag is off', () => { expect(res.status).toBe(200) expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + // Flag-off no-op returns before any QBO round-trip. + expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() const logs = await db .select() diff --git a/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts b/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts index 1eb65d87..273869ca 100644 --- a/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts @@ -83,7 +83,8 @@ describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (batc entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, status: LogStatus.SUCCESS, - quickbooksId: expect.any(String), + // Deterministic: createBankDepositForPayment returns res.Deposit.Id. + quickbooksId: 'qb-deposit-1', }) }) }) diff --git a/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts b/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts index fa7b4ffc..e32e893d 100644 --- a/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/negativeFee.test.ts @@ -77,6 +77,8 @@ describe('payout — the total fee across line items is negative', () => { 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() @@ -88,6 +90,8 @@ describe('payout — the total fee across line items is negative', () => { 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. diff --git a/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts b/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts index 24ff2e0b..b4f3ad61 100644 --- a/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/refundPresent.test.ts @@ -79,6 +79,8 @@ describe('payout — a line item is a refund (negative gross amount)', () => { 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() @@ -90,6 +92,8 @@ describe('payout — a line item is a refund (negative gross amount)', () => { 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. diff --git a/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts b/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts index 571d66ee..2280a7e7 100644 --- a/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts @@ -60,6 +60,8 @@ describe('payout — reported net amount does not match the line items', () => { 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() @@ -71,6 +73,8 @@ describe('payout — reported net amount does not match the line items', () => { 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. diff --git a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts index dc0708a3..ab610619 100644 --- a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts @@ -46,6 +46,8 @@ describe('payout — one invoice has no PAID sync log', () => { 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() @@ -57,6 +59,8 @@ describe('payout — one invoice has no PAID sync log', () => { 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 unresolved-line guard specifically — not the // refund guard, the sum-mismatch guard, or the bankAccountRef guard. From c3f9ceaac61e872b24b6912e21a7427238cb5a52 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 27 Jul 2026 14:00:56 +0545 Subject: [PATCH 24/49] fix(OUT-4010): freeze batched-deposit intent per invoice Reading bankDepositFeeFlag live in both handlePaymentSucceeded and handlePayoutReconciliationCompleted desynced when the flag was toggled between a payment and its payout (fee double-booked OFF->ON, or missed and payment stranded in Undeposited Funds ON->OFF). Freeze the decision on qb_invoice_sync.is_batched_deposit at row creation; both handlers now read the frozen value. - add is_batched_deposit column (+ migration) and freeze it on invoice create / paid-on-create - resolveDepositToAccountRef takes the frozen flag; the only live read stays at the freeze point - payment.succeeded: dedupe + resolve intent before the claim; batched intent defers to the payout with zero rows - payout: per-invoice intent via getSuccessfulPaidPaymentIds; all-batched books one deposit, all-non-batched skips before claiming, mixed rejected Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 - .../batched-deposit-fee-edge-case.md | 60 + .../api/quickbooks/invoice/invoice.service.ts | 35 +- .../api/quickbooks/syncLog/syncLog.service.ts | 27 +- src/app/api/quickbooks/webhook/route.ts | 6 +- .../quickbooks/webhook/webhook.controller.ts | 53 + .../api/quickbooks/webhook/webhook.service.ts | 293 +++-- .../20260724091542_add_is_batched_deposit.sql | 1 + .../meta/20260724091542_snapshot.json | 1153 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/qbInvoiceSync.ts | 1 + 11 files changed, 1504 insertions(+), 133 deletions(-) create mode 100644 docs/stripe reconciliation/batched-deposit-fee-edge-case.md create mode 100644 src/db/migrations/20260724091542_add_is_batched_deposit.sql create mode 100644 src/db/migrations/meta/20260724091542_snapshot.json 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/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 isBatchedDeposit
for every line item"] + B --> C{All batched?} + C -->|Yes| D["✅ Create one deposit
fees folded in → 1:1 bank match"] + C -->|No| E{All non-batched?} + E -->|Yes| F["✅ No deposit
fees already booked at payment"] + E -->|"No — mixed"| G["⚠️ Skip deposit
log 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/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 5a9f06de..6a7a654f 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -480,17 +480,23 @@ export class InvoiceService extends BaseService { return { value: serviceItemRef } } - // Batched-deposit mode routes the payment through Undeposited Funds so the - // payout deposit can later link and sweep it into the bank. Returns - // undefined when batching is off, letting QBO use its default account. - private async resolveDepositToAccountRef( - intuitApi: IntuitAPI, - ): Promise { + // 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 { const settingService = new SettingService(this.user) const setting = await settingService.getOneByPortalId([ 'bankDepositFeeFlag', ]) - return setting?.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 } @@ -782,6 +788,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, @@ -791,6 +798,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']) @@ -834,8 +842,10 @@ export class InvoiceService extends BaseService { // payment must land in Undeposited Funds so the payout deposit can // sweep it, otherwise it deposits straight to the bank and the batched // deposit can't link it. - const depositToAccountRef = - await this.resolveDepositToAccountRef(intuitApiService) + const depositToAccountRef = await this.resolveDepositToAccountRef( + intuitApiService, + isBatchedDeposit, + ) const qbPaymentPayload = { TotalAmt: totalWithTax, CustomerRef: { @@ -885,6 +895,7 @@ export class InvoiceService extends BaseService { 'qbInvoiceId', 'status', 'customerId', + 'isBatchedDeposit', ]) if (!invoiceSync) { @@ -940,7 +951,10 @@ 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) + const depositToAccountRef = await this.resolveDepositToAccountRef( + intuitApi, + invoiceSync.isBatchedDeposit, + ) const qbPaymentPayload = { TotalAmt: invoiceAmount, @@ -1421,6 +1435,7 @@ export class InvoiceService extends BaseService { recipientId: recipientInfo.recipientId, customerId: customerMapId, status, + isBatchedDeposit: await this.readBankDepositFeeFlag(), }, ['id'], ) diff --git a/src/app/api/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index ba866a57..0a5b93eb 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -17,6 +17,7 @@ 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' @@ -381,20 +382,30 @@ export class SyncLogService extends BaseService { } /** - * Maps Copilot invoice IDs → QBO Payment IDs from this portal's - * INVOICE/PAID/SUCCESS rows (quickbooksId holds the Payment ID there). + * 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> { + ): Promise> { if (copilotInvoiceIds.length === 0) return new Map() const rows = await this.db .select({ copilotId: QBSyncLog.copilotId, quickbooksId: QBSyncLog.quickbooksId, + isBatchedDeposit: QBInvoiceSync.isBatchedDeposit, }) .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), @@ -406,10 +417,16 @@ export class SyncLogService extends BaseService { ), ) - const paymentIdByInvoice = new Map() + const paymentIdByInvoice = new Map< + string, + { paymentId: string; isBatchedDeposit: boolean } + >() for (const row of rows) { if (row.quickbooksId) - paymentIdByInvoice.set(row.copilotId, row.quickbooksId) + paymentIdByInvoice.set(row.copilotId, { + paymentId: row.quickbooksId, + isBatchedDeposit: row.isBatchedDeposit, + }) } return paymentIdByInvoice } diff --git a/src/app/api/quickbooks/webhook/route.ts b/src/app/api/quickbooks/webhook/route.ts index d2b3b235..0087df7e 100644 --- a/src/app/api/quickbooks/webhook/route.ts +++ b/src/app/api/quickbooks/webhook/route.ts @@ -1,6 +1,10 @@ import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -import { captureWebhookEvent } from '@/app/api/quickbooks/webhook/webhook.controller' +import { + captureWebhookEvent, + captureWebhookEventGET, +} from '@/app/api/quickbooks/webhook/webhook.controller' export const maxDuration = 300 // 5 minutes export const POST = withErrorHandler(captureWebhookEvent) +export const GET = withErrorHandler(captureWebhookEventGET) diff --git a/src/app/api/quickbooks/webhook/webhook.controller.ts b/src/app/api/quickbooks/webhook/webhook.controller.ts index fedab968..619ee34e 100644 --- a/src/app/api/quickbooks/webhook/webhook.controller.ts +++ b/src/app/api/quickbooks/webhook/webhook.controller.ts @@ -1,3 +1,4 @@ +import { WebhookEvents } from '@/app/api/core/types/webhook' import authenticate from '@/app/api/core/utils/authenticate' import { AuthService } from '@/app/api/quickbooks/auth/auth.service' import { WebhookService } from '@/app/api/quickbooks/webhook/webhook.service' @@ -29,3 +30,55 @@ export async function captureWebhookEvent(req: NextRequest) { return NextResponse.json({ ok: true }) }) } + +export async function captureWebhookEventGET(req: NextRequest) { + return Sentry.withScope(async (scope) => { + console.info('\n\n####### Webhook triggered #######') + const user = await authenticate(req) + scope.setTag('portalId', user.workspaceId) + scope.setTag('workspaceId', user.workspaceId) + + const authService = new AuthService(user) + // example test payload + const payload = { + eventType: WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED, + eventTime: '1784705274', + data: { + payout: { + id: 'po_test_7', + arrivalDate: 1784705701, + currency: 'usd', + netAmount: 2844, + status: 'paid', + }, + lineItems: [ + { + copilotInvoiceId: 'in_1TwL04FdviIHOKAnA2vjpthY', + grossAmount: 1000, + feeAmount: 62, + }, + { + copilotInvoiceId: 'in_1TwL3xFdviIHOKAnfnauoxkD', + grossAmount: 2000, + feeAmount: 94, + }, + ], + }, + } + + const qbTokenInfo = await authService.getQBPortalConnection( + user.workspaceId, + ) + user.qbConnection = { + serviceItemRef: qbTokenInfo.serviceItemRef, + clientFeeRef: qbTokenInfo.clientFeeRef, + } + + if (payload.eventType === WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED) { + const webhookService = new WebhookService(user) + await webhookService.handleWebhookEvent(payload, qbTokenInfo) + } + + return NextResponse.json({ ok: true }) + }) +} diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 0f027cb0..bcb7e948 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -1,7 +1,12 @@ 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' @@ -349,22 +354,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}`, ) @@ -474,6 +470,35 @@ export class WebhookService extends BaseService { } } + // Writes the FAILED absorbed-fee sync log shared by the no-mapping and + // QB-error paths of handlePaymentSucceeded. + 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, @@ -488,102 +513,118 @@ export class WebhookService extends BaseService { ) return } - const parsedPaymentSucceedResource = parsedPaymentSucceed.data - const feeAmount = parsedPaymentSucceedResource.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', - 'bankDepositFeeFlag', - ]) - - if (!setting?.absorbedFeeFlag) { - console.info( - 'WebhookService#handleWebhookEvent#payment-succeeded | Absorbed fee flag is false', - ) - return - } + const resource = parsedPaymentSucceed.data + const feeAmount = resource.data.feeAmount - if (setting.bankDepositFeeFlag) { - // Batched mode: deposit happens on payout.reconciliation_completed. - // Return before claiming so no stale PENDING row is left behind. - console.info( - 'WebhookService#handlePaymentSucceeded | Batched-deposit mode; deferring deposit to payout event', - ) - return - } + // Only a platform-absorbed fee books a QBO expense; nothing to do otherwise. + if (!feeAmount?.paidByPlatform || feeAmount.paidByPlatform <= 0) return - if (opts.delayMs) await sleep(opts.delayMs) + // Absorbed-fee flag gates this handler; read it before any fetch so an + // off-flag portal never calls out to Copilot. + 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, + const syncLogService = new SyncLogService(this.user) + // Cheap duplicate short-circuit: a redelivered event already has a + // SUCCEEDED claim row, so skip the sleep + Copilot fetch. The atomic + // claim below still guards the first-delivery race. + const existingPaymentLog = + await syncLogService.getOneByCopilotIdAndEventType({ + copilotId: resource.data.id, 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 processed (payment/succeeded); skipping', + ) + return + } + + if (opts.delayMs) await sleep(opts.delayMs) + + const copilotApp = new CopilotAPI(this.user.token) + const invoice = await copilotApp.getInvoice(resource.data.invoiceId) + if (!invoice) + throw new APIError( + httpStatus.NOT_FOUND, + `Invoice not found in Assembly for invoice id: ${resource.data.invoiceId}`, + ) + + // Fetch the invoice-sync row before claiming so the frozen batched defer + // (below) can return with zero sync-log rows written. + const invService = new InvoiceService(this.user) + const invoiceSync = await invService.getInvoiceByNumber(invoice.number, [ + 'id', + 'qbInvoiceId', + 'qbDocNumber', + 'isBatchedDeposit', + ]) + + if (invoiceSync?.isBatchedDeposit) { + // Frozen batched intent: the payout deposit books the fee. Defer before + // claiming so no stale PENDING row is left behind. + console.info( + 'WebhookService#handlePaymentSucceeded | Batched-deposit mode (frozen); deferring to payout event', + ) + return + } - const copilotApp = new CopilotAPI(this.user.token) - const invoice = await copilotApp.getInvoice( - parsedPaymentSucceedResource.data.invoiceId, + const { claimed } = await syncLogService.claimWebhookEvent({ + copilotId: resource.data.id, + eventType: EventType.SUCCEEDED, + entityType: EntityType.PAYMENT, + }) + if (!claimed) { + console.info( + `WebhookService#handlePaymentSucceeded | Already claimed (payment/${EventType.SUCCEEDED}, copilotId=${resource.data.id}), skipping`, ) - if (!invoice) - throw new APIError( - httpStatus.NOT_FOUND, - `Invoice not found in Assembly for invoice id: ${parsedPaymentSucceedResource.data.invoiceId}`, - ) + return + } - 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 + // Handled post-claim so the update goes against the row just claimed above, + // instead of racing another redelivery's insert. + if (!invoiceSync) { + await this.logAbsorbedFeeFailure({ + copilotId: resource.data.id, + feeAmount: feeAmount.paidByPlatform.toFixed(2), + errorMessage: `No invoice found in invoice sync table for invoice id: ${resource.data.invoiceId}`, + shouldRetry: true, + }) + 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 - } + 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: resource.data.id, + invoiceNumber: invoice.number, + feeAmount: feeAmount.paidByPlatform.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: ${resource.data.id}`, + ) + return } } @@ -603,18 +644,25 @@ export class WebhookService extends BaseService { data: { payout, lineItems }, } = parsedPayout.data - const settingService = new SettingService(this.user) - const setting = await settingService.getOneByPortalId([ - 'bankDepositFeeFlag', - ]) - if (!setting?.bankDepositFeeFlag) { + const syncLogService = new SyncLogService(this.user) + const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) + + // Resolve the frozen per-invoice intent before claiming. A payout whose + // invoices are all frozen non-batched books nothing, so skip it with zero + // sync-log rows — claiming first would leave a PENDING row that later flips + // to a spurious 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 | Batching disabled (bankDepositFeeFlag off)', + `WebhookService#handlePayoutReconciliationCompleted | Payout ${payout.id}: all invoices non-batched, nothing to deposit`, ) return } - const syncLogService = new SyncLogService(this.user) const { claimed } = await syncLogService.claimWebhookEvent({ copilotId: payout.id, entityType: EntityType.PAYOUT, @@ -657,15 +705,16 @@ export class WebhookService extends BaseService { ) } - const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) { throw new APIError( httpStatus.BAD_REQUEST, `Payout ${payout.id} contains duplicate invoice line items`, ) } - const paymentIdByInvoice = - await syncLogService.getSuccessfulPaidPaymentIds(copilotInvoiceIds) + + // A voided/deleted invoice soft-deletes its qb_invoice_sync row, which + // drops it from the join above and surfaces here — failing the whole + // payout (v1: manual recovery, no partial deposit). const unresolved = copilotInvoiceIds.filter( (id) => !paymentIdByInvoice.has(id), ) @@ -676,6 +725,19 @@ export class WebhookService extends BaseService { ) } + // All-non-batched already skipped before the claim, so any non-batched + // invoice here means a mixed payout — unsupported in v1. Every id + // resolved above (unresolved check), so get() is defined. + const allBatched = copilotInvoiceIds.every( + (id) => paymentIdByInvoice.get(id)!.isBatchedDeposit, + ) + if (!allBatched) { + throw new APIError( + httpStatus.BAD_REQUEST, + `Payout ${payout.id} mixes batched and non-batched invoices; unsupported`, + ) + } + if (grossCents - feeCents !== payout.netAmount) { throw new APIError( httpStatus.BAD_REQUEST, @@ -714,9 +776,8 @@ export class WebhookService extends BaseService { intuitApi, { lines: lineItems.map((line) => ({ - qbPaymentId: paymentIdByInvoice.get( - line.copilotInvoiceId, - ) as string, + qbPaymentId: paymentIdByInvoice.get(line.copilotInvoiceId) + ?.paymentId as string, amount: line.grossAmount / 100, })), feeTotal: feeCents / 100, 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/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/_journal.json b/src/db/migrations/meta/_journal.json index 6c6e4b0f..c0d75bd3 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1784628005402, "tag": "20260721100005_extend_oneshot_index_payout", "breakpoints": true + }, + { + "idx": 27, + "version": "7", + "when": 1784884542846, + "tag": "20260724091542_add_is_batched_deposit", + "breakpoints": true } ] } \ No newline at end of file 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) => [ From 9a1040916da6db442ed248d7df51514924c695ad Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 27 Jul 2026 14:38:53 +0545 Subject: [PATCH 25/49] test(OUT-4010): cover frozen batched-deposit intent - invoice.created freezes is_batched_deposit; paid-on-create deposit routing - invoice.paid + payment.succeeded route off the frozen value, not the live flag - payout: all-batched books one deposit, all-non-batched skips before the claim, mixed rejected - shared infra: getUndepositedFundsAccountId/createDeposit mocks, seedPaidInvoiceForPayout, payout fixture + setup helper Overlaps the OUT-4006 payout suite (#268) on shared infra and the removed live-flag tests; reconcile on rebase once #268 lands on the base branch. Co-Authored-By: Claude Opus 4.8 --- test/fixtures/payoutReconciliation.webhook.ts | 38 +++++++++++ test/helpers/mocks.ts | 9 +++ test/helpers/payoutReconciliationTestSetup.ts | 39 +++++++++++ test/helpers/seed.ts | 33 ++++++++++ .../freezeBatchedIntent.test.ts | 27 ++++++++ .../statusPaidDepositRouting.test.ts | 45 +++++++++++++ .../invoicePaid/frozenIntentRouting.test.ts | 56 ++++++++++++++++ .../copilotInvoiceNotFound.test.ts | 17 ++--- .../frozenIntentDefer.test.ts | 38 +++++++++++ .../paymentSucceeded/idempotency.test.ts | 2 + .../allBatched.test.ts | 65 +++++++++++++++++++ .../allNonBatched.test.ts | 53 +++++++++++++++ .../mixed.test.ts | 62 ++++++++++++++++++ 13 files changed, 472 insertions(+), 12 deletions(-) create mode 100644 test/fixtures/payoutReconciliation.webhook.ts create mode 100644 test/helpers/payoutReconciliationTestSetup.ts create mode 100644 test/integration/quickbooks/invoiceCreated/freezeBatchedIntent.test.ts create mode 100644 test/integration/quickbooks/invoiceCreated/statusPaidDepositRouting.test.ts create mode 100644 test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts create mode 100644 test/integration/quickbooks/paymentSucceeded/frozenIntentDefer.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliationCompleted/allBatched.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliationCompleted/allNonBatched.test.ts create mode 100644 test/integration/quickbooks/payoutReconciliationCompleted/mixed.test.ts diff --git a/test/fixtures/payoutReconciliation.webhook.ts b/test/fixtures/payoutReconciliation.webhook.ts new file mode 100644 index 00000000..dd339b7c --- /dev/null +++ b/test/fixtures/payoutReconciliation.webhook.ts @@ -0,0 +1,38 @@ +import type { z } from 'zod' + +import { WebhookEvents } from '@/app/api/core/types/webhook' +import { PayoutReconciliationCompletedSchema } from '@/type/dto/webhook.dto' + +export const TEST_PAYOUT_ID = 'po-test-0001' +export const TEST_COPILOT_INVOICE_ID_A = 'inv-cop-A' +export const TEST_COPILOT_INVOICE_ID_B = 'inv-cop-B' + +type PayoutFixture = z.input + +// Two-invoice payout. grossCents (3000) - feeCents (156) == netAmount (2844), +// so it clears the deposit-balance check in the all-batched path. +export const payoutReconciliationPayload: PayoutFixture = { + eventType: WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED, + eventTime: '1784705274', + data: { + payout: { + id: TEST_PAYOUT_ID, + arrivalDate: 1784705701, + currency: 'usd', + netAmount: 2844, + status: 'paid', + }, + lineItems: [ + { + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, + grossAmount: 1000, + feeAmount: 62, + }, + { + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, + grossAmount: 2000, + feeAmount: 94, + }, + ], + }, +} diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index a4010725..f0de3559 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 @@ -130,6 +131,14 @@ 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' }, + }), // 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/payoutReconciliationTestSetup.ts b/test/helpers/payoutReconciliationTestSetup.ts new file mode 100644 index 00000000..c99f3161 --- /dev/null +++ b/test/helpers/payoutReconciliationTestSetup.ts @@ -0,0 +1,39 @@ +import { beforeEach, afterEach, vi } from 'vitest' +import { truncateAllTestTables } from '@test/helpers/testDb' +import { + installMockApis, + type MockCopilotAPI, + type MockIntuitAPI, +} from '@test/helpers/mocks' + +type InstallOpts = Parameters[0] + +export interface PayoutReconciliationTestHandle { + copilot: MockCopilotAPI + intuit: MockIntuitAPI +} + +/** + * beforeEach (truncate + installMockApis) and afterEach (clearAllMocks) hooks + * for payout.reconciliation_completed tests. Mirrors `setupPaymentSucceededTest`; + * `optsFactory` runs once per test so override `vi.fn()`s are freshly instantiated. + */ +export function setupPayoutReconciliationTest( + optsFactory?: () => InstallOpts, +): PayoutReconciliationTestHandle { + const handle = {} as PayoutReconciliationTestHandle + + beforeEach(async () => { + await truncateAllTestTables() + const { copilot, intuit } = installMockApis(optsFactory?.()) + handle.copilot = copilot + handle.intuit = intuit + }) + + afterEach(() => { + // clearAllMocks (not restoreAllMocks) keeps the module-level mock factories installed. + vi.clearAllMocks() + }) + + return handle +} diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index a4faf15a..94fa2e1e 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -21,6 +21,7 @@ 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' @@ -186,3 +187,35 @@ 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, + }) +} 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/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..005b15a4 --- /dev/null +++ b/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts @@ -0,0 +1,56 @@ +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, + status: 'open', + }) + 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, + status: 'open', + }) + 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/paymentSucceeded/copilotInvoiceNotFound.test.ts b/test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.test.ts index 4ff0dbbb..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,23 +26,17 @@ 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() 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/payoutReconciliationCompleted/allBatched.test.ts b/test/integration/quickbooks/payoutReconciliationCompleted/allBatched.test.ts new file mode 100644 index 00000000..97985f68 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliationCompleted/allBatched.test.ts @@ -0,0 +1,65 @@ +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 { + payoutReconciliationPayload, + TEST_PAYOUT_ID, + TEST_COPILOT_INVOICE_ID_A, + TEST_COPILOT_INVOICE_ID_B, +} from '@test/fixtures/payoutReconciliation.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_BANK_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPayoutReconciliationTest } from '@test/helpers/payoutReconciliationTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (all invoices batched)', () => { + const apis = setupPayoutReconciliationTest() + + it('creates one batched bank deposit and logs the payout as SUCCESS', async () => { + await seedHealthyPortal({ + portal: { bankAccountRef: TEST_BANK_ACCOUNT_REF }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, + invoiceNumber: 'INV-A', + paymentId: 'qb-pay-A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, + invoiceNumber: 'INV-B', + paymentId: 'qb-pay-B', + isBatchedDeposit: true, + }) + + const res = await postWebhook(payoutReconciliationPayload) + expect(res.status).toBe(200) + + // Both payments swept into a single deposit landing in the bank account. + expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) + const [depositPayload] = apis.intuit.createDeposit.mock.calls[0] + expect(depositPayload.DepositToAccountRef).toEqual({ + value: TEST_BANK_ACCOUNT_REF, + }) + + const [payoutLog] = await db + .select() + .from(QBSyncLog) + .where( + and( + eq(QBSyncLog.entityType, EntityType.PAYOUT), + eq(QBSyncLog.eventType, EventType.SETTLED), + eq(QBSyncLog.copilotId, TEST_PAYOUT_ID), + ), + ) + expect(payoutLog.status).toBe(LogStatus.SUCCESS) + expect(payoutLog.quickbooksId).toBe('qb-deposit-1') + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliationCompleted/allNonBatched.test.ts b/test/integration/quickbooks/payoutReconciliationCompleted/allNonBatched.test.ts new file mode 100644 index 00000000..9b9a07d8 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliationCompleted/allNonBatched.test.ts @@ -0,0 +1,53 @@ +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 { + payoutReconciliationPayload, + TEST_COPILOT_INVOICE_ID_A, + TEST_COPILOT_INVOICE_ID_B, +} from '@test/fixtures/payoutReconciliation.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_BANK_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPayoutReconciliationTest } from '@test/helpers/payoutReconciliationTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (all invoices non-batched)', () => { + const apis = setupPayoutReconciliationTest() + + it('books no deposit and writes no sync log — skipped before the claim', async () => { + await seedHealthyPortal({ + portal: { bankAccountRef: TEST_BANK_ACCOUNT_REF }, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, + invoiceNumber: 'INV-A', + paymentId: 'qb-pay-A', + isBatchedDeposit: false, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, + invoiceNumber: 'INV-B', + paymentId: 'qb-pay-B', + isBatchedDeposit: false, + }) + + const res = await postWebhook(payoutReconciliationPayload) + expect(res.status).toBe(200) + + expect(apis.intuit.createDeposit).not.toHaveBeenCalled() + + // No claim, no audit row — the two seeded INVOICE/PAID logs are all that remain. + const payoutLogs = await db + .select() + .from(QBSyncLog) + .where(eq(QBSyncLog.entityType, EntityType.PAYOUT)) + expect(payoutLogs).toHaveLength(0) + }) +}) diff --git a/test/integration/quickbooks/payoutReconciliationCompleted/mixed.test.ts b/test/integration/quickbooks/payoutReconciliationCompleted/mixed.test.ts new file mode 100644 index 00000000..456df5d7 --- /dev/null +++ b/test/integration/quickbooks/payoutReconciliationCompleted/mixed.test.ts @@ -0,0 +1,62 @@ +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 { + payoutReconciliationPayload, + TEST_PAYOUT_ID, + TEST_COPILOT_INVOICE_ID_A, + TEST_COPILOT_INVOICE_ID_B, +} from '@test/fixtures/payoutReconciliation.webhook' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_BANK_ACCOUNT_REF, +} from '@test/helpers/seed' +import { setupPayoutReconciliationTest } from '@test/helpers/payoutReconciliationTestSetup' +import { postWebhook } from '@test/helpers/webhook' + +describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (mixed batched + non-batched)', () => { + const apis = setupPayoutReconciliationTest() + + it('rejects the payout without booking a deposit and logs it FAILED (no retry)', async () => { + await seedHealthyPortal({ + portal: { bankAccountRef: TEST_BANK_ACCOUNT_REF }, + }) + // One invoice froze batched, the other non-batched — unsupported in v1. + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, + invoiceNumber: 'INV-A', + paymentId: 'qb-pay-A', + isBatchedDeposit: true, + }) + await seedPaidInvoiceForPayout({ + copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, + invoiceNumber: 'INV-B', + paymentId: 'qb-pay-B', + isBatchedDeposit: false, + }) + + const res = await postWebhook(payoutReconciliationPayload) + 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, TEST_PAYOUT_ID), + ), + ) + expect(payoutLog.status).toBe(LogStatus.FAILED) + expect(payoutLog.shouldRetry).toBe(false) + expect(payoutLog.errorMessage).toContain('mixes batched and non-batched') + }) +}) From 8ea1f9579017a5ecdd10e07b8b0b4d77221dce69 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 27 Jul 2026 16:15:03 +0545 Subject: [PATCH 26/49] =?UTF-8?q?fix(OUT-4010):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20remove=20debug=20endpoint,=20reconcile=20payout=20t?= =?UTF-8?q?ests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove the debug GET webhook route (captureWebhookEventGET) that replayed a hardcoded payout against real QBO on any authenticated GET - correct the unresolved-invoice comment: the join is safe because webhookInvoicePaid throws without an invoice-sync row, not via a soft-delete - extract repeated payload fields into locals (paymentId/invoiceId/platformFee, payoutId) Reconcile the OUT-4006 payout suite with the frozen-intent behavior: - port payout tests to seedPaidInvoiceForPayout so the new invoice-sync join resolves; update resolvePayments for the {paymentId, isBatchedDeposit} shape - delete now-obsolete live-flag tests (bankDepositFlagNoOp, flagOff) - fold payoutReconciliationCompleted/* into payoutReconciliation/ on the shared payout fixture; drop the duplicate happy-path and parallel infra Co-Authored-By: Claude Opus 4.8 --- src/app/api/quickbooks/webhook/route.ts | 6 +- .../quickbooks/webhook/webhook.controller.ts | 53 --------------- .../api/quickbooks/webhook/webhook.service.ts | 58 +++++++++-------- test/fixtures/payoutReconciliation.webhook.ts | 38 ----------- test/helpers/payoutReconciliationTestSetup.ts | 39 ----------- .../bankDepositFlagNoOp.test.ts | 36 ---------- .../allNonBatched.test.ts | 30 ++++----- .../bankAccountDeleted.test.ts | 31 ++++----- .../bankAccountInactive.test.ts | 31 ++++----- .../payoutReconciliation/flagOff.test.ts | 52 --------------- .../payoutReconciliation/happyPath.test.ts | 31 ++++----- .../idempotentRedelivery.test.ts | 31 ++++----- .../mixed.test.ts | 27 ++++---- .../resolvePayments.test.ts | 31 +++++---- .../payoutReconciliation/sumMismatch.test.ts | 31 ++++----- .../unresolvedLine.test.ts | 21 +++--- .../allBatched.test.ts | 65 ------------------- 17 files changed, 150 insertions(+), 461 deletions(-) delete mode 100644 test/fixtures/payoutReconciliation.webhook.ts delete mode 100644 test/helpers/payoutReconciliationTestSetup.ts delete mode 100644 test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts rename test/integration/quickbooks/{payoutReconciliationCompleted => payoutReconciliation}/allNonBatched.test.ts (52%) delete mode 100644 test/integration/quickbooks/payoutReconciliation/flagOff.test.ts rename test/integration/quickbooks/{payoutReconciliationCompleted => payoutReconciliation}/mixed.test.ts (62%) delete mode 100644 test/integration/quickbooks/payoutReconciliationCompleted/allBatched.test.ts diff --git a/src/app/api/quickbooks/webhook/route.ts b/src/app/api/quickbooks/webhook/route.ts index 0087df7e..d2b3b235 100644 --- a/src/app/api/quickbooks/webhook/route.ts +++ b/src/app/api/quickbooks/webhook/route.ts @@ -1,10 +1,6 @@ import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' -import { - captureWebhookEvent, - captureWebhookEventGET, -} from '@/app/api/quickbooks/webhook/webhook.controller' +import { captureWebhookEvent } from '@/app/api/quickbooks/webhook/webhook.controller' export const maxDuration = 300 // 5 minutes export const POST = withErrorHandler(captureWebhookEvent) -export const GET = withErrorHandler(captureWebhookEventGET) diff --git a/src/app/api/quickbooks/webhook/webhook.controller.ts b/src/app/api/quickbooks/webhook/webhook.controller.ts index 619ee34e..fedab968 100644 --- a/src/app/api/quickbooks/webhook/webhook.controller.ts +++ b/src/app/api/quickbooks/webhook/webhook.controller.ts @@ -1,4 +1,3 @@ -import { WebhookEvents } from '@/app/api/core/types/webhook' import authenticate from '@/app/api/core/utils/authenticate' import { AuthService } from '@/app/api/quickbooks/auth/auth.service' import { WebhookService } from '@/app/api/quickbooks/webhook/webhook.service' @@ -30,55 +29,3 @@ export async function captureWebhookEvent(req: NextRequest) { return NextResponse.json({ ok: true }) }) } - -export async function captureWebhookEventGET(req: NextRequest) { - return Sentry.withScope(async (scope) => { - console.info('\n\n####### Webhook triggered #######') - const user = await authenticate(req) - scope.setTag('portalId', user.workspaceId) - scope.setTag('workspaceId', user.workspaceId) - - const authService = new AuthService(user) - // example test payload - const payload = { - eventType: WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED, - eventTime: '1784705274', - data: { - payout: { - id: 'po_test_7', - arrivalDate: 1784705701, - currency: 'usd', - netAmount: 2844, - status: 'paid', - }, - lineItems: [ - { - copilotInvoiceId: 'in_1TwL04FdviIHOKAnA2vjpthY', - grossAmount: 1000, - feeAmount: 62, - }, - { - copilotInvoiceId: 'in_1TwL3xFdviIHOKAnfnauoxkD', - grossAmount: 2000, - feeAmount: 94, - }, - ], - }, - } - - const qbTokenInfo = await authService.getQBPortalConnection( - user.workspaceId, - ) - user.qbConnection = { - serviceItemRef: qbTokenInfo.serviceItemRef, - clientFeeRef: qbTokenInfo.clientFeeRef, - } - - if (payload.eventType === WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED) { - const webhookService = new WebhookService(user) - await webhookService.handleWebhookEvent(payload, qbTokenInfo) - } - - return NextResponse.json({ ok: true }) - }) -} diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index bcb7e948..e9fc0efb 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -519,6 +519,9 @@ export class WebhookService extends BaseService { // Only a platform-absorbed fee books a QBO expense; nothing to do otherwise. if (!feeAmount?.paidByPlatform || feeAmount.paidByPlatform <= 0) return + const { id: paymentId, invoiceId } = resource.data + const platformFee = feeAmount.paidByPlatform + // Absorbed-fee flag gates this handler; read it before any fetch so an // off-flag portal never calls out to Copilot. const settingService = new SettingService(this.user) @@ -536,7 +539,7 @@ export class WebhookService extends BaseService { // claim below still guards the first-delivery race. const existingPaymentLog = await syncLogService.getOneByCopilotIdAndEventType({ - copilotId: resource.data.id, + copilotId: paymentId, eventType: EventType.SUCCEEDED, entityType: EntityType.PAYMENT, }) @@ -550,11 +553,11 @@ export class WebhookService extends BaseService { if (opts.delayMs) await sleep(opts.delayMs) const copilotApp = new CopilotAPI(this.user.token) - const invoice = await copilotApp.getInvoice(resource.data.invoiceId) + const invoice = await copilotApp.getInvoice(invoiceId) if (!invoice) throw new APIError( httpStatus.NOT_FOUND, - `Invoice not found in Assembly for invoice id: ${resource.data.invoiceId}`, + `Invoice not found in Assembly for invoice id: ${invoiceId}`, ) // Fetch the invoice-sync row before claiming so the frozen batched defer @@ -577,13 +580,13 @@ export class WebhookService extends BaseService { } const { claimed } = await syncLogService.claimWebhookEvent({ - copilotId: resource.data.id, + copilotId: paymentId, eventType: EventType.SUCCEEDED, entityType: EntityType.PAYMENT, }) if (!claimed) { console.info( - `WebhookService#handlePaymentSucceeded | Already claimed (payment/${EventType.SUCCEEDED}, copilotId=${resource.data.id}), skipping`, + `WebhookService#handlePaymentSucceeded | Already claimed (payment/${EventType.SUCCEEDED}, copilotId=${paymentId}), skipping`, ) return } @@ -592,9 +595,9 @@ export class WebhookService extends BaseService { // instead of racing another redelivery's insert. if (!invoiceSync) { await this.logAbsorbedFeeFailure({ - copilotId: resource.data.id, - feeAmount: feeAmount.paidByPlatform.toFixed(2), - errorMessage: `No invoice found in invoice sync table for invoice id: ${resource.data.invoiceId}`, + copilotId: paymentId, + feeAmount: platformFee.toFixed(2), + errorMessage: `No invoice found in invoice sync table for invoice id: ${invoiceId}`, shouldRetry: true, }) return @@ -613,16 +616,16 @@ export class WebhookService extends BaseService { CustomLogger.error({ message: 'Webhook handler failed', obj: error }) const errorWithCode = getMessageAndCodeFromError(error) await this.logAbsorbedFeeFailure({ - copilotId: resource.data.id, + copilotId: paymentId, invoiceNumber: invoice.number, - feeAmount: feeAmount.paidByPlatform.toFixed(2), + 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: ${resource.data.id}`, + `WebhookService#handlePaymentSucceeded :: Error | Portal Id: ${this.user.workspaceId} | Payment: ${paymentId}`, ) return } @@ -644,6 +647,7 @@ export class WebhookService extends BaseService { data: { payout, lineItems }, } = parsedPayout.data + const payoutId = payout.id const syncLogService = new SyncLogService(this.user) const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) @@ -658,19 +662,19 @@ export class WebhookService extends BaseService { ) if (resolvedIntents.every((intent) => intent && !intent.isBatchedDeposit)) { console.info( - `WebhookService#handlePayoutReconciliationCompleted | Payout ${payout.id}: all invoices non-batched, nothing to deposit`, + `WebhookService#handlePayoutReconciliationCompleted | Payout ${payoutId}: all invoices non-batched, nothing to deposit`, ) return } const { claimed } = await syncLogService.claimWebhookEvent({ - copilotId: payout.id, + copilotId: payoutId, entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, }) if (!claimed) { console.info( - `WebhookService#handlePayoutReconciliationCompleted | Already claimed (payout/${EventType.SETTLED}, copilotId=${payout.id}), skipping`, + `WebhookService#handlePayoutReconciliationCompleted | Already claimed (payout/${EventType.SETTLED}, copilotId=${payoutId}), skipping`, ) return } @@ -692,7 +696,7 @@ export class WebhookService extends BaseService { if (lineItems.some((line) => line.grossAmount < 0)) { throw new APIError( httpStatus.BAD_REQUEST, - `Payout ${payout.id} contains refund lines; batched deposit unsupported in v1`, + `Payout ${payoutId} contains refund lines; batched deposit unsupported in v1`, ) } @@ -701,19 +705,21 @@ export class WebhookService extends BaseService { if (feeCents < 0) { throw new APIError( httpStatus.BAD_REQUEST, - `Payout ${payout.id} has a negative aggregate fee (${feeCents}); unsupported in v1`, + `Payout ${payoutId} has a negative aggregate fee (${feeCents}); unsupported in v1`, ) } if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) { throw new APIError( httpStatus.BAD_REQUEST, - `Payout ${payout.id} contains duplicate invoice line items`, + `Payout ${payoutId} contains duplicate invoice line items`, ) } - // A voided/deleted invoice soft-deletes its qb_invoice_sync row, which - // drops it from the join above and surfaces here — failing the whole + // An invoice is unresolved when it has no SUCCESS INVOICE/PAID sync log + // yet (payment unprocessed or failed). webhookInvoicePaid throws without + // an invoice-sync row, so a SUCCESS PAID log always has its join match — + // a miss here is a missing payment, not a dropped row. Fail the whole // payout (v1: manual recovery, no partial deposit). const unresolved = copilotInvoiceIds.filter( (id) => !paymentIdByInvoice.has(id), @@ -721,7 +727,7 @@ export class WebhookService extends BaseService { if (unresolved.length > 0) { throw new APIError( httpStatus.NOT_FOUND, - `Payout ${payout.id}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`, + `Payout ${payoutId}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`, ) } @@ -734,14 +740,14 @@ export class WebhookService extends BaseService { if (!allBatched) { throw new APIError( httpStatus.BAD_REQUEST, - `Payout ${payout.id} mixes batched and non-batched invoices; unsupported`, + `Payout ${payoutId} mixes batched and non-batched invoices; unsupported`, ) } if (grossCents - feeCents !== payout.netAmount) { throw new APIError( httpStatus.BAD_REQUEST, - `Payout ${payout.id}: deposit total ${grossCents - feeCents} != payout net ${payout.netAmount}`, + `Payout ${payoutId}: deposit total ${grossCents - feeCents} != payout net ${payout.netAmount}`, ) } @@ -786,7 +792,7 @@ export class WebhookService extends BaseService { txnDate: new Date(payout.arrivalDate * 1000) .toISOString() .split('T')[0], - privateNote: `Stripe payout ${payout.id}`, + privateNote: `Stripe payout ${payoutId}`, }, ) @@ -795,7 +801,7 @@ export class WebhookService extends BaseService { entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, status: LogStatus.SUCCESS, - copilotId: payout.id, + copilotId: payoutId, quickbooksId: depositId, amount: payout.netAmount.toFixed(2), feeAmount: feeCents.toFixed(2), @@ -814,7 +820,7 @@ export class WebhookService extends BaseService { entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, status: LogStatus.FAILED, - copilotId: payout.id, + copilotId: payoutId, amount: payout.netAmount.toFixed(2), feeAmount: feeCents.toFixed(2), remark: 'Stripe payout batched deposit', @@ -827,7 +833,7 @@ export class WebhookService extends BaseService { category: getCategory(errorWithCode), }) console.error( - `WebhookService#handlePayoutReconciliationCompleted :: Error | Portal Id: ${this.user.workspaceId} | Payout: ${payout.id}`, + `WebhookService#handlePayoutReconciliationCompleted :: Error | Portal Id: ${this.user.workspaceId} | Payout: ${payoutId}`, ) return } diff --git a/test/fixtures/payoutReconciliation.webhook.ts b/test/fixtures/payoutReconciliation.webhook.ts deleted file mode 100644 index dd339b7c..00000000 --- a/test/fixtures/payoutReconciliation.webhook.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { z } from 'zod' - -import { WebhookEvents } from '@/app/api/core/types/webhook' -import { PayoutReconciliationCompletedSchema } from '@/type/dto/webhook.dto' - -export const TEST_PAYOUT_ID = 'po-test-0001' -export const TEST_COPILOT_INVOICE_ID_A = 'inv-cop-A' -export const TEST_COPILOT_INVOICE_ID_B = 'inv-cop-B' - -type PayoutFixture = z.input - -// Two-invoice payout. grossCents (3000) - feeCents (156) == netAmount (2844), -// so it clears the deposit-balance check in the all-batched path. -export const payoutReconciliationPayload: PayoutFixture = { - eventType: WebhookEvents.PAYOUT_RECONCILIATION_COMPLETED, - eventTime: '1784705274', - data: { - payout: { - id: TEST_PAYOUT_ID, - arrivalDate: 1784705701, - currency: 'usd', - netAmount: 2844, - status: 'paid', - }, - lineItems: [ - { - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, - grossAmount: 1000, - feeAmount: 62, - }, - { - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, - grossAmount: 2000, - feeAmount: 94, - }, - ], - }, -} diff --git a/test/helpers/payoutReconciliationTestSetup.ts b/test/helpers/payoutReconciliationTestSetup.ts deleted file mode 100644 index c99f3161..00000000 --- a/test/helpers/payoutReconciliationTestSetup.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { beforeEach, afterEach, vi } from 'vitest' -import { truncateAllTestTables } from '@test/helpers/testDb' -import { - installMockApis, - type MockCopilotAPI, - type MockIntuitAPI, -} from '@test/helpers/mocks' - -type InstallOpts = Parameters[0] - -export interface PayoutReconciliationTestHandle { - copilot: MockCopilotAPI - intuit: MockIntuitAPI -} - -/** - * beforeEach (truncate + installMockApis) and afterEach (clearAllMocks) hooks - * for payout.reconciliation_completed tests. Mirrors `setupPaymentSucceededTest`; - * `optsFactory` runs once per test so override `vi.fn()`s are freshly instantiated. - */ -export function setupPayoutReconciliationTest( - optsFactory?: () => InstallOpts, -): PayoutReconciliationTestHandle { - const handle = {} as PayoutReconciliationTestHandle - - beforeEach(async () => { - await truncateAllTestTables() - const { copilot, intuit } = installMockApis(optsFactory?.()) - handle.copilot = copilot - handle.intuit = intuit - }) - - afterEach(() => { - // clearAllMocks (not restoreAllMocks) keeps the module-level mock factories installed. - vi.clearAllMocks() - }) - - return handle -} diff --git a/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts b/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts deleted file mode 100644 index bef3544b..00000000 --- a/test/integration/quickbooks/paymentSucceeded/bankDepositFlagNoOp.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { eq } from 'drizzle-orm' - -import { db } from '@/db' -import { QBSyncLog } from '@/db/schema/qbSyncLogs' - -import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' -import { seedHealthyPortal, TEST_COPILOT_PAYMENT_ID } from '@test/helpers/seed' -import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' -import { postWebhook } from '@test/helpers/webhook' - -describe('payment.succeeded with bankDepositFeeFlag on — no per-payment deposit', () => { - const apis = setupPaymentSucceededTest() - - it('creates neither a deposit nor a purchase', async () => { - // handlePaymentSucceeded returns immediately once bankDepositFeeFlag is - // true (the deposit is deferred to payout.reconciliation_completed), so - // no invoice sync, bank account ref, or QBO calls are ever reached here. - await seedHealthyPortal({ - setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, - }) - - const res = await postWebhook(paymentSucceededPayload) - expect(res.status).toBe(200) - - expect(apis.intuit.createDeposit).not.toHaveBeenCalled() - expect(apis.intuit.createPurchase).not.toHaveBeenCalled() - - // Handler returns before claiming, so no claim row exists at all. - const logs = await db - .select() - .from(QBSyncLog) - .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) - expect(logs).toHaveLength(0) - }) -}) diff --git a/test/integration/quickbooks/payoutReconciliationCompleted/allNonBatched.test.ts b/test/integration/quickbooks/payoutReconciliation/allNonBatched.test.ts similarity index 52% rename from test/integration/quickbooks/payoutReconciliationCompleted/allNonBatched.test.ts rename to test/integration/quickbooks/payoutReconciliation/allNonBatched.test.ts index 9b9a07d8..3f7f8960 100644 --- a/test/integration/quickbooks/payoutReconciliationCompleted/allNonBatched.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/allNonBatched.test.ts @@ -5,45 +5,39 @@ import { db } from '@/db' import { QBSyncLog } from '@/db/schema/qbSyncLogs' import { EntityType } from '@/app/api/core/types/log' -import { - payoutReconciliationPayload, - TEST_COPILOT_INVOICE_ID_A, - TEST_COPILOT_INVOICE_ID_B, -} from '@test/fixtures/payoutReconciliation.webhook' +import { payoutPayload } from '@test/fixtures/payout.webhook' import { seedHealthyPortal, seedPaidInvoiceForPayout, - TEST_BANK_ACCOUNT_REF, + TEST_COPILOT_INVOICE_ID, } from '@test/helpers/seed' -import { setupPayoutReconciliationTest } from '@test/helpers/payoutReconciliationTestSetup' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' import { postWebhook } from '@test/helpers/webhook' -describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (all invoices non-batched)', () => { - const apis = setupPayoutReconciliationTest() +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({ - portal: { bankAccountRef: TEST_BANK_ACCOUNT_REF }, - }) + await seedHealthyPortal() await seedPaidInvoiceForPayout({ - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, invoiceNumber: 'INV-A', - paymentId: 'qb-pay-A', + paymentId: 'qbpay_A', isBatchedDeposit: false, }) await seedPaidInvoiceForPayout({ - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, + copilotInvoiceId: 'inv-cop-0002', invoiceNumber: 'INV-B', - paymentId: 'qb-pay-B', + paymentId: 'qbpay_B', isBatchedDeposit: false, }) - const res = await postWebhook(payoutReconciliationPayload) + const res = await postWebhook(payoutPayload) expect(res.status).toBe(200) expect(apis.intuit.createDeposit).not.toHaveBeenCalled() - // No claim, no audit row — the two seeded INVOICE/PAID logs are all that remain. + // No claim, no audit row — resolved before claiming. const payoutLogs = await db .select() .from(QBSyncLog) diff --git a/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts index dfe74aff..a8856d35 100644 --- a/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts @@ -8,6 +8,7 @@ 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, @@ -44,24 +45,18 @@ describe('payout — configured bank account no longer exists in QuickBooks', () }, 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', - }, - { - portalId: TEST_PORTAL_ID, - copilotId: 'inv-cop-0002', - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_B', - }, - ]) + 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) diff --git a/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts b/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts index c220ffb4..404c0134 100644 --- a/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountInactive.test.ts @@ -8,6 +8,7 @@ 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, @@ -60,24 +61,18 @@ describe('payout — configured bank account is inactive in QuickBooks', () => { }, 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', - }, - { - portalId: TEST_PORTAL_ID, - copilotId: 'inv-cop-0002', - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_B', - }, - ]) + 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) diff --git a/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts b/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts deleted file mode 100644 index 6750412c..00000000 --- a/test/integration/quickbooks/payoutReconciliation/flagOff.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -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_COPILOT_INVOICE_ID } from '@test/helpers/seed' -import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' -import { postWebhook } from '@test/helpers/webhook' - -describe('payout — bank deposit fee flag is off', () => { - const apis = setupPaymentSucceededTest() - - it('no-ops: no deposit is created and no payout/settled log is written', async () => { - const { portal } = await seedHealthyPortal({ - setting: { absorbedFeeFlag: true, bankDepositFeeFlag: false }, - }) - await db.insert(QBSyncLog).values([ - { - portalId: portal.portalId, - copilotId: TEST_COPILOT_INVOICE_ID, - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_A', - }, - { - portalId: portal.portalId, - copilotId: 'inv-cop-0002', - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_B', - }, - ]) - - const res = await postWebhook(payoutPayload) - expect(res.status).toBe(200) - - expect(apis.intuit.createDeposit).not.toHaveBeenCalled() - // Flag-off no-op returns before any QBO round-trip. - expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() - - 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 index 273869ca..fa840a58 100644 --- a/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/happyPath.test.ts @@ -8,6 +8,7 @@ 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, @@ -31,24 +32,18 @@ describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (batc }, 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', - }, - { - portalId: TEST_PORTAL_ID, - copilotId: 'inv-cop-0002', - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_B', - }, - ]) + 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) diff --git a/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts b/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts index 8474bf79..3abb6e5f 100644 --- a/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/idempotentRedelivery.test.ts @@ -8,6 +8,7 @@ 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, @@ -27,24 +28,18 @@ describe('payout — the same webhook is redelivered', () => { }, 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', - }, - { - portalId: TEST_PORTAL_ID, - copilotId: 'inv-cop-0002', - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_B', - }, - ]) + 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) diff --git a/test/integration/quickbooks/payoutReconciliationCompleted/mixed.test.ts b/test/integration/quickbooks/payoutReconciliation/mixed.test.ts similarity index 62% rename from test/integration/quickbooks/payoutReconciliationCompleted/mixed.test.ts rename to test/integration/quickbooks/payoutReconciliation/mixed.test.ts index 456df5d7..6f067b0d 100644 --- a/test/integration/quickbooks/payoutReconciliationCompleted/mixed.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/mixed.test.ts @@ -5,42 +5,37 @@ import { db } from '@/db' import { QBSyncLog } from '@/db/schema/qbSyncLogs' import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' -import { - payoutReconciliationPayload, - TEST_PAYOUT_ID, - TEST_COPILOT_INVOICE_ID_A, - TEST_COPILOT_INVOICE_ID_B, -} from '@test/fixtures/payoutReconciliation.webhook' +import { payoutPayload } from '@test/fixtures/payout.webhook' import { seedHealthyPortal, seedPaidInvoiceForPayout, + TEST_COPILOT_INVOICE_ID, TEST_BANK_ACCOUNT_REF, } from '@test/helpers/seed' -import { setupPayoutReconciliationTest } from '@test/helpers/payoutReconciliationTestSetup' +import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' import { postWebhook } from '@test/helpers/webhook' -describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (mixed batched + non-batched)', () => { - const apis = setupPayoutReconciliationTest() +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 }, }) - // One invoice froze batched, the other non-batched — unsupported in v1. await seedPaidInvoiceForPayout({ - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, + copilotInvoiceId: TEST_COPILOT_INVOICE_ID, invoiceNumber: 'INV-A', - paymentId: 'qb-pay-A', + paymentId: 'qbpay_A', isBatchedDeposit: true, }) await seedPaidInvoiceForPayout({ - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, + copilotInvoiceId: 'inv-cop-0002', invoiceNumber: 'INV-B', - paymentId: 'qb-pay-B', + paymentId: 'qbpay_B', isBatchedDeposit: false, }) - const res = await postWebhook(payoutReconciliationPayload) + const res = await postWebhook(payoutPayload) expect(res.status).toBe(200) expect(apis.intuit.createDeposit).not.toHaveBeenCalled() @@ -52,7 +47,7 @@ describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (mixe and( eq(QBSyncLog.entityType, EntityType.PAYOUT), eq(QBSyncLog.eventType, EventType.SETTLED), - eq(QBSyncLog.copilotId, TEST_PAYOUT_ID), + eq(QBSyncLog.copilotId, 'po_test_1'), ), ) expect(payoutLog.status).toBe(LogStatus.FAILED) diff --git a/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts b/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts index 12cae41d..45dc814f 100644 --- a/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts @@ -6,29 +6,34 @@ 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, TEST_PORTAL_ID } from '@test/helpers/seed' +import { + seedHealthyPortal, + seedPaidInvoiceForPayout, + TEST_PORTAL_ID, +} from '@test/helpers/seed' import { truncateAllTestTables } from '@test/helpers/testDb' describe('SyncLogService.getSuccessfulPaidPaymentIds', () => { - it('returns only SUCCESS INVOICE/PAID rows for this portal', async () => { + 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_a', - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_a', - }, { portalId: TEST_PORTAL_ID, copilotId: 'inv_b', entityType: EntityType.INVOICE, eventType: EventType.PAID, status: LogStatus.FAILED, + invoiceNumber: 'INV-B', quickbooksId: 'qbpay_b', }, { @@ -37,6 +42,7 @@ describe('SyncLogService.getSuccessfulPaidPaymentIds', () => { entityType: EntityType.INVOICE, eventType: EventType.PAID, status: LogStatus.SUCCESS, + invoiceNumber: 'INV-C', quickbooksId: 'qbpay_c', }, ]) @@ -50,7 +56,10 @@ describe('SyncLogService.getSuccessfulPaidPaymentIds', () => { 'inv_c', ]) - expect(result.get('inv_a')).toBe('qbpay_a') + expect(result.get('inv_a')).toEqual({ + paymentId: 'qbpay_a', + isBatchedDeposit: true, + }) 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 index 2280a7e7..754ec8d3 100644 --- a/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/sumMismatch.test.ts @@ -8,6 +8,7 @@ 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, @@ -29,24 +30,18 @@ describe('payout — reported net amount does not match the line items', () => { }, 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', - }, - { - portalId: TEST_PORTAL_ID, - copilotId: 'inv-cop-0002', - entityType: EntityType.INVOICE, - eventType: EventType.PAID, - status: LogStatus.SUCCESS, - quickbooksId: 'qbpay_B', - }, - ]) + 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, diff --git a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts index ab610619..a4474796 100644 --- a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts @@ -8,6 +8,7 @@ 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, @@ -29,18 +30,14 @@ describe('payout — one invoice has no PAID sync log', () => { }, setting: { absorbedFeeFlag: true, bankDepositFeeFlag: true }, }) - // Only the first invoice has a PAID sync log; inv-cop-0002 is missing one, - // so the handler can't resolve it to a QBO payment id. - 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', - }, - ]) + // 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) diff --git a/test/integration/quickbooks/payoutReconciliationCompleted/allBatched.test.ts b/test/integration/quickbooks/payoutReconciliationCompleted/allBatched.test.ts deleted file mode 100644 index 97985f68..00000000 --- a/test/integration/quickbooks/payoutReconciliationCompleted/allBatched.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -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 { - payoutReconciliationPayload, - TEST_PAYOUT_ID, - TEST_COPILOT_INVOICE_ID_A, - TEST_COPILOT_INVOICE_ID_B, -} from '@test/fixtures/payoutReconciliation.webhook' -import { - seedHealthyPortal, - seedPaidInvoiceForPayout, - TEST_BANK_ACCOUNT_REF, -} from '@test/helpers/seed' -import { setupPayoutReconciliationTest } from '@test/helpers/payoutReconciliationTestSetup' -import { postWebhook } from '@test/helpers/webhook' - -describe('POST /api/quickbooks/webhook — payout.reconciliation_completed (all invoices batched)', () => { - const apis = setupPayoutReconciliationTest() - - it('creates one batched bank deposit and logs the payout as SUCCESS', async () => { - await seedHealthyPortal({ - portal: { bankAccountRef: TEST_BANK_ACCOUNT_REF }, - }) - await seedPaidInvoiceForPayout({ - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_A, - invoiceNumber: 'INV-A', - paymentId: 'qb-pay-A', - isBatchedDeposit: true, - }) - await seedPaidInvoiceForPayout({ - copilotInvoiceId: TEST_COPILOT_INVOICE_ID_B, - invoiceNumber: 'INV-B', - paymentId: 'qb-pay-B', - isBatchedDeposit: true, - }) - - const res = await postWebhook(payoutReconciliationPayload) - expect(res.status).toBe(200) - - // Both payments swept into a single deposit landing in the bank account. - expect(apis.intuit.createDeposit).toHaveBeenCalledTimes(1) - const [depositPayload] = apis.intuit.createDeposit.mock.calls[0] - expect(depositPayload.DepositToAccountRef).toEqual({ - value: TEST_BANK_ACCOUNT_REF, - }) - - const [payoutLog] = await db - .select() - .from(QBSyncLog) - .where( - and( - eq(QBSyncLog.entityType, EntityType.PAYOUT), - eq(QBSyncLog.eventType, EventType.SETTLED), - eq(QBSyncLog.copilotId, TEST_PAYOUT_ID), - ), - ) - expect(payoutLog.status).toBe(LogStatus.SUCCESS) - expect(payoutLog.quickbooksId).toBe('qb-deposit-1') - }) -}) From 04b36f105d21cb2c69f5f3051824f959dab8dd80 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 27 Jul 2026 16:22:05 +0545 Subject: [PATCH 27/49] fix(OUT-4010): clarify status-blind duplicate short-circuit (greptile P2) The peek matches any prior claim row (PENDING/SUCCESS/FAILED), mirroring claimWebhookEvent's status-blind onConflictDoNothing. A redelivery never reprocesses either way; FAILED recovery is the resync cron's job. Reword the comment + skip log to say "already claimed", not "processed". Co-Authored-By: Claude Opus 4.8 --- src/app/api/quickbooks/webhook/webhook.service.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index e9fc0efb..60523555 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -534,9 +534,11 @@ export class WebhookService extends BaseService { } const syncLogService = new SyncLogService(this.user) - // Cheap duplicate short-circuit: a redelivered event already has a - // SUCCEEDED claim row, so skip the sleep + Copilot fetch. The atomic - // claim below still guards the first-delivery race. + // Cheap duplicate short-circuit: any prior claim row (PENDING/SUCCESS/ + // FAILED) means this event was already taken, so skip the sleep + Copilot + // fetch. Deliberately status-blind, mirroring claimWebhookEvent below — a + // redelivery never reprocesses; recovering a FAILED attempt is the resync + // cron's job, not the webhook's. const existingPaymentLog = await syncLogService.getOneByCopilotIdAndEventType({ copilotId: paymentId, @@ -545,7 +547,7 @@ export class WebhookService extends BaseService { }) if (existingPaymentLog) { console.info( - 'WebhookService#handlePaymentSucceeded | Already processed (payment/succeeded); skipping', + `WebhookService#handlePaymentSucceeded | Already claimed (payment/${EventType.SUCCEEDED}, copilotId=${paymentId}); skipping`, ) return } From e66aafa69cc22f0b34f8539161a5bf90ff5d430f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 27 Jul 2026 16:24:53 +0545 Subject: [PATCH 28/49] style(OUT-4010): trim verbose comments to one or two lines Co-Authored-By: Claude Opus 4.8 --- .../api/quickbooks/invoice/invoice.service.ts | 6 +-- .../api/quickbooks/webhook/webhook.service.ts | 40 ++++++------------- 2 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 6a7a654f..db1f9fcd 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -838,10 +838,8 @@ export class InvoiceService extends BaseService { */ if (invoiceResource.status === InvoiceStatus.PAID) { const paymentService = new PaymentService(this.user) - // Same batched-deposit routing as invoice.paid: a paid-on-create - // payment must land in Undeposited Funds so the payout deposit can - // sweep it, otherwise it deposits straight to the bank and the batched - // deposit can't link it. + // Same routing as invoice.paid: batched → Undeposited Funds so the + // payout deposit can sweep it later. const depositToAccountRef = await this.resolveDepositToAccountRef( intuitApiService, isBatchedDeposit, diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 60523555..9a4a316e 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -470,8 +470,7 @@ export class WebhookService extends BaseService { } } - // Writes the FAILED absorbed-fee sync log shared by the no-mapping and - // QB-error paths of handlePaymentSucceeded. + // Shared FAILED absorbed-fee log for the no-mapping and QB-error paths. private async logAbsorbedFeeFailure(opts: { copilotId: string feeAmount: string @@ -522,8 +521,7 @@ export class WebhookService extends BaseService { const { id: paymentId, invoiceId } = resource.data const platformFee = feeAmount.paidByPlatform - // Absorbed-fee flag gates this handler; read it before any fetch so an - // off-flag portal never calls out to Copilot. + // 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) { @@ -534,11 +532,8 @@ export class WebhookService extends BaseService { } const syncLogService = new SyncLogService(this.user) - // Cheap duplicate short-circuit: any prior claim row (PENDING/SUCCESS/ - // FAILED) means this event was already taken, so skip the sleep + Copilot - // fetch. Deliberately status-blind, mirroring claimWebhookEvent below — a - // redelivery never reprocesses; recovering a FAILED attempt is the resync - // cron's job, not the webhook's. + // 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, @@ -562,8 +557,7 @@ export class WebhookService extends BaseService { `Invoice not found in Assembly for invoice id: ${invoiceId}`, ) - // Fetch the invoice-sync row before claiming so the frozen batched defer - // (below) can return with zero sync-log rows written. + // 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', @@ -573,8 +567,7 @@ export class WebhookService extends BaseService { ]) if (invoiceSync?.isBatchedDeposit) { - // Frozen batched intent: the payout deposit books the fee. Defer before - // claiming so no stale PENDING row is left behind. + // Frozen batched: the payout books the fee. Defer before claiming. console.info( 'WebhookService#handlePaymentSucceeded | Batched-deposit mode (frozen); deferring to payout event', ) @@ -593,8 +586,7 @@ export class WebhookService extends BaseService { return } - // Handled post-claim so the update goes against the row just claimed above, - // instead of racing another redelivery's insert. + // Post-claim so the update targets the row just claimed, not a racing insert. if (!invoiceSync) { await this.logAbsorbedFeeFailure({ copilotId: paymentId, @@ -653,10 +645,8 @@ export class WebhookService extends BaseService { const syncLogService = new SyncLogService(this.user) const copilotInvoiceIds = lineItems.map((line) => line.copilotInvoiceId) - // Resolve the frozen per-invoice intent before claiming. A payout whose - // invoices are all frozen non-batched books nothing, so skip it with zero - // sync-log rows — claiming first would leave a PENDING row that later flips - // to a spurious FAILED. + // 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) => @@ -718,11 +708,8 @@ export class WebhookService extends BaseService { ) } - // An invoice is unresolved when it has no SUCCESS INVOICE/PAID sync log - // yet (payment unprocessed or failed). webhookInvoicePaid throws without - // an invoice-sync row, so a SUCCESS PAID log always has its join match — - // a miss here is a missing payment, not a dropped row. Fail the whole - // payout (v1: manual recovery, no partial deposit). + // A SUCCESS PAID log always has an invoice-sync row (webhookInvoicePaid + // throws otherwise), so a miss here is a missing payment. Fail the payout. const unresolved = copilotInvoiceIds.filter( (id) => !paymentIdByInvoice.has(id), ) @@ -733,9 +720,8 @@ export class WebhookService extends BaseService { ) } - // All-non-batched already skipped before the claim, so any non-batched - // invoice here means a mixed payout — unsupported in v1. Every id - // resolved above (unresolved check), so get() is defined. + // All-non-batched skipped pre-claim; a non-batched invoice here = mixed. + // get() is non-null — every id passed the unresolved check above. const allBatched = copilotInvoiceIds.every( (id) => paymentIdByInvoice.get(id)!.isBatchedDeposit, ) From d8997470048f10e82569c66bca6c5ad7d2046983 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Jul 2026 12:05:23 +0545 Subject: [PATCH 29/49] refactor(OUT-4010): replace non-null assertion operator with optional chaining --- src/app/api/quickbooks/webhook/webhook.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 9a4a316e..bd4b077f 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -723,7 +723,7 @@ export class WebhookService extends BaseService { // All-non-batched skipped pre-claim; a non-batched invoice here = mixed. // get() is non-null — every id passed the unresolved check above. const allBatched = copilotInvoiceIds.every( - (id) => paymentIdByInvoice.get(id)!.isBatchedDeposit, + (id) => paymentIdByInvoice.get(id)?.isBatchedDeposit, ) if (!allBatched) { throw new APIError( From 9f7274ce33a06af59da4d7f3d341032a2e40c49b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Jul 2026 17:11:40 +0545 Subject: [PATCH 30/49] feat(OUT-4011): add mixed-payout notification code, context, and copy - PAYOUT_MIXED_INTENT_CODE sentinel + AppActionableErrorCodes routing - invoiceNumbers context field for multi-invoice failures - QB_PAYOUT_MIXED_INTENT body/email copy names the affected invoices Co-Authored-By: Claude Opus 4.8 --- src/app/api/core/types/notification.ts | 4 ++++ .../api/notification/notification.helper.ts | 24 +++++++++++++++++++ src/constant/intuitErrorCode.ts | 11 +++++++++ 3 files changed, 39 insertions(+) diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index 83d6f080..30097489 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', } /** @@ -25,6 +26,9 @@ export interface NotificationContext { eventType?: string entityKey?: string invoiceNumber?: string + // Comma-joined invoice numbers for a multi-invoice failure (mixed payout), + // where the single invoiceNumber above can't hold them all. + invoiceNumbers?: string customerName?: string productName?: string qbItemName?: string diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index a1e4d4cc..5e58a571 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,27 @@ 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}` + : '' + 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}, so nothing was double-booked. Record this payout's deposit manually in QuickBooks. This will not retry automatically.` + }, + emailSubject: 'QuickBooks sync failed: payout needs manual reconciliation', + emailBody: (ref, ctx) => { + const forInvoices = ctx?.invoiceNumbers + ? ` for invoices ${ctx.invoiceNumbers}` + : '' + 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}, so nothing was double-booked. Record this payout's deposit manually in QuickBooks. This payout will not retry automatically.` + }, + }, + [NotificationActions.QB_INVALID_ACCOUNT_TYPE]: { title: 'QuickBooks sync failed: account type is invalid for this transaction', diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index f78fe985..8b2cc262 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -57,3 +57,14 @@ 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' + +// 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, +} From 58f5ca1a70dd74389fb978231e75f1d0a3e7aa2c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Jul 2026 17:11:52 +0545 Subject: [PATCH 31/49] feat(OUT-4011): detect mixed-intent payouts and dispatch the notification - throw MixedPayoutIntentError; tag the FAILED log with the sentinel and stash the affected invoice numbers in remark - getSuccessfulPaidPaymentIds returns each invoice number - SyncErrorNotifier surfaces the invoice list, keeping the payout id as the ref Co-Authored-By: Claude Opus 4.8 --- .../quickbooks/syncLog/syncErrorNotifier.ts | 17 ++++++++- .../api/quickbooks/syncLog/syncLog.service.ts | 11 +++++- .../api/quickbooks/webhook/webhook.service.ts | 37 +++++++++++++++---- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts index 2682d76a..b9533b91 100644 --- a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -5,7 +5,10 @@ import { NotificationContext, } from '@/app/api/core/types/notification' import { NotificationService } from '@/app/api/notification/notification.service' -import { UserActionableErrorCodes } from '@/constant/intuitErrorCode' +import { + AppActionableErrorCodes, + UserActionableErrorCodes, +} from '@/constant/intuitErrorCode' import { QBSyncLogSelectSchemaType } from '@/db/schema/qbSyncLogs' import { getPortalConnection } from '@/db/service/token.service' @@ -18,7 +21,11 @@ export function getActionForErrorCode( errorCode: string | null | undefined, ): NotificationActions | null { if (!errorCode) return null - return UserActionableErrorCodes[errorCode] ?? null + return ( + UserActionableErrorCodes[errorCode] ?? + AppActionableErrorCodes[errorCode] ?? + null + ) } /** @@ -71,6 +78,12 @@ export class SyncErrorNotifier extends BaseService { productName: log.productName ?? undefined, qbItemName: log.qbItemName ?? undefined, errorMessage: log.errorMessage ?? undefined, + // Mixed-payout rows stash the affected invoice numbers in `remark`; surface + // them for the body while copilotId stays the ref. + invoiceNumbers: + action === NotificationActions.QB_PAYOUT_MIXED_INTENT + ? (log.remark ?? undefined) + : undefined, } const portal = await getPortalConnection(this.user.workspaceId) diff --git a/src/app/api/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index 0a5b93eb..7d476abd 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -388,7 +388,12 @@ export class SyncLogService extends BaseService { */ async getSuccessfulPaidPaymentIds( copilotInvoiceIds: string[], - ): Promise> { + ): Promise< + Map< + string, + { paymentId: string; isBatchedDeposit: boolean; invoiceNumber: string } + > + > { if (copilotInvoiceIds.length === 0) return new Map() const rows = await this.db @@ -396,6 +401,7 @@ export class SyncLogService extends BaseService { copilotId: QBSyncLog.copilotId, quickbooksId: QBSyncLog.quickbooksId, isBatchedDeposit: QBInvoiceSync.isBatchedDeposit, + invoiceNumber: QBSyncLog.invoiceNumber, }) .from(QBSyncLog) .innerJoin( @@ -419,13 +425,14 @@ export class SyncLogService extends BaseService { const paymentIdByInvoice = new Map< string, - { paymentId: string; isBatchedDeposit: boolean } + { 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 diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index bd4b077f..f6ce5a35 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -36,8 +36,14 @@ import { addSyncBreadcrumb } from '@/utils/sentry' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' import { AccountTypeObj } from '@/constant/qbConnection' +import { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode' import { TokenService } from '@/app/api/quickbooks/token/token.service' +// A payout that mixes batched and non-batched invoices. Thrown so the single +// FAILED-log write in the catch can tag it with the routable sentinel code +// (a plain APIError would land as "400" and skip the IU notification). +class MixedPayoutIntentError extends Error {} + export class WebhookService extends BaseService { async handleWebhookEvent( body: WebhookEventResponseType, @@ -726,8 +732,7 @@ export class WebhookService extends BaseService { (id) => paymentIdByInvoice.get(id)?.isBatchedDeposit, ) if (!allBatched) { - throw new APIError( - httpStatus.BAD_REQUEST, + throw new MixedPayoutIntentError( `Payout ${payoutId} mixes batched and non-batched invoices; unsupported`, ) } @@ -802,7 +807,18 @@ export class WebhookService extends BaseService { 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(', ') + // 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, @@ -811,14 +827,19 @@ export class WebhookService extends BaseService { copilotId: payoutId, amount: payout.netAmount.toFixed(2), feeAmount: feeCents.toFixed(2), - remark: 'Stripe payout batched deposit', - qbItemName: 'Stripe payout', + remark: + isMixed && affectedInvoiceNumbers + ? affectedInvoiceNumbers + : 'Stripe payout batched deposit', errorMessage: errorWithCode.message, - errorCode: errorWithCode.code?.toString(), - // Terminal: no PAYOUT resync path yet, so retrying only burns - // attempts to a misleading alert. Recovery is manual for now. + errorCode: isMixed + ? PAYOUT_MIXED_INTENT_CODE + : errorWithCode.code?.toString(), + // Terminal: no PAYOUT resync path, so retrying only burns attempts. shouldRetry: false, - category: getCategory(errorWithCode), + category: isMixed + ? FailedRecordCategoryType.OTHERS + : getCategory(errorWithCode), }) console.error( `WebhookService#handlePayoutReconciliationCompleted :: Error | Portal Id: ${this.user.workspaceId} | Payout: ${payoutId}`, From 6f116d2ede56b3e9eb228e7db5e76f20506ee534 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 28 Jul 2026 17:14:19 +0545 Subject: [PATCH 32/49] test(OUT-4011): cover mixed-payout invoice numbers and stale-object void - mixed payout persists affected invoice numbers to remark; resolvePayments returns them - notifier surfaces the invoice list through the real copy (both channels) - invoice.voided on an OPEN row surfaces QBO 5010 as error_code (QB_STALE_OBJECT) - fix duplicate createDeposit mock key; add IU-notify Copilot mocks Co-Authored-By: Claude Opus 4.8 --- test/helpers/mocks.ts | 6 +- .../invoiceVoided/qbVoidStaleObject.test.ts | 75 +++++++++++++++++++ .../payoutReconciliation/mixed.test.ts | 9 +++ .../resolvePayments.test.ts | 1 + .../notification/notification.helper.test.ts | 32 ++++++++ .../unit/quickbooks/syncErrorNotifier.test.ts | 52 +++++++++++++ 6 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 test/integration/quickbooks/invoiceVoided/qbVoidStaleObject.test.ts diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index f0de3559..94ee7903 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -71,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, } } @@ -154,9 +157,6 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { createPurchase: vi.fn().mockResolvedValue({ Purchase: { Id: TEST_QB_PURCHASE_ID, SyncToken: '0' }, }), - createDeposit: vi.fn().mockResolvedValue({ - Deposit: { Id: 'qb-deposit-1', SyncToken: '0' }, - }), deletePurchase: vi.fn().mockResolvedValue({ Purchase: { Id: TEST_QB_PURCHASE_ID, status: 'Deleted' }, }), 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/payoutReconciliation/mixed.test.ts b/test/integration/quickbooks/payoutReconciliation/mixed.test.ts index 6f067b0d..769356f9 100644 --- a/test/integration/quickbooks/payoutReconciliation/mixed.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/mixed.test.ts @@ -5,6 +5,7 @@ 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, @@ -53,5 +54,13 @@ describe('payout — invoices froze a mix of batched and non-batched', () => { 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/resolvePayments.test.ts b/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts index 45dc814f..9b5cd065 100644 --- a/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/resolvePayments.test.ts @@ -59,6 +59,7 @@ describe('SyncLogService.getSuccessfulPaidPaymentIds', () => { 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 diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts index 580ce839..1e41d68e 100644 --- a/test/unit/notification/notification.helper.test.ts +++ b/test/unit/notification/notification.helper.test.ts @@ -82,6 +82,38 @@ 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, so nothing') + expect(detail.body).not.toContain('for invoices') + }) + it('5010 (invoice-only after suppression) warns that the failure is final', () => { const ctx: NotificationContext = { entityType: 'invoice', diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts index deb6c752..6ed6b000 100644 --- a/test/unit/quickbooks/syncErrorNotifier.test.ts +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -42,9 +42,15 @@ import { 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 +76,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 +93,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() @@ -223,6 +238,43 @@ 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', + }) + + // 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('dispatches a notification for a FAILED row with a user-actionable code', async () => { const notifier = new SyncErrorNotifier(user) From 81a43de02a4a8b1ebef19f4bcfb5c6a3dfc0fb06 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Jul 2026 12:36:17 +0545 Subject: [PATCH 33/49] =?UTF-8?q?fix(OUT-4011):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20passive=20retry=20copy,=20nullable=20context=20fiel?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mixed-payout copy reads "will not be retried automatically" (both channels) - NotificationContext nullable string fields are string | null, dropping the ?? undefined normalization in SyncErrorNotifier Co-Authored-By: Claude Opus 4.8 --- src/app/api/core/types/notification.ts | 14 ++++++++------ src/app/api/notification/notification.helper.ts | 4 ++-- .../api/quickbooks/syncLog/syncErrorNotifier.ts | 12 ++++++------ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index 30097489..fee09918 100644 --- a/src/app/api/core/types/notification.ts +++ b/src/app/api/core/types/notification.ts @@ -25,12 +25,14 @@ export interface NotificationContext { entityType?: string eventType?: string entityKey?: string - invoiceNumber?: 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 - customerName?: string - productName?: string - qbItemName?: string - errorMessage?: string + invoiceNumbers?: string | null + customerName?: string | null + productName?: string | null + qbItemName?: string | null + errorMessage?: string | null } diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index 5e58a571..19da981b 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -249,14 +249,14 @@ export const NotificationCopy: Record< const forInvoices = ctx?.invoiceNumbers ? ` for invoices ${ctx.invoiceNumbers}` : '' - 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}, so nothing was double-booked. Record this payout's deposit manually in QuickBooks. This will not retry automatically.` + 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}, so nothing was double-booked. 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}` : '' - 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}, so nothing was double-booked. Record this payout's deposit manually in QuickBooks. This payout will not retry automatically.` + 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}, so nothing was double-booked. Record this payout's deposit manually in QuickBooks. This payout will not be retried automatically.` }, }, diff --git a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts index b9533b91..46c1008e 100644 --- a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -73,16 +73,16 @@ export class SyncErrorNotifier extends BaseService { 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, // Mixed-payout rows stash the affected invoice numbers in `remark`; surface // them for the body while copilotId stays the ref. invoiceNumbers: action === NotificationActions.QB_PAYOUT_MIXED_INTENT - ? (log.remark ?? undefined) + ? log.remark : undefined, } const portal = await getPortalConnection(this.user.workspaceId) From 413dd1a78149d339c4e139a2d4d74d5f225ae5c8 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Jul 2026 12:17:35 +0545 Subject: [PATCH 34/49] feat(OUT-4012): warn before changing the bank-deposit flag Show a confirmation modal when saving invoice settings that flip bankDepositFeeFlag, so users acknowledge that the change applies only to new invoices and that a payout mixing pre/post-change invoices may need manual reconciliation. UX safeguard only; fires on save and only when the flag differs from its saved value, both directions. - add reusable ConfirmModal (portal, Escape/backdrop dismiss, a11y ids) - gate the invoice save behind requestInvoiceSettingsSave in useSettings - render the modal from SettingAccordion; InvoiceDetail toggle unchanged Co-Authored-By: Claude Opus 4.8 --- .../dashboard/settings/SettingAccordion.tsx | 15 ++++- src/components/ui/ConfirmModal.tsx | 67 +++++++++++++++++++ src/hook/useSettings.ts | 26 ++++++- 3 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 src/components/ui/ConfirmModal.tsx diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index 4105c997..64014baf 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,7 +40,6 @@ export default function SettingAccordion({ const { settingState, - submitInvoiceSettings, cancelInvoiceSettings, isLoading, changeSettings, @@ -47,6 +47,10 @@ export default function SettingAccordion({ bankAccountOptions, bankAccountsError, canSave, + showBankDepositWarning, + requestInvoiceSettingsSave, + confirmBankDepositChange, + cancelBankDepositChange, } = useInvoiceDetailSettings() const { @@ -172,7 +176,7 @@ export default function SettingAccordion({ variant="primary" prefixIcon="Check" disabled={!canSave} - onClick={submitInvoiceSettings} + onClick={requestInvoiceSettingsSave} /> )} @@ -202,6 +206,13 @@ export default function SettingAccordion({ ) })} + ) } diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx new file mode 100644 index 00000000..8bf3dea2 --- /dev/null +++ b/src/components/ui/ConfirmModal.tsx @@ -0,0 +1,67 @@ +'use client' +import { useEffect, useId } from 'react' +import { createPortal } from 'react-dom' +import { Button } from 'copilot-design-system' + +type ConfirmModalProps = { + open: boolean + title: string + description: string + confirmLabel?: string + cancelLabel?: string + onConfirm: () => void + onCancel: () => void +} + +export default function ConfirmModal({ + open, + title, + description, + confirmLabel = 'Continue', + cancelLabel = 'Cancel', + onConfirm, + onCancel, +}: ConfirmModalProps) { + const titleId = useId() + const descId = useId() + + // Wire Escape-to-cancel while open. + useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onCancel() + } + 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/hook/useSettings.ts b/src/hook/useSettings.ts index 5d0ceaf0..5c2e4e9e 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -442,6 +442,7 @@ export const useInvoiceDetailSettings = () => { initialInvoiceSetting, ) const [showButton, setShowButton] = useState(false) + const [showBankDepositWarning, setShowBankDepositWarning] = useState(false) const [intialSettingState, setIntialSettingState] = useState< InvoiceSettingType | undefined >() @@ -522,10 +523,29 @@ 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, @@ -533,6 +553,10 @@ export const useInvoiceDetailSettings = () => { bankAccountOptions, bankAccountsError, canSave, + showBankDepositWarning, + requestInvoiceSettingsSave, + confirmBankDepositChange, + cancelBankDepositChange, } } From 523392d5c7b938d87c4ad0ffeb65a55563e139f6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 29 Jul 2026 12:59:21 +0545 Subject: [PATCH 35/49] fix(OUT-4012): add focus management to the confirm modal Address Greptile P2: the aria-modal dialog left focus on the background save button with no trap or restoration. On open, move focus into the dialog, trap Tab/Shift+Tab between its buttons, and restore focus to the previously focused element on close. Keying the effect on `open` via an onCancel ref also stops it re-subscribing on every render. Co-Authored-By: Claude Opus 4.8 --- src/components/ui/ConfirmModal.tsx | 34 +++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 8bf3dea2..7bebbfdb 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -1,5 +1,5 @@ 'use client' -import { useEffect, useId } from 'react' +import { useEffect, useId, useRef } from 'react' import { createPortal } from 'react-dom' import { Button } from 'copilot-design-system' @@ -24,16 +24,39 @@ export default function ConfirmModal({ }: ConfirmModalProps) { const titleId = useId() const descId = useId() + const dialogRef = useRef(null) + // Keep the latest onCancel without re-running the focus effect each render. + const onCancelRef = useRef(onCancel) + onCancelRef.current = onCancel - // Wire Escape-to-cancel while open. + // On open: focus into the dialog, trap Tab, and restore focus on close. useEffect(() => { if (!open) return + const previouslyFocused = document.activeElement as HTMLElement | null + const focusables = Array.from( + dialogRef.current?.querySelectorAll('button') ?? [], + ) + focusables[0]?.focus() + const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onCancel() + if (e.key === 'Escape') return onCancelRef.current() + if (e.key !== 'Tab' || focusables.length === 0) return + const first = focusables[0] + const last = focusables[focusables.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]) + return () => { + document.removeEventListener('keydown', onKeyDown) + previouslyFocused?.focus() + } + }, [open]) if (!open) return null @@ -43,6 +66,7 @@ export default function ConfirmModal({ onClick={onCancel} >
Date: Fri, 31 Jul 2026 12:54:25 +0545 Subject: [PATCH 36/49] refactor(OUT-4012): split confirm-modal effects per review Replace the onCancelRef workaround with two focused effects: one keyed on `open` for focus-in/restore (runs once), one keyed on `open`+`onCancel` for the Escape + Tab-trap listener. Clearer, honest dependency arrays. Co-Authored-By: Claude Opus 4.8 --- src/components/ui/ConfirmModal.tsx | 35 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/components/ui/ConfirmModal.tsx b/src/components/ui/ConfirmModal.tsx index 7bebbfdb..171037f7 100644 --- a/src/components/ui/ConfirmModal.tsx +++ b/src/components/ui/ConfirmModal.tsx @@ -25,24 +25,28 @@ export default function ConfirmModal({ const titleId = useId() const descId = useId() const dialogRef = useRef(null) - // Keep the latest onCancel without re-running the focus effect each render. - const onCancelRef = useRef(onCancel) - onCancelRef.current = onCancel - // On open: focus into the dialog, trap Tab, and restore focus on close. + // 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 focusables = Array.from( - dialogRef.current?.querySelectorAll('button') ?? [], - ) - focusables[0]?.focus() + 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 onCancelRef.current() - if (e.key !== 'Tab' || focusables.length === 0) return - const first = focusables[0] - const last = focusables[focusables.length - 1] + 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() @@ -52,11 +56,8 @@ export default function ConfirmModal({ } } document.addEventListener('keydown', onKeyDown) - return () => { - document.removeEventListener('keydown', onKeyDown) - previouslyFocused?.focus() - } - }, [open]) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open, onCancel]) if (!open) return null From ce487c0b623ab07ab98e447ccd7299e0c2bfe964 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 31 Jul 2026 12:36:23 +0545 Subject: [PATCH 37/49] feat(OUT-4005): add qb_payout_sync table schema and migration New table stores the payout payload so a failed reconciliation can be rebuilt on resync. Amounts are integer cents, matching line_items. Co-Authored-By: Claude Opus 4.8 --- ...0260729105911_add_qb_payout_sync_table.sql | 15 + .../meta/20260729105911_snapshot.json | 1258 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/index.ts | 2 + src/db/schema/qbPayoutSync.ts | 51 + src/type/dto/webhook.dto.ts | 17 +- test/helpers/testDb.ts | 3 +- .../quickbooks/payoutResync/schema.test.ts | 42 + 8 files changed, 1385 insertions(+), 10 deletions(-) create mode 100644 src/db/migrations/20260729105911_add_qb_payout_sync_table.sql create mode 100644 src/db/migrations/meta/20260729105911_snapshot.json create mode 100644 src/db/schema/qbPayoutSync.ts create mode 100644 test/integration/quickbooks/payoutResync/schema.test.ts 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/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 c0d75bd3..6ef0c736 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -197,6 +197,13 @@ "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/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/type/dto/webhook.dto.ts b/src/type/dto/webhook.dto.ts index 9f4d3e54..c2a5c7e6 100644 --- a/src/type/dto/webhook.dto.ts +++ b/src/type/dto/webhook.dto.ts @@ -135,6 +135,13 @@ 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(), @@ -146,15 +153,7 @@ export const PayoutReconciliationCompletedSchema = z.object({ netAmount: z.number(), status: z.string(), }), - lineItems: z - .array( - z.object({ - copilotInvoiceId: z.string(), - grossAmount: z.number(), - feeAmount: z.number(), - }), - ) - .min(1), + lineItems: z.array(PayoutLineItemSchema).min(1), }), }) export type PayoutReconciliationCompletedType = z.infer< 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/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 }, + ]) + }) +}) From ed4c2cfd775f583d42b3811f708d3c2f174f3e7e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 31 Jul 2026 12:36:47 +0545 Subject: [PATCH 38/49] feat(OUT-4005): add payout reconciliation service and deposit lookup PayoutService.reconcile validates the payout, resolves its payments, and builds one batched deposit. On resync it reuses an already-made deposit (stored id, then a txn-date query on PrivateNote) so a retry can't duplicate. Adds getDepositsByTxnDate and its schemas. Co-Authored-By: Claude Opus 4.8 --- .../api/quickbooks/payout/payout.errors.ts | 19 ++ .../api/quickbooks/payout/payout.service.ts | 225 ++++++++++++++ src/type/dto/intuitAPI.dto.ts | 15 + src/utils/intuitAPI.ts | 38 +++ test/helpers/mocks.ts | 2 + .../payoutServiceReconcile.test.ts | 287 ++++++++++++++++++ test/unit/dto/depositQueryResponse.test.ts | 16 + test/unit/dto/payoutLineItem.test.ts | 19 ++ test/unit/payout/payoutErrors.test.ts | 41 +++ .../intuitAPI.getDepositsByTxnDate.test.ts | 100 ++++++ 10 files changed, 762 insertions(+) create mode 100644 src/app/api/quickbooks/payout/payout.errors.ts create mode 100644 src/app/api/quickbooks/payout/payout.service.ts create mode 100644 test/integration/quickbooks/payoutResync/payoutServiceReconcile.test.ts create mode 100644 test/unit/dto/depositQueryResponse.test.ts create mode 100644 test/unit/dto/payoutLineItem.test.ts create mode 100644 test/unit/payout/payoutErrors.test.ts create mode 100644 test/unit/utils/intuitAPI.getDepositsByTxnDate.test.ts 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..ea816e9d --- /dev/null +++ b/src/app/api/quickbooks/payout/payout.service.ts @@ -0,0 +1,225 @@ +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' + +export class PayoutService extends BaseService { + private syncLogService = new SyncLogService(this.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 }> { + 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/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 3620b989..baefecb1 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -287,6 +287,21 @@ export const QBDepositResponseSchema = z.object({ }) 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/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 6e6cb698..10470bf7 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -16,6 +16,7 @@ import { QBDepositCreatePayloadType, QBDepositResponseSchema, QBDepositResponseType, + QBDepositQueryResponseSchema, QBDeletePayloadType, QBDestructiveInvoicePayloadSchema, QBItemRowType, @@ -1006,6 +1007,42 @@ export default class IntuitAPI { 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 { @@ -1134,5 +1171,6 @@ export default class IntuitAPI { 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/test/helpers/mocks.ts b/test/helpers/mocks.ts index 94ee7903..676256a3 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -142,6 +142,8 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { 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/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/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/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/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) + }) +}) From 028757ffcef63da5cc79f39762a4bddea09c8554 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 31 Jul 2026 17:23:58 +0545 Subject: [PATCH 39/49] refactor(OUT-4005): use constructor to instantiate service --- src/app/api/quickbooks/payout/payout.service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/app/api/quickbooks/payout/payout.service.ts b/src/app/api/quickbooks/payout/payout.service.ts index ea816e9d..9054ced2 100644 --- a/src/app/api/quickbooks/payout/payout.service.ts +++ b/src/app/api/quickbooks/payout/payout.service.ts @@ -18,9 +18,15 @@ 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' export class PayoutService extends BaseService { - private syncLogService = new SyncLogService(this.user) + 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. From 736a8fabb83a6e535054e422e4a2a46465feff95 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 31 Jul 2026 12:37:12 +0545 Subject: [PATCH 40/49] feat(OUT-4005): reconcile payouts into batched deposits via webhook and resync Webhook delegates to PayoutService (saving the payout row before claiming). Resync claims the row FAILED->PENDING before work so overlapping runs can't double-deposit, and skips the absorbed-fee expense for batched invoices. Routes token-exchange resync through afterIfAvailable to keep next/server out of the service graph. Co-Authored-By: Claude Opus 4.8 --- src/app/api/quickbooks/auth/auth.service.ts | 3 +- src/app/api/quickbooks/sync/sync.service.ts | 107 ++++++++++-- .../api/quickbooks/webhook/webhook.service.ts | 148 +++------------- test/helpers/seed.ts | 39 +++++ .../bankAccountDeleted.test.ts | 3 +- .../transientRetryable.test.ts | 71 ++++++++ .../unresolvedLine.test.ts | 4 +- .../quickbooks/payoutResync/resync.test.ts | 161 ++++++++++++++++++ 8 files changed, 400 insertions(+), 136 deletions(-) create mode 100644 test/integration/quickbooks/payoutReconciliation/transientRetryable.test.ts create mode 100644 test/integration/quickbooks/payoutResync/resync.test.ts diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index 404fa8a9..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( @@ -169,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 = { diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index e83746e2..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, @@ -401,17 +488,6 @@ export class SyncService extends BaseService { const authService = new AuthService(this.user) for (const log of logs) { - // TODO: no PAYOUT resync path yet — skip so terminal payout rows don't - // burn attempts to a misleading alert. Auto-recovery is a follow-up. - if (log.entityType === EntityType.PAYOUT) { - CustomLogger.info({ - message: - 'SyncService#intiateSync | Skipping payout log (no resync path)', - obj: { copilotId: log.copilotId, workspaceId: this.user.workspaceId }, - }) - continue - } - // check and update attempt for failed logs const resyncAttemtps = await this.checkAndUpdateAttempt(log) if (resyncAttemtps.maxAttempts) { @@ -475,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/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index f6ce5a35..257b1766 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -10,6 +10,11 @@ import { 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' @@ -28,21 +33,14 @@ import { import { validateAccessToken } from '@/utils/auth' import { CopilotAPI } from '@/utils/copilotAPI' import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error' -import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' +import { IntuitAPITokensType } from '@/utils/intuitAPI' import CustomLogger from '@/utils/logger' import { sleep } from '@/utils/sleep' import { getCategory, getShouldRetryForCategory } from '@/utils/synclog' import { addSyncBreadcrumb } from '@/utils/sentry' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' -import { AccountTypeObj } from '@/constant/qbConnection' import { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode' -import { TokenService } from '@/app/api/quickbooks/token/token.service' - -// A payout that mixes batched and non-batched invoices. Thrown so the single -// FAILED-log write in the catch can tag it with the routable sentinel code -// (a plain APIError would land as "400" and skip the IU notification). -class MixedPayoutIntentError extends Error {} export class WebhookService extends BaseService { async handleWebhookEvent( @@ -599,6 +597,7 @@ export class WebhookService extends BaseService { feeAmount: platformFee.toFixed(2), errorMessage: `No invoice found in invoice sync table for invoice id: ${invoiceId}`, shouldRetry: true, + invoiceNumber: invoice.number, }) return } @@ -649,6 +648,7 @@ export class WebhookService extends BaseService { 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, @@ -665,6 +665,20 @@ export class WebhookService extends BaseService { 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, @@ -677,116 +691,11 @@ export class WebhookService extends BaseService { return } - // Computed before the try so the FAILED-log path can record the amounts. - const { grossCents, feeCents } = lineItems.reduce( - (acc, line) => { - acc.grossCents += line.grossAmount - acc.feeCents += line.feeAmount - return acc - }, - { grossCents: 0, feeCents: 0 }, - ) - try { - validateAccessToken(qbTokenInfo) - - // v1: refunds unsupported — a negative line means QBO cannot link to a Payment. - if (lineItems.some((line) => line.grossAmount < 0)) { - throw new APIError( - httpStatus.BAD_REQUEST, - `Payout ${payoutId} contains refund lines; batched deposit unsupported in v1`, - ) - } - - // A negative total fee would drop the fee line and unbalance the - // deposit. Abort instead (fee credits arrive with refund support). - if (feeCents < 0) { - throw new APIError( - httpStatus.BAD_REQUEST, - `Payout ${payoutId} has a negative aggregate fee (${feeCents}); unsupported in v1`, - ) - } - - if (new Set(copilotInvoiceIds).size !== copilotInvoiceIds.length) { - throw new APIError( - httpStatus.BAD_REQUEST, - `Payout ${payoutId} contains duplicate invoice line items`, - ) - } - - // A SUCCESS PAID log always has an invoice-sync row (webhookInvoicePaid - // throws otherwise), so a miss here is a missing payment. Fail the payout. - const unresolved = copilotInvoiceIds.filter( - (id) => !paymentIdByInvoice.has(id), - ) - if (unresolved.length > 0) { - throw new APIError( - httpStatus.NOT_FOUND, - `Payout ${payoutId}: no SUCCESS INVOICE/PAID sync log for invoices [${unresolved.join(', ')}]`, - ) - } - - // All-non-batched skipped pre-claim; a non-batched invoice here = mixed. - // get() is non-null — every id passed the unresolved check above. - const allBatched = copilotInvoiceIds.every( - (id) => paymentIdByInvoice.get(id)?.isBatchedDeposit, - ) - if (!allBatched) { - throw new MixedPayoutIntentError( - `Payout ${payoutId} mixes batched and non-batched invoices; unsupported`, - ) - } - - if (grossCents - feeCents !== payout.netAmount) { - throw new APIError( - httpStatus.BAD_REQUEST, - `Payout ${payoutId}: deposit total ${grossCents - feeCents} != payout net ${payout.netAmount}`, - ) - } - - // Fail fast on the free local check before any QBO round-trip. - const bankAccountRef = qbTokenInfo.bankAccountRef - if (!bankAccountRef) { - 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) - // Reactivates an archived bank account; a deleted one throws. - 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(payout.arrivalDate * 1000) - .toISOString() - .split('T')[0], - privateNote: `Stripe payout ${payoutId}`, - }, + const { depositId } = await payoutService.reconcile( + payoutRow, + qbTokenInfo, + { runIdempotencyCheck: false }, ) await syncLogService.updateOrCreateQBSyncLog({ @@ -795,7 +704,7 @@ export class WebhookService extends BaseService { eventType: EventType.SETTLED, status: LogStatus.SUCCESS, copilotId: payoutId, - quickbooksId: depositId, + quickbooksId: depositId ?? undefined, amount: payout.netAmount.toFixed(2), feeAmount: feeCents.toFixed(2), remark: 'Stripe payout batched deposit', @@ -835,8 +744,7 @@ export class WebhookService extends BaseService { errorCode: isMixed ? PAYOUT_MIXED_INTENT_CODE : errorWithCode.code?.toString(), - // Terminal: no PAYOUT resync path, so retrying only burns attempts. - shouldRetry: false, + shouldRetry: getShouldRetryForPayout(error), category: isMixed ? FailedRecordCategoryType.OTHERS : getCategory(errorWithCode), diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index 94fa2e1e..7503e7d7 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' @@ -219,3 +220,41 @@ export async function seedPaidInvoiceForPayout(opts: { 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 +}) { + await 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, + }) + await 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/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts index a8856d35..acef9ca9 100644 --- a/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/bankAccountDeleted.test.ts @@ -73,7 +73,8 @@ describe('payout — configured bank account no longer exists in QuickBooks', () entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, status: LogStatus.FAILED, - shouldRetry: false, + // 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. 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 index a4474796..50f1d568 100644 --- a/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts +++ b/test/integration/quickbooks/payoutReconciliation/unresolvedLine.test.ts @@ -56,8 +56,8 @@ describe('payout — one invoice has no PAID sync log', () => { entityType: EntityType.PAYOUT, eventType: EventType.SETTLED, status: LogStatus.FAILED, - // Payout FAILED rows are terminal by design — never retryable. - shouldRetry: false, + // 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. 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() + }) +}) From f1bf9d1543313535f3036e7e425b29c1c8eedf28 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 3 Aug 2026 14:23:55 +0545 Subject: [PATCH 41/49] refactor(OUT-4005): insert payout sync and log in parallel in seedFailedPayout The qb_payout_sync and qb_sync_logs inserts are independent (no FK), so run them together with Promise.all instead of sequentially. Co-Authored-By: Claude Opus 4.8 --- test/helpers/seed.ts | 45 +++++++++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index 7503e7d7..dbfbf089 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -236,25 +236,28 @@ export async function seedFailedPayout(opts: { qbDepositId?: string errorMessage?: string }) { - await 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, - }) - await 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, - }) + // 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, + }), + ]) } From 3e98ab3799977d003ceb711e7d3557d5861faba9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 31 Jul 2026 12:38:20 +0545 Subject: [PATCH 42/49] chore(OUT-4005): add test typecheck script + CI job, fix stale test types Adds yarn typecheck:test (tsc over test/tsconfig.json) and a CI job so type errors in tests gate PRs. Fixes the pre-existing type errors it surfaces (missing bankAccountRef fixtures, stale UnitPrice input, string status/id) and loads the ambient shims in test/tsconfig. Also adds CLAUDE.md. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test.yml | 21 +++ CLAUDE.md | 157 ++++++++++++++++++ package.json | 3 +- .../invoiceCreated/lazyItemCreation.test.ts | 2 +- .../invoicePaid/frozenIntentRouting.test.ts | 2 - test/tsconfig.json | 3 +- test/unit/utils/intuitAPI.accounts.test.ts | 1 + test/unit/utils/intuitAPI.responses.test.ts | 2 +- test/unit/utils/intuitAPI.test.ts | 1 + 9 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 CLAUDE.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e34b432e..ccdba289 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,27 @@ name: CI on: pull_request jobs: + typecheck: + name: Typecheck tests + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.14.0 + cache: yarn + cache-dependency-path: './yarn.lock' + + - name: Install dependencies + run: yarn install + + - name: Typecheck test files + run: yarn typecheck:test + run-tests: name: Run tests runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..60f757d2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,157 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this app is + +A multi-tenant Next.js (App Router) service that synchronizes Copilot / Assembly workspaces with QuickBooks Online (QBO). It runs on Vercel, persists state in Postgres (Supabase in prod, Drizzle ORM throughout), and reacts to Copilot webhooks (`invoice.created/updated/paid/voided/deleted`, `product.updated`, `price.created`, `payment.succeeded`) by mirroring those entities into the corresponding QBO realm. + +A "portal" is one Copilot/Assembly workspace bonded to one QuickBooks realm. Almost every table is keyed by `portalId`; almost every service derives `this.user.workspaceId` from the request token and scopes everything to that portal. + +## Common commands + +Package manager is **Yarn 4 (Berry)**, Node **22.14.0** (`.nvmrc`). + +```bash +yarn install # install +yarn dev # Next dev (Turbopack) +yarn build # next build (CI uses build.sh which also runs drizzle-kit migrate) +yarn lint:check # ESLint over src/ and test/ +yarn prettier:check # Prettier check +yarn lint:fix # ESLint --fix +yarn prettier:fix # Prettier write + +# Tests (Vitest, two projects defined in vitest.config.ts) +yarn test # both: unit then integration (groupOrder enforces this) +yarn test:watch # watch +yarn test:coverage # v8 coverage +npx vitest run --project unit # only unit +npx vitest run --project integration # only integration +npx vitest run test/integration/quickbooks/priceCreated/happyPath.test.ts # single file +npx vitest run -t 'happy path' # by test-name pattern + +# Trigger.dev (background task runtime) +yarn trigger:dev # local dev worker +yarn trigger:deploy # deploy tasks + +# DB migrations (Drizzle Kit, schema lives at src/db/schema/) +npx drizzle-kit generate # create new migration from schema changes +npx drizzle-kit migrate # apply pending migrations to DATABASE_URL + +# One-off operational scripts (tsx, see src/cmd/*) +yarn cmd:rename-qb-accounts +yarn cmd:backfill-product-info +yarn cmd:sync-missed-invoices +yarn cmd:sync-missed-products +``` + +Husky `pre-commit` runs `lint-staged` (eslint --fix + prettier --write on `src/**/*.{ts,tsx}`). CI (`.github/workflows/test.yml`) runs `yarn test` on PRs; `.github/workflows/lint.yml` runs lint+prettier on every push. CI assumes the testcontainers Postgres image is available (Docker is preinstalled on `ubuntu-latest`). + +## Architecture + +### Request → handler shape + +Every API route follows the same skeleton: + +``` +src/app/api/// + route.ts # exports { POST/GET } = withErrorHandler(controllerFn); sets maxDuration + .controller.ts # auth + Sentry scope + parse + delegate to service + .service.ts # extends BaseService; orchestrates DB + external APIs +``` + +Controllers call `authenticate(req)` (`src/app/api/core/utils/authenticate.ts`), which reads `?token=…`, asks Copilot to decrypt it, and returns a `User` (`src/app/api/core/models/User.model.ts`). `User` carries `workspaceId` (= portalId), role, and the lazily-attached `qbConnection` (service-item / client-fee refs). + +`withErrorHandler` (`src/app/api/core/utils/withErrorHandler.ts`) is the **only** error path. It maps `ZodError` / `APIError` / `CopilotApiError` / `RetryableError` / Intuit OAuth + Axios errors to HTTP responses and forwards categorized exceptions to Sentry. Don't add try/catch in route handlers — throw and let this wrapper format. + +### BaseService and the DB singleton + +Services extend `BaseService` (`src/app/api/core/services/base.service.ts`), which holds: + +- `this.db` — the **module-level Drizzle singleton** from `src/db/index.ts` (`DBClient.getInstance()`); `casing: 'snake_case'`. +- `this.user` — the authenticated `User` for the request. +- `setTransaction(tx)` / `unsetTransaction()` — swap `this.db` for a transaction handle inside a `db.transaction(...)` callback, then restore. + +**Pitfall (known, see `memory/project_unsetTransaction_bug.md`):** `unsetTransaction()` is sometimes called inside the transaction callback or skipped on error paths — across `BaseService` subclasses this leaves the singleton pointed at a closed tx. When introducing or modifying transactional code, audit that `setTransaction` / `unsetTransaction` are paired in `try/finally` and that nested service calls share the tx handle. + +The DB singleton is also why test helpers (`test/helpers/seed.ts`, `test/helpers/testDb.ts`) import `@/db` directly — see `docs/why-test-helpers-use-the-app-db-singleton.md`. Don't introduce a separate test-only Drizzle client; tests must read what the app writes. + +### Webhook flow (the central path) + +`POST /api/quickbooks/webhook` → `WebhookService.handleWebhookEvent` (`src/app/api/quickbooks/webhook/webhook.service.ts`) is a switch on `payload.eventType` that dispatches to `InvoiceService` / `ProductService` / `PaymentService`. A few things to know before changing it: + +1. **Idempotency is enforced via `qb_sync_logs` claim rows.** `SyncLogService.claimWebhookEvent({ copilotId, entityType, eventType, … })` returns `{ claimed: false }` if a row already exists; handlers exit early. Any new webhook handler must call `claimWebhookEvent` before doing real work or duplicate processing will leak into QBO. +2. **`qb_sync_logs.quickbooks_id` is polymorphic.** Its meaning depends on `(entityType, eventType)` — for `INVOICE/PAID` it stores the QBO **Payment** ID, not the Invoice ID. See `memory/project_qb_sync_logs_semantics.md`. +3. **Pre-claim sleeps for ordering.** `INVOICE_UPDATED` / `INVOICE_VOIDED` / `PAYMENT_SUCCEEDED` sleep before `claimWebhookEvent` so a companion event (e.g., `INVOICE_CREATED`) can claim first. The `delayMs` lives in the handler, not the caller — keep it that way; moving the sleep after the claim re-opens the race. +4. **Setting flags gate handlers.** `PRICE_CREATED` / `PRODUCT_UPDATED` no-op when `createNewProductFlag` is false; `PAYMENT_SUCCEEDED` no-ops when `absorbedFeeFlag` is false or there's no platform-paid fee. Read `qb_settings` via `SettingService` rather than passing flags around. +5. **There's a known TOCTOU race on `claimWebhookEvent`** — accepted, parked, will be addressed with an advisory lock + dedupe job, not a rewrite. See `memory/project_qb_sync_logs_toctou_parked.md`. + +### Token refresh + +QBO access tokens expire in ~1h, refresh tokens in ~100 days. `src/utils/intuitAPI.ts` sends authenticated requests; `src/utils/tokenRefresh.ts` (`getValidQbTokens`) refreshes when stale. The `vercel.json` cron `/api/quickbooks/refresh-tokens` runs daily at 06:00 UTC to keep refresh tokens warm. There's a known silent-401 bug — expired tokens cause `null` returns from `getFetchWithHeader/postFetchWithHeaders`; the planned fix is auto-refresh inside those helpers (design at `docs/intuit-api-token-refresh.md`, summary in `memory/project_intuit_api_token_refresh.md`). + +### Background work + +- **Vercel crons** (`vercel.json`): + - `/api/quickbooks/cron` every 12h — kicks off `processResyncForFailedRecords` (Trigger.dev task) to retry failed sync logs. Auth via `Bearer ${CRON_SECRET}`. + - `/api/quickbooks/refresh-tokens` daily 06:00 UTC. +- **Trigger.dev** tasks live in `src/trigger/` (config at `trigger.config.ts`, runtime: node, default 3 retries, `maxDuration: 3600s`). Sentry source maps are uploaded only when `VERCEL_ENV === 'production'`. + +## Multi-tenancy invariant + +Every `WHERE` clause that touches a portal-scoped table needs `portalId = this.user.workspaceId`. Forgetting this leaks one tenant's data into another. The unique indexes on `qb_sync_logs` and `qb_invoice_sync` (see migrations 20260427100328 / 20260427055352) enforce some of this at the DB level, but most of it is service-layer discipline. + +## Database & schema + +- Drizzle schemas in `src/db/schema/*` registered in `src/db/schema/index.ts`. Relations in `relation.ts`. +- Migrations in `src/db/migrations/` (prefix `supabase`, generated by drizzle-kit). The `init.sql` (20250701) defines all enums; subsequent files alter. +- Custom column helpers in `src/db/helper/column.helper.ts` (`timestamps`) and enum bridge in `drizzle.helper.ts` (`enumToPgEnum`). +- `qb_payments` table exists but is currently unused (reserved for future) — no rows in prod. See `memory/project_qb_payments_unused.md`. +- Type-safe Zod schemas come from `drizzle-zod` (`createInsertSchema` / `createSelectSchema`); reuse those rather than hand-rolling Zod for DB rows. + +## Testing + +- Two Vitest **projects** in `vitest.config.ts` — `unit` (mock-heavy, isolated) and `integration` (real Postgres via testcontainers). Run order is enforced via `sequence.groupOrder` (unit=0, integration=1). +- Integration project is configured **`pool: 'forks'` + `fileParallelism: false` + `isolate: false`** so all integration tests share one Postgres container _and_ one app DB connection. Don't change these without reading `docs/vitest-gotchas.md` and `docs/why-test-helpers-use-the-app-db-singleton.md`. +- `.env.test` is loaded by `test/integration/globalSetup.ts` with `override: true` so a developer's local `.env` can't leak into tests. `DATABASE_URL` is intentionally **not** in `.env.test` — globalSetup sets it from the container's URI before any worker imports `src/config`. +- Module mocks for integration are in `test/integration/setup.ts` — `@/utils/copilotAPI`, `@/utils/intuitAPI`, and `@sentry/nextjs` must be mocked with **explicit factories** (and Intuit/Copilot mock implementations must use `function`, not `=>`, because the code does `new IntuitAPI(...)`). See `docs/vitest-gotchas.md` items 1–3. +- Test helpers in `test/helpers/`: `seed.ts` (`seedHealthyPortal`, `TEST_PORTAL_ID`, etc.), `webhook.ts` (`postWebhook` via `next-test-api-route-handler`), `testDb.ts` (`truncateAllTestTables`). +- Test-data philosophy in `docs/test-data-dos-and-donts.md`: static fixtures for the thing under test, factories with explicit overrides for single-dimension variants, **no faker** in fixtures or assertions. + +## Path aliases + +``` +@/* → src/* +@test/* → test/* +``` + +Configured in `tsconfig.json` and propagated to Vitest via `vite-tsconfig-paths` (per-project in `vitest.config.ts`). + +## Style notes + +- Prettier: single quotes, no semis, trailing comma all (`.prettierrc`). +- ESLint: `next/core-web-vitals` + TypeScript; `prefer-const` and `no-var` are errors; unused-var underscore prefix is exempt; `@typescript-eslint/no-explicit-any` is disabled (the codebase uses `any` deliberately at framework boundaries). +- Tailwind v4 + `copilot-design-system`. UI surface is small (settings dashboard + OAuth callback) — most work happens in the API/service layer. +- The `docs/` folder is **gitignored** (per `.gitignore`) and used for local decision notes — design docs, post-mortems, comparison tables. Save non-trivial tradeoff discussions there rather than in code comments or commit messages. + +## Things to read before non-trivial changes + +- `docs/testcontainers-vs-local-supabase.md` — why integration tests use testcontainers, not the local Supabase stack. +- `docs/why-test-helpers-use-the-app-db-singleton.md` — why test helpers import `@/db` and what would break if you opened a separate client. +- `docs/vitest-gotchas.md` — the five real traps already hit in this project. +- `docs/test-data-dos-and-donts.md` — the test-data rules. +- `docs/intuit-api-token-refresh.md` — design for the silent-401 fix. + +## What this repo doesn't have + +- No design system / shared component library — UI is a thin dashboard, mostly settings forms. +- No GraphQL, no tRPC — plain Next.js Route Handlers + service classes. +- No DI container — `BaseService` reads `db` from a module singleton; tests work _with_ that constraint, not around it. +- No existing CLAUDE.md until this one. + +## Engineering notes + +- After a successful implementation, the changes will be reviewed by the team lead and greptileAI in github. +- Do not use let unless absolutely necessary. Use const instead. +- Always keep the comments short, on point and easy to understand with easy wordings. This is must. +- Follow DRY, KISS, SOLID, YAGNI principles. diff --git a/package.json b/package.json index 5cd9f720..3f73d9a1 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "cmd:sync-missed-products": "tsx src/cmd/syncMissedProducts/index.ts", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "typecheck:test": "tsc --noEmit -p test/tsconfig.json" }, "dependencies": { "@sentry/nextjs": "^9.13.0", diff --git a/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts b/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts index 44d8010c..4890c3f7 100644 --- a/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts +++ b/test/integration/quickbooks/invoiceCreated/lazyItemCreation.test.ts @@ -44,7 +44,7 @@ describe('POST /api/quickbooks/webhook — invoice.created (lazy item creation f .where( eq( QBProductSync.productId, - invoiceCreatedPayload.data.lineItems[0].productId, + invoiceCreatedPayload.data.lineItems[0].productId!, ), ) expect(rows).toHaveLength(1) diff --git a/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts b/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts index 005b15a4..b3e96c9d 100644 --- a/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts +++ b/test/integration/quickbooks/invoicePaid/frozenIntentRouting.test.ts @@ -21,7 +21,6 @@ describe('POST /api/quickbooks/webhook — invoice.paid routes off the frozen in await seedQBInvoiceSync({ customerId: customer.id, isBatchedDeposit: true, - status: 'open', }) await seedInvoiceCreatedLog() @@ -41,7 +40,6 @@ describe('POST /api/quickbooks/webhook — invoice.paid routes off the frozen in await seedQBInvoiceSync({ customerId: customer.id, isBatchedDeposit: false, - status: 'open', }) await seedInvoiceCreatedLog() diff --git a/test/tsconfig.json b/test/tsconfig.json index 54a7c47b..ee4fcdf1 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../tsconfig.json", - "include": ["**/*.ts"], + // intuit-oauth ambient shim the root config loads; needed for src/config. + "include": ["../src/type/intuit.d.ts", "**/*.ts"], "exclude": ["node_modules"] } diff --git a/test/unit/utils/intuitAPI.accounts.test.ts b/test/unit/utils/intuitAPI.accounts.test.ts index de3ee2c9..09a244c6 100644 --- a/test/unit/utils/intuitAPI.accounts.test.ts +++ b/test/unit/utils/intuitAPI.accounts.test.ts @@ -32,6 +32,7 @@ const baseTokens: IntuitAPITokensType = { assetAccountRef: 'asset', serviceItemRef: 'service', clientFeeRef: 'client-fee', + bankAccountRef: 'bank', } type Row = { diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts index a07a90d3..0c64e1fe 100644 --- a/test/unit/utils/intuitAPI.responses.test.ts +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -35,6 +35,7 @@ const baseTokens: IntuitAPITokensType = { assetAccountRef: 'asset', serviceItemRef: 'service', clientFeeRef: 'client-fee', + bankAccountRef: 'bank', } function makeApi() { @@ -361,7 +362,6 @@ describe('IntuitAPI POST-based writes', () => { const api = makeApi() const result = await api.createItem({ Name: 'Widget', - UnitPrice: 25, Type: 'Service' as never, Taxable: false, }) diff --git a/test/unit/utils/intuitAPI.test.ts b/test/unit/utils/intuitAPI.test.ts index 956bd58a..fa57466a 100644 --- a/test/unit/utils/intuitAPI.test.ts +++ b/test/unit/utils/intuitAPI.test.ts @@ -52,6 +52,7 @@ const baseTokens: IntuitAPITokensType = { assetAccountRef: 'asset', serviceItemRef: 'service', clientFeeRef: 'client-fee', + bankAccountRef: 'bank', } // Builds a customer row in the shape QBO returns inside `QueryResponse.Customer`. From 102df07eae7679e04b97365621637793d80af00c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 3 Aug 2026 14:16:16 +0545 Subject: [PATCH 43/49] feat(OUT-3617): add bank-deposit AB gate primitive and config Parse AB_FEATURE_TESTING_PORTALS into an allowlist (empty/unset = all portals) and expose isPortalInBankDepositABTest for the rollout gate. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 +++ src/config/index.ts | 9 +++++++++ src/utils/abTesting.ts | 11 +++++++++++ 3 files changed, 23 insertions(+) create mode 100644 src/utils/abTesting.ts 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/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/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) +} From 0ff090758e05b98741c363a8cd7b226ce8d6f812 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 3 Aug 2026 14:16:26 +0545 Subject: [PATCH 44/49] feat(OUT-3617): gate bank-deposit backend paths behind the AB allowlist Freeze gate in readBankDepositFeeFlag (non-allowlisted portals freeze non-batched) and a reconcile gate covering both the payout webhook and resync cron. The settings write path strips the flag + bank account for non-AB portals, and GET returns bankDepositEnabled for the UI. The reconcile short-circuit is logged so a rare mid-flight exclusion is visible rather than silent. Co-Authored-By: Claude Opus 4.8 --- .../api/quickbooks/invoice/invoice.service.ts | 3 +++ .../api/quickbooks/payout/payout.service.ts | 10 ++++++++++ .../quickbooks/setting/setting.controller.ts | 19 ++++++++++++++++--- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index db1f9fcd..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' @@ -483,6 +484,8 @@ export class InvoiceService extends BaseService { // 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', diff --git a/src/app/api/quickbooks/payout/payout.service.ts b/src/app/api/quickbooks/payout/payout.service.ts index 9054ced2..0cbf46a2 100644 --- a/src/app/api/quickbooks/payout/payout.service.ts +++ b/src/app/api/quickbooks/payout/payout.service.ts @@ -19,6 +19,7 @@ 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 @@ -84,6 +85,15 @@ export class PayoutService extends BaseService { 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 diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index 5c69f940..7dabdd1c 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,6 +1,7 @@ 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' @@ -41,7 +42,12 @@ export async function getSettings(req: NextRequest) { ? (await getPortalConnection(user.workspaceId))?.bankAccountRef || null : null - return NextResponse.json({ setting, bankAccountRef }) + const bankDepositEnabled = + parsedType.success && parsedType.data === SettingType.INVOICE + ? isPortalInBankDepositABTest(user.workspaceId) + : false + + return NextResponse.json({ setting, bankAccountRef, bankDepositEnabled }) } export async function updateSettings(req: NextRequest) { @@ -54,17 +60,24 @@ export async function updateSettings(req: NextRequest) { const parsedType = z.nativeEnum(SettingType).parse(type) const parsed = SettingRequestSchema.parse(body) - const { bankAccountRef, ...settingFields } = parsed + 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 = { ...settingFields, + ...(isBankDepositAB && { bankDepositFeeFlag }), ...(parsedType === SettingType.INVOICE ? { initialInvoiceSettingMap: true } : { initialProductSettingMap: true }), } const writeBankAccountRef = - parsedType === SettingType.INVOICE && typeof bankAccountRef !== 'undefined' + isBankDepositAB && typeof bankAccountRef !== 'undefined' const setting = await db.transaction(async (tx) => { settingService.setTransaction(tx) From e02a2351ebad76ebe08cd86500ea2d057cf9ca77 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 3 Aug 2026 14:16:34 +0545 Subject: [PATCH 45/49] feat(OUT-3617): hide bank-deposit settings UI when the AB gate is off Read bankDepositEnabled from the settings GET, thread it through the accordion into InvoiceDetail to hide the checkbox + bank-account dropdown, and skip the bank-account fetch entirely for gated-off portals. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/settings/SettingAccordion.tsx | 2 + .../sections/invoice/InvoiceDetail.tsx | 81 ++++++++++--------- src/hook/useSettings.ts | 6 +- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index 64014baf..2d05810a 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -44,6 +44,7 @@ export default function SettingAccordion({ isLoading, changeSettings, showButton: showInvoiceButton, + bankDepositEnabled, bankAccountOptions, bankAccountsError, canSave, @@ -93,6 +94,7 @@ export default function SettingAccordion({ settingState={settingState} changeSettings={changeSettings} isLoading={isLoading} + bankDepositEnabled={bankDepositEnabled} bankAccountOptions={bankAccountOptions} bankAccountsError={bankAccountsError} /> diff --git a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx index 606883d7..8f705050 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -11,6 +11,7 @@ type InvoiceDetailProps = { value: InvoiceSettingType[K], ) => void isLoading: boolean + bankDepositEnabled: boolean bankAccountOptions: AccountOption[] | undefined bankAccountsError: unknown } @@ -19,6 +20,7 @@ export default function InvoiceDetail({ settingState, changeSettings, isLoading, + bankDepositEnabled, bankAccountOptions, bankAccountsError, }: InvoiceDetailProps) { @@ -41,44 +43,49 @@ export default function InvoiceDetail({ } />
-
- - 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. -

- )} - + {/* 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. +

+ )} + + )} +
)} -
+ )}
{ isLoading, } = useSwrHelper(`/api/quickbooks/setting?type=invoice&token=${token}`) + // 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 + isDisconnected || !bankDepositEnabled ? null : `/api/quickbooks/setting/bank-account?token=${token}`, { suspense: false, revalidateOnMount: true }, @@ -550,6 +553,7 @@ export const useInvoiceDetailSettings = () => { error, isLoading, showButton, + bankDepositEnabled, bankAccountOptions, bankAccountsError, canSave, From e3c4e4fe56f4a779a22ce3beaf781fe23f3f2123 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 3 Aug 2026 14:16:42 +0545 Subject: [PATCH 46/49] test(OUT-3617): cover the bank-deposit AB gate end to end Unit tests for the gate util, the freeze and reconcile gates, and the settings write path + bankDepositEnabled signal. Integration test drives the real webhook -> invoice.service -> DB path to confirm an excluded portal freezes non-batched. The gate is env-parsed at config load, so the integration harness mocks it via a globalThis-pinned allowlist (default = all portals) driven per-test by test/helpers/abTestGate.ts. Co-Authored-By: Claude Opus 4.8 --- test/helpers/abTestGate.ts | 17 ++ .../invoiceCreated/abTestingGate.test.ts | 45 +++++ test/integration/setup.ts | 17 ++ .../invoice.service.bankDepositGate.test.ts | 90 ++++++++++ .../payout.service.bankDepositGate.test.ts | 76 ++++++++ test/unit/setting/setting.controller.test.ts | 168 ++++++++++++++++++ test/unit/utils/abTesting.test.ts | 49 +++++ 7 files changed, 462 insertions(+) create mode 100644 test/helpers/abTestGate.ts create mode 100644 test/integration/quickbooks/invoiceCreated/abTestingGate.test.ts create mode 100644 test/unit/app/api/quickbooks/invoice/invoice.service.bankDepositGate.test.ts create mode 100644 test/unit/payout/payout.service.bankDepositGate.test.ts create mode 100644 test/unit/setting/setting.controller.test.ts create mode 100644 test/unit/utils/abTesting.test.ts 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/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/setup.ts b/test/integration/setup.ts index e7bdd268..f50e5295 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -103,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/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/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/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/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) + }) +}) From 937e2e233e53e3842bd808aedd193e36f40f2b27 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 7 Aug 2026 12:48:40 +0545 Subject: [PATCH 47/49] feat(OUT-4030): add ground-truth lookup for invoices with a recorded fee Query invoices that have a SUCCESS PAYMENT/SUCCEEDED sync log. The absorbed-fee Purchase exists in QBO only when that row is written, so its presence is ground truth rather than inferring from the batched-intent flag. Standalone @/db/service fn to avoid a syncLog.service <-> syncErrorNotifier import cycle. Co-Authored-By: Claude Opus 4.8 --- src/db/service/syncLog.service.ts | 36 +++++++ .../getInvoiceNumbersWithRecordedFee.test.ts | 93 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/db/service/syncLog.service.ts create mode 100644 test/integration/quickbooks/syncLog/getInvoiceNumbersWithRecordedFee.test.ts 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/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()) + }) +}) From 8765fad09ee46eebe34e36f6589d4a801d56ec50 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 7 Aug 2026 12:48:52 +0545 Subject: [PATCH 48/49] feat(OUT-4030): warn IUs which mixed-payout fees are already recorded A mixed-intent payout fails terminally; the notification told the IU to record the deposit manually but did not flag that the non-batched invoices already have their fee expensed, so following it would double-book those fees. Name the already-recorded invoices in the copy (in-product + email) and warn against re-recording. A recorded-fee lookup failure now drops only that detail, not the whole notification. Share the remark delimiter so the writer and notifier split can't drift. Co-Authored-By: Claude Opus 4.8 --- src/app/api/core/types/notification.ts | 3 + .../api/notification/notification.helper.ts | 10 +- .../quickbooks/syncLog/syncErrorNotifier.ts | 40 ++++++-- .../api/quickbooks/webhook/webhook.service.ts | 7 +- src/constant/intuitErrorCode.ts | 4 + .../notification/notification.helper.test.ts | 25 ++++- .../unit/quickbooks/syncErrorNotifier.test.ts | 96 +++++++++++++++++++ 7 files changed, 174 insertions(+), 11 deletions(-) diff --git a/src/app/api/core/types/notification.ts b/src/app/api/core/types/notification.ts index fee09918..81984753 100644 --- a/src/app/api/core/types/notification.ts +++ b/src/app/api/core/types/notification.ts @@ -31,6 +31,9 @@ export interface NotificationContext { // 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 diff --git a/src/app/api/notification/notification.helper.ts b/src/app/api/notification/notification.helper.ts index 19da981b..eb2f34ce 100644 --- a/src/app/api/notification/notification.helper.ts +++ b/src/app/api/notification/notification.helper.ts @@ -249,14 +249,20 @@ export const NotificationCopy: Record< const forInvoices = ctx?.invoiceNumbers ? ` for invoices ${ctx.invoiceNumbers}` : '' - 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}, so nothing was double-booked. Record this payout's deposit manually in QuickBooks. This will not be retried automatically.` + 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}` : '' - 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}, so nothing was double-booked. Record this payout's deposit manually in QuickBooks. This payout will not be retried automatically.` + 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.` }, }, diff --git a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts index 46c1008e..42bbf145 100644 --- a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -7,10 +7,13 @@ import { import { NotificationService } from '@/app/api/notification/notification.service' 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. @@ -69,6 +72,35 @@ export class SyncErrorNotifier extends BaseService { return } + // Mixed-payout rows stash the affected invoices in `remark`; surface them, + // then flag which already have a recorded fee to warn against double-booking. + const affectedInvoiceNumbers = + action === NotificationActions.QB_PAYOUT_MIXED_INTENT ? log.remark : null + let invoiceNumbersWithFee: string | undefined + if (affectedInvoiceNumbers) { + try { + const affected = affectedInvoiceNumbers + .split(MIXED_INTENT_INVOICE_DELIMITER) + .filter(Boolean) + 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) { + // A lookup blip must still let the terminal payout notification through. + CustomLogger.error({ + message: + 'SyncErrorNotifier | recorded-fee lookup failed; notifying without it', + obj: error, + }) + } + } + const context: NotificationContext = { entityType: log.entityType, eventType: log.eventType, @@ -78,12 +110,8 @@ export class SyncErrorNotifier extends BaseService { productName: log.productName, qbItemName: log.qbItemName, errorMessage: log.errorMessage, - // Mixed-payout rows stash the affected invoice numbers in `remark`; surface - // them for the body while copilotId stays the ref. - invoiceNumbers: - action === NotificationActions.QB_PAYOUT_MIXED_INTENT - ? log.remark - : undefined, + invoiceNumbers: affectedInvoiceNumbers ?? undefined, + invoiceNumbersWithFee, } const portal = await getPortalConnection(this.user.workspaceId) diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 257b1766..3d5df6a3 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -40,7 +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 { PAYOUT_MIXED_INTENT_CODE } from '@/constant/intuitErrorCode' +import { + MIXED_INTENT_INVOICE_DELIMITER, + PAYOUT_MIXED_INTENT_CODE, +} from '@/constant/intuitErrorCode' export class WebhookService extends BaseService { async handleWebhookEvent( @@ -723,7 +726,7 @@ export class WebhookService extends BaseService { const affectedInvoiceNumbers = copilotInvoiceIds .map((id) => paymentIdByInvoice.get(id)?.invoiceNumber) .filter(Boolean) - .join(', ') + .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 diff --git a/src/constant/intuitErrorCode.ts b/src/constant/intuitErrorCode.ts index 8b2cc262..f164400a 100644 --- a/src/constant/intuitErrorCode.ts +++ b/src/constant/intuitErrorCode.ts @@ -63,6 +63,10 @@ export const UserActionableErrorCodes: Record = { // 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 = { diff --git a/test/unit/notification/notification.helper.test.ts b/test/unit/notification/notification.helper.test.ts index 1e41d68e..04f8b7f6 100644 --- a/test/unit/notification/notification.helper.test.ts +++ b/test/unit/notification/notification.helper.test.ts @@ -110,8 +110,31 @@ describe('getInProductNotificationDetail', () => { NotificationActions.QB_PAYOUT_MIXED_INTENT, ctx, ) - expect(detail.body).toContain('No deposit was created, so nothing') + 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', () => { diff --git a/test/unit/quickbooks/syncErrorNotifier.test.ts b/test/unit/quickbooks/syncErrorNotifier.test.ts index 6ed6b000..bd74fef7 100644 --- a/test/unit/quickbooks/syncErrorNotifier.test.ts +++ b/test/unit/quickbooks/syncErrorNotifier.test.ts @@ -36,6 +36,16 @@ 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, @@ -189,6 +199,8 @@ describe('SyncErrorNotifier#notify', () => { beforeEach(() => { sendNotificationToIU.mockReset() + getInvoiceNumbersWithRecordedFeeMock.mockReset() + getInvoiceNumbersWithRecordedFeeMock.mockResolvedValue(new Set()) }) it('skips when status is not FAILED', async () => { @@ -264,6 +276,8 @@ describe('SyncErrorNotifier#notify', () => { 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. @@ -275,6 +289,88 @@ describe('SyncErrorNotifier#notify', () => { } }) + 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) From d73f5ab6d7a6b7b311c6cda20750811da102c503 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 7 Aug 2026 16:45:20 +0545 Subject: [PATCH 49/49] refactor(OUT-4030): extract mixed-payout invoice resolution into a helper Move the remark parse + recorded-fee lookup out of notify() into a private resolveMixedPayoutInvoices helper returning { affectedInvoiceNumbers, invoiceNumbersWithFee }. Behavior-preserving; keeps the fault-isolation so a lookup failure still dispatches the notification. Co-Authored-By: Claude Opus 4.8 --- .../quickbooks/syncLog/syncErrorNotifier.ts | 75 ++++++++++++------- 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts index 42bbf145..2861e425 100644 --- a/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts +++ b/src/app/api/quickbooks/syncLog/syncErrorNotifier.ts @@ -46,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 @@ -72,34 +79,14 @@ export class SyncErrorNotifier extends BaseService { return } - // Mixed-payout rows stash the affected invoices in `remark`; surface them, - // then flag which already have a recorded fee to warn against double-booking. - const affectedInvoiceNumbers = - action === NotificationActions.QB_PAYOUT_MIXED_INTENT ? log.remark : null - let invoiceNumbersWithFee: string | undefined - if (affectedInvoiceNumbers) { - try { - const affected = affectedInvoiceNumbers - .split(MIXED_INTENT_INVOICE_DELIMITER) - .filter(Boolean) - 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) { - // A lookup blip must still let the terminal payout notification through. - CustomLogger.error({ - message: - 'SyncErrorNotifier | recorded-fee lookup failed; notifying without it', - obj: error, - }) - } - } + // 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, @@ -110,7 +97,7 @@ export class SyncErrorNotifier extends BaseService { productName: log.productName, qbItemName: log.qbItemName, errorMessage: log.errorMessage, - invoiceNumbers: affectedInvoiceNumbers ?? undefined, + invoiceNumbers: affectedInvoiceNumbers, invoiceNumbersWithFee, } const portal = await getPortalConnection(this.user.workspaceId) @@ -125,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 } + } }