From 065b6fad948ca643ecba5d04470da9fdd4c33c70 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 22 Jul 2026 20:44:51 +0545 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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)