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/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/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/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index f49ac63f..361c5d9e 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -914,11 +914,31 @@ export class InvoiceService extends BaseService { ) const invoiceAmount = Number(z.string().parse(invoiceLog.amount)) / 100 + + // Batched-deposit mode routes the payment through Undeposited Funds so the + // payout deposit can later link and sweep it into the bank. + const settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId([ + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + ]) + const useBankDepositFlow = + setting?.absorbedFeeFlag && setting?.bankDepositFeeFlag + + const intuitApi = new IntuitAPI(qbTokenInfo) + + const depositToAccountRef = useBankDepositFlow + ? await intuitApi.getUndepositedFundsAccountId() + : undefined + const qbPaymentPayload = { TotalAmt: invoiceAmount, CustomerRef: { value: existingCustomer.qbCustomerId, }, + ...(depositToAccountRef && { + DepositToAccountRef: { value: depositToAccountRef }, + }), Line: [ { Amount: invoiceAmount, @@ -931,7 +951,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..83a32618 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -21,6 +21,8 @@ import { } from '@/db/schema/qbPaymentSync' import { WhereClause } from '@/type/common' import { + QBDepositCreatePayloadSchema, + QBDepositCreatePayloadType, QBPaymentCreatePayloadSchema, QBPaymentCreatePayloadType, QBPurchaseCreatePayloadSchema, @@ -34,6 +36,7 @@ import { addSyncBreadcrumb } from '@/utils/sentry' import dayjs from 'dayjs' import { z } from 'zod' import httpStatus from 'http-status' +import CustomLogger from '@/utils/logger' export class PaymentService extends BaseService { private syncLogService: SyncLogService @@ -195,6 +198,72 @@ export class PaymentService extends BaseService { } } + async createBankDepositForPayment( + intuitApi: IntuitAPI, + opts: { + lines: Array<{ qbPaymentId: string; amount: number }> + feeTotal: number + bankAccountRef: string + expenseAccountRef: string + txnDate: string + privateNote: string + }, + ): Promise { + addSyncBreadcrumb('Creating batched bank deposit in QBO', { + privateNote: opts.privateNote, + lineCount: opts.lines.length, + feeTotal: opts.feeTotal, + }) + + const paymentLines: Required['Line'] = + opts.lines.map((line) => ({ + Amount: line.amount, + LinkedTxn: [ + { + TxnId: line.qbPaymentId, + TxnType: 'Payment' as const, + TxnLineId: '0', + }, + ], + })) + + // feeTotal is always >= 0 (caller rejects negative): 0 = no fee line. + if (opts.feeTotal > 0) { + paymentLines.push({ + Amount: -opts.feeTotal, + DetailType: 'DepositLineDetail' as const, + DepositLineDetail: { + AccountRef: { value: opts.expenseAccountRef }, + }, + Description: 'Stripe processing fees', + }) + } + + const depositPayload: QBDepositCreatePayloadType = { + DepositToAccountRef: { value: opts.bankAccountRef }, + PrivateNote: opts.privateNote, + TxnDate: opts.txnDate, + Line: paymentLines, + } + + const parsedPayload = QBDepositCreatePayloadSchema.parse(depositPayload) + const res = await intuitApi.createDeposit(parsedPayload) + + CustomLogger.info({ + obj: { + depositId: res.Deposit?.Id, + lineCount: opts.lines.length, + feeTotal: opts.feeTotal, + }, + message: `PaymentService#createBankDepositForPayment | Batched bank deposit created (${opts.privateNote})`, + }) + addSyncBreadcrumb('Batched bank deposit created in QBO', { + depositId: res.Deposit?.Id, + }) + + return res.Deposit.Id + } + async webhookPaymentSucceeded({ parsedPaymentSucceedResource, qbTokenInfo, diff --git a/src/app/api/quickbooks/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/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/app/api/quickbooks/token/token.service.ts b/src/app/api/quickbooks/token/token.service.ts index f4fead40..26f787be 100644 --- a/src/app/api/quickbooks/token/token.service.ts +++ b/src/app/api/quickbooks/token/token.service.ts @@ -177,6 +177,9 @@ export class TokenService extends BaseService { case AccountTypeObj.Asset: payload = { assetAccountRef: accountRef } break + // AccountTypeObj.Bank intentionally falls through: restoreAccountRef + // throws for Bank before we ever get here (bank refs are user-selected, + // never auto-mapped). If that ever changes, add a Bank case here. default: throw new APIError( httpStatus.BAD_REQUEST, @@ -298,6 +301,13 @@ export class TokenService extends BaseService { return this.getOrCreateExpenseAccountRef(intuitApi) case AccountTypeObj.Asset: return this.getOrCreateAssetAccountRef(intuitApi) + case AccountTypeObj.Bank: + // Never auto-restore a bank account — that could deposit into the + // wrong one. Make the user reselect instead. + throw new APIError( + httpStatus.BAD_REQUEST, + 'Bank account is missing or was deleted in QuickBooks. Please reselect a bank account in the QuickBooks integration settings.', + ) default: throw new APIError( httpStatus.BAD_REQUEST, diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index d29e3c08..0f027cb0 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,189 @@ 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, feeCents } = lineItems.reduce( + (acc, line) => { + acc.grossCents += line.grossAmount + acc.feeCents += line.feeAmount + return acc + }, + { grossCents: 0, feeCents: 0 }, + ) + + try { + validateAccessToken(qbTokenInfo) + + // v1: refunds unsupported — a negative line means QBO cannot link to a Payment. + if (lineItems.some((line) => line.grossAmount < 0)) { + throw new APIError( + httpStatus.BAD_REQUEST, + `Payout ${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/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/migrate.ts b/src/db/migrate.ts new file mode 100644 index 00000000..dbf9ede3 --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,39 @@ +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import path from 'node:path' +import { databaseUrl } from '@/config' +import { migratePerFile } from '@/db/migratePerFile' + +/** + * Production/dev migration runner. Replaces `drizzle-kit migrate` in + * `scripts/build.sh` — the drizzle-kit CLI batches every pending migration + * into one transaction (same underlying `drizzle-orm` migrator), which + * breaks whenever an enum-add migration and a later migration that + * references the new value are both pending in the same run. `migratePerFile` + * applies one migration per transaction instead; see that module for why. + * + * command to run: `yarn db:migrate` + */ + +const MIGRATIONS_FOLDER = path.resolve(process.cwd(), 'src/db/migrations') + +;(async function run() { + if (!databaseUrl) { + console.error('migrate | DATABASE_URL is not set') + process.exit(1) + } + + const client = postgres(databaseUrl, { max: 1, prepare: false }) + try { + console.info('migrate | Applying pending migrations...') + await migratePerFile(drizzle(client), MIGRATIONS_FOLDER) + console.info('migrate | Migrations applied successfully') + } catch (error) { + console.error('migrate | Migration failed', error) + await client.end() + process.exit(1) + } + + await client.end() + process.exit(0) +})() diff --git a/src/db/migratePerFile.ts b/src/db/migratePerFile.ts new file mode 100644 index 00000000..cae4e7f2 --- /dev/null +++ b/src/db/migratePerFile.ts @@ -0,0 +1,61 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { migrate } from 'drizzle-orm/postgres-js/migrator' +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { sql } from 'drizzle-orm' + +type Journal = { + version: string + dialect: string + entries: { idx: number; when: number; tag: string; breakpoints: boolean }[] +} + +// Fixed key so concurrent runners serialize on one advisory lock. +const MIGRATION_ADVISORY_LOCK_KEY = 4030604 + +/** + * Applies migrations one-per-transaction under a session advisory lock. + * + * Per-file commits avoid drizzle's batched `migrate()` "unsafe use of new + * value" error (enum added, then used in a later file); the lock stops + * concurrent runners racing the same pending migration. Used by globalSetup + * and the prod runner (src/db/migrate.ts). + */ +export async function migratePerFile>( + db: PostgresJsDatabase, + migrationsFolder: string, +): Promise { + const journal = JSON.parse( + fs.readFileSync(path.join(migrationsFolder, 'meta/_journal.json'), 'utf-8'), + ) as Journal + + await db.execute(sql`SELECT pg_advisory_lock(${MIGRATION_ADVISORY_LOCK_KEY})`) + + const tempFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'drizzle-migrate-')) + try { + fs.mkdirSync(path.join(tempFolder, 'meta')) + for (const entry of journal.entries) { + fs.copyFileSync( + path.join(migrationsFolder, `${entry.tag}.sql`), + path.join(tempFolder, `${entry.tag}.sql`), + ) + } + + for (let i = 0; i < journal.entries.length; i++) { + fs.writeFileSync( + path.join(tempFolder, 'meta/_journal.json'), + JSON.stringify({ + ...journal, + entries: journal.entries.slice(0, i + 1), + }), + ) + await migrate(db, { migrationsFolder: tempFolder }) + } + } finally { + fs.rmSync(tempFolder, { recursive: true, force: true }) + await db.execute( + sql`SELECT pg_advisory_unlock(${MIGRATION_ADVISORY_LOCK_KEY})`, + ) + } +} diff --git a/src/db/migrations/20260717110112_add_bank_deposit_fee_column.sql b/src/db/migrations/20260717110112_add_bank_deposit_fee_column.sql new file mode 100644 index 00000000..793e8730 --- /dev/null +++ b/src/db/migrations/20260717110112_add_bank_deposit_fee_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE "qb_portal_connections" ADD COLUMN "bank_account_ref" varchar(100);--> statement-breakpoint +ALTER TABLE "qb_settings" ADD COLUMN "bank_deposit_fee_flag" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/20260721083213_add_payout_settled_enums.sql b/src/db/migrations/20260721083213_add_payout_settled_enums.sql new file mode 100644 index 00000000..fdc92e7a --- /dev/null +++ b/src/db/migrations/20260721083213_add_payout_settled_enums.sql @@ -0,0 +1,2 @@ +ALTER TYPE "public"."entity_types" ADD VALUE 'payout';--> statement-breakpoint +ALTER TYPE "public"."event_types" ADD VALUE 'settled'; \ No newline at end of file diff --git a/src/db/migrations/20260721100005_extend_oneshot_index_payout.sql b/src/db/migrations/20260721100005_extend_oneshot_index_payout.sql new file mode 100644 index 00000000..cab3a7f2 --- /dev/null +++ b/src/db/migrations/20260721100005_extend_oneshot_index_payout.sql @@ -0,0 +1,6 @@ +DROP INDEX "uq_qb_sync_logs_oneshot_active";--> statement-breakpoint +CREATE UNIQUE INDEX "uq_qb_sync_logs_oneshot_active" ON "qb_sync_logs" USING btree ("portal_id","copilot_id","entity_type","event_type") WHERE "qb_sync_logs"."deleted_at" IS NULL AND ( + ("qb_sync_logs"."entity_type" = 'invoice' AND "qb_sync_logs"."event_type" IN ('created','paid','voided','deleted')) + OR ("qb_sync_logs"."entity_type" = 'payment' AND "qb_sync_logs"."event_type" = 'succeeded') + OR ("qb_sync_logs"."entity_type" = 'payout' AND "qb_sync_logs"."event_type" = 'settled') + ); \ No newline at end of file diff --git a/src/db/migrations/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') )`, ), ], 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/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index f87d770d..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(), @@ -240,6 +245,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/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 +> 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) } diff --git a/src/utils/tokenRefresh.ts b/src/utils/tokenRefresh.ts index d367f36f..dac1ac17 100644 --- a/src/utils/tokenRefresh.ts +++ b/src/utils/tokenRefresh.ts @@ -60,6 +60,7 @@ function extractTokens( assetAccountRef: row.assetAccountRef, serviceItemRef: row.serviceItemRef, clientFeeRef: row.clientFeeRef, + bankAccountRef: row.bankAccountRef, } } @@ -161,6 +162,7 @@ export async function getRefreshedQbTokenInfo( assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, + bankAccountRef: portalConnection.bankAccountRef, } const updatedPayload: QBPortalConnectionUpdateSchemaType = { diff --git a/test/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() }