diff --git a/package.json b/package.json index 0247e09..e6b4085 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev --turbopack", "trigger:dev": "npx trigger.dev@4.3.0 dev", + "trigger:deploy": "npx trigger.dev@4.3.0 deploy", "dev:embedded": "node scripts/dev.js", "build": "next build", "start": "next start", @@ -16,7 +17,9 @@ "lint-staged": "npx lint-staged", "prepare": "husky", "supabase:dev": "supabase start --ignore-health-check", - "cmd:rename-qb-accounts": "tsx src/cmd/renameQbAccount/index.ts" + "cmd:rename-qb-accounts": "tsx src/cmd/renameQbAccount/index.ts", + "cmd:check-impact-on-workspace": "tsx src/cmd/checkWorkspaceInvoice/index.ts", + "cmd:seed-impacted-workspace": "tsx src/cmd/seedPortalImpactVerification.ts" }, "dependencies": { "@sentry/nextjs": "^9.13.0", @@ -88,4 +91,4 @@ "yarn prettier:fix" ] } -} +} \ No newline at end of file diff --git a/src/app/api/core/constants/limit.ts b/src/app/api/core/constants/limit.ts index a08a9a2..3736e90 100644 --- a/src/app/api/core/constants/limit.ts +++ b/src/app/api/core/constants/limit.ts @@ -1,2 +1,2 @@ export const MAX_PRODUCT_LIST_LIMIT = 1_000 -export const MAX_INVOICE_LIST_LIMIT = 10_000 +export const MAX_INVOICE_LIST_LIMIT = 1_000 diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index f28e5dc..70fbf84 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -21,6 +21,7 @@ import { getPortalConnection, getPortalSettings, } from '@/db/service/token.service' +import { checkIncorrectlySyncedInvoiceForPortal } from '@/trigger/checkImpactedInvoiceForPortal' import { QBAuthTokenResponse, QBAuthTokenResponseSchema, @@ -174,7 +175,7 @@ export class AuthService extends BaseService { const tokenService = new TokenService(this.user) // check if the token exists - const existingToken = await tokenService.getOneByPortalId(portalId) + const existingPortal = await tokenService.getOneByPortalId(portalId) const insertPayload: QBPortalConnectionCreateSchemaType = { intuitRealmId: realmId, @@ -186,9 +187,9 @@ export class AuthService extends BaseService { tokenSetTime, tokenType: tokenInfo.token_type, intiatedBy: this.user.internalUserId as string, // considering this is defined since we know this action is intiated by an IU - incomeAccountRef: existingToken?.incomeAccountRef || '', - expenseAccountRef: existingToken?.expenseAccountRef || '', - assetAccountRef: existingToken?.assetAccountRef || '', + incomeAccountRef: existingPortal?.incomeAccountRef || '', + expenseAccountRef: existingPortal?.expenseAccountRef || '', + assetAccountRef: existingPortal?.assetAccountRef || '', isSuspended: false, // default vaalue is false when created. Added this for the re-auth case. } const intuitApi = new IntuitAPI({ @@ -198,8 +199,8 @@ export class AuthService extends BaseService { incomeAccountRef: insertPayload.incomeAccountRef, expenseAccountRef: insertPayload.expenseAccountRef, assetAccountRef: insertPayload.assetAccountRef, - serviceItemRef: existingToken?.serviceItemRef || null, - clientFeeRef: existingToken?.clientFeeRef || null, + serviceItemRef: existingPortal?.serviceItemRef || null, + clientFeeRef: existingPortal?.clientFeeRef || null, }) // handle accounts const createPayload = await this.handleAccountReferences( @@ -229,11 +230,21 @@ export class AuthService extends BaseService { }) after(async () => { - if (existingToken) { - console.info('Not initial process. Starting the re-sync process') + if (existingPortal) { + console.info(`Not initial connection for the portal: ${portalId}`) + + // check for impacted portals that could have incorrectly synced invoices + checkIncorrectlySyncedInvoiceForPortal.trigger({ + user: this.user, + portal: existingPortal, + }) + + console.info( + `Starting the re-sync process for the portal ${portalId}`, + ) this.user.qbConnection = { - serviceItemRef: existingToken.serviceItemRef, - clientFeeRef: existingToken.clientFeeRef, + serviceItemRef: existingPortal.serviceItemRef, + clientFeeRef: existingPortal.clientFeeRef, } const syncService = new SyncService(this.user) await syncService.syncFailedRecords({ diff --git a/src/app/api/quickbooks/portalImpactVerification-temp/portalImpactVerification.service.ts b/src/app/api/quickbooks/portalImpactVerification-temp/portalImpactVerification.service.ts new file mode 100644 index 0000000..5af053f --- /dev/null +++ b/src/app/api/quickbooks/portalImpactVerification-temp/portalImpactVerification.service.ts @@ -0,0 +1,414 @@ +import { MAX_INVOICE_LIST_LIMIT } from '@/app/api/core/constants/limit' +import APIError from '@/app/api/core/exceptions/api' +import { BaseService } from '@/app/api/core/services/base.service' +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { copilotAPIKey, intuitBaseUrl } from '@/config' +import { API_DOMAIN } from '@/constant/domains' +import { PortalImpactVerification } from '@/db/schema/portalImpactVerification' +import { + QBPortalConnection, + QBPortalConnectionSelectSchemaType, + QBPortalConnectionUpdateSchemaType, +} from '@/db/schema/qbPortalConnections' +import { getFetcher } from '@/helper/fetch.helper' +import { bottleneck } from '@/utils/bottleneck' +import Intuit from '@/utils/intuit' +import CustomLogger from '@/utils/logger' +import { captureMessage } from '@sentry/nextjs' +import dayjs from 'dayjs' +import { and, eq, inArray, ne, SQL } from 'drizzle-orm' +import httpStatus from 'http-status' + +type InvoiceResponseType = { + Id: string + DocNumber: string +} + +type processForPortalType = { + portal: QBPortalConnectionSelectSchemaType + propagateError?: boolean + impactedPortal?: Record + failedPortal?: string[] + failedPortalInAssembly?: string[] +} + +export class PortalImpactVerificationService extends BaseService { + async startProcess() { + const portalConnections = await this.db.query.QBPortalConnection.findMany() + const promises = [] + const impactedPortal: Record = {} + const failedPortal: string[] = [] + const failedPortalInAssembly: string[] = [] + + for (const portal of portalConnections) { + const asyncFn = bottleneck.schedule(() => { + return this.processForPortal({ + portal, + impactedPortal, + failedPortal, + failedPortalInAssembly, + }) + }) + promises.push(asyncFn) + } + await Promise.all(promises) + + CustomLogger.info({ + message: 'Overall report', + obj: { impactedPortal }, + }) + + console.info('Impacted portals: ', JSON.stringify(impactedPortal)) + console.info('Failed portals: ', JSON.stringify(failedPortal)) + console.info( + 'Failed portals in Assembly: ', + JSON.stringify(failedPortalInAssembly), + ) + } + + private async processForPortal({ + portal, + impactedPortal, + failedPortal, + failedPortalInAssembly, + propagateError = false, + }: processForPortalType) { + const portalId = portal.portalId + try { + // 1. get all invoices from assembly for the portal + const portalInvoiceNumberList = await this.getPortalInvoicesFromAssembly( + portalId, + failedPortalInAssembly, + ) + + if (portalInvoiceNumberList.length === 0) { + console.info( + '\n\n No invoice found for portal: ' + portalId + ' Skipping.. \n\n', + ) + return + } + + // 2. get all synced invoices for the portal in QB + const accessToken = await this.getRefreshedAccessToken( + portal, + failedPortal, + ) + if (!accessToken) { + console.error('No access token for portal: ' + portal.portalId) + return + } + + const qbInvoices = await this.getInvoiceFromQB( + portal, + accessToken, + failedPortal, + ) + + if (!qbInvoices) { + console.error( + '\n\nNo invoices found for portal in QB: ' + portal.portalId, + ) + return + } + + // 3. diff two lists and get invoices only in QB + const diffInvoiceNumber = qbInvoices + .filter((invNumber) => !portalInvoiceNumberList.includes(invNumber)) + .filter((inv) => inv !== null || inv !== undefined || inv !== '') + if (impactedPortal) impactedPortal[portalId] = diffInvoiceNumber + + CustomLogger.info({ + message: `Portal ${portalId} with impacted invoice list: `, + obj: { invoices: diffInvoiceNumber }, + }) + console.info('\n\n##### Completed portal: ' + portalId) + + return diffInvoiceNumber + } catch (error: unknown) { + console.error( + 'portalImpactVerificationService#processForPortal :: Error processing portal', + portalId, + ) + if (propagateError) throw error + } + } + + private async getPortalInvoicesFromAssembly( + portalId: string, + failedPortalInAssembly?: string[], + ) { + try { + console.info('Fetching portal invoices from Assembly') + const url = `${API_DOMAIN}/v1/invoices?limit=${MAX_INVOICE_LIST_LIMIT.toString()}` + const options = { + method: 'GET', + headers: { + accept: 'application/json', + 'X-API-KEY': `${portalId}/${copilotAPIKey}`, + }, + } + const invoiceList = [] + let nextToken = null + while (true) { + const urlWithToken: string = `${url}${nextToken ? `&nextToken=${nextToken}` : ''}` + + console.info('Fetch URL: ' + urlWithToken + ' portal: ' + portalId) + const resp = await fetch(urlWithToken, options) + if (!resp.ok) { + console.error( + `Error fetching invoices from Assembly. ${portalId} ${resp.status} ${resp.statusText}`, + ) + if (failedPortalInAssembly) failedPortalInAssembly.push(portalId) + throw new APIError( + resp.status, + `Error fetching invoices from Assembly. ${portalId} ${resp.status} ${resp.statusText}`, + ) + } + + const data = await resp.json() + if (!data.data) break + + invoiceList.push(...data.data) + if (!data.nextToken) break + + nextToken = data.nextToken + console.info('Pagination token found. Fetching next page...') + } + return invoiceList.map((invoice) => invoice.number) + } catch (error) { + console.error(error) + throw new APIError( + httpStatus.BAD_REQUEST, + 'Error while getting invoices from Assembly', + ) + } + } + + private async getInvoiceFromQB( + portal: QBPortalConnectionSelectSchemaType, + accessToken: string, + failedPortal?: string[], + ) { + console.info('Getting invoices from QB for portal ' + portal.portalId) + try { + const maxResults = '1000' + let startPosition = 1 + const qbInvoices: string[] = [] + + const txnDate = new Date(portal.createdAt) + const result = txnDate.toISOString().split('T')[0] + + while (true) { + const query = `select id, DocNumber from Invoice where DocNumber != '' and TxnDate > '${result}' maxresults ${maxResults} startPosition ${startPosition}` + const url = `${intuitBaseUrl}/v3/company/${portal.intuitRealmId}/query?query=${encodeURIComponent(query)}&minorversion=75` + + console.info(`QB url: ${url}. Portal id: ${portal.portalId}`) + + const header = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'content-type': 'application/json', + } + const res = await getFetcher(url, header) + if (!res) { + if (failedPortal) failedPortal.push(portal.portalId) + throw new APIError( + httpStatus.BAD_REQUEST, + 'PortalImpactVerificationService#getInoiceFromQB | No response from Intuit API', + ) + } + + if (res?.Fault) { + // CustomLogger.error({ obj: res.Fault?.Error, message: 'Error: ' }) + console.error( + `Fault response for portal: ${portal.portalId}. ${res.Fault.Error?.code}`, + ) + if (failedPortal) failedPortal.push(portal.portalId) + throw new APIError( + res.Fault.Error?.code || httpStatus.BAD_REQUEST, + `PortalImpactVerificationService#getInoiceFromQB`, + res.Fault?.Error, + ) + } + if (!res.QueryResponse || !res.QueryResponse.Invoice) break + + const ids = res.QueryResponse.Invoice?.map( + (invoice: InvoiceResponseType) => invoice.DocNumber, + ).filter( + (invoiceNum: string) => + invoiceNum !== null || invoiceNum !== undefined, + ) + qbInvoices.push(...ids) + + console.info( + `\n\n Max invoice count for portal: ${portal.portalId} is ${res.QueryResponse.maxResults} \n\n`, + ) + + if (res.QueryResponse.maxResults < maxResults) { + break + } + startPosition = startPosition + 1 + } + return qbInvoices + } catch (error: unknown) { + console.error(error) + throw new APIError( + httpStatus.BAD_REQUEST, + 'Error while getting invoices from QB for portalId', + ) + } + } + + private async getRefreshedAccessToken( + portal: QBPortalConnectionSelectSchemaType, + failedPortal?: string[], + ) { + try { + const tokenInfo = await Intuit.getInstance().getRefreshedQBToken( + portal.refreshToken, + ) + + const updatedPayload: QBPortalConnectionUpdateSchemaType = { + accessToken: tokenInfo.access_token, + refreshToken: tokenInfo.refresh_token, + expiresIn: tokenInfo.expires_in, + XRefreshTokenExpiresIn: tokenInfo.x_refresh_token_expires_in, + tokenSetTime: dayjs().toDate(), + } + + const whereConditions = and( + eq(QBPortalConnection.intuitRealmId, portal.intuitRealmId), + eq(QBPortalConnection.portalId, portal.portalId), + ) as SQL + + const tokenService = new TokenService(this.user) + await tokenService.updateQBPortalConnection( + updatedPayload, + whereConditions, + ['id'], + ) + console.info('Access token refreshed and updated in DB 🔥') + return tokenInfo.access_token + } catch (error: unknown) { + if (failedPortal) failedPortal.push(portal.portalId) + + console.error(error) + throw new APIError( + httpStatus.BAD_REQUEST, + 'Error while refreshing token for portal' + portal.portalId, + ) + } + } + + async checkImpactedInvoiceForPortal( + portal: QBPortalConnectionSelectSchemaType, + ) { + try { + const portalId = portal.portalId + const impactedPortal = await this.getUnverifiedByPortalId(portalId) + + if (!impactedPortal) { + console.info(`Not an impacted portal: ${portalId}. Skipping..`) + return + } + + const invoiceNumbers = await this.processForPortal({ + portal, + propagateError: true, + }) + if (!invoiceNumbers || invoiceNumbers.length === 0) { + console.info( + `No invoice number difference found for portal: ${portalId}.`, + ) + // if no difference found, mark the portal impact verification as verified + await this.markPortalImpactVerified(portalId) + return + } + + const syncLogs = await this.getLogsNotInPortal(portalId, invoiceNumbers) + if (syncLogs.length > 0) { + // report to sentry if incorrect invoices in QB are synced through our app + captureMessage( + `Portal ${portalId} has incorrect invoice created in their QBO account`, + { + tags: { + key: 'incorrectlyCreatedInvoices', // can be used to search like "key:incorrectlyCreatedInvoices" + }, + extra: { + details: syncLogs, + }, + level: 'error', + }, + ) + return + } + await this.markPortalImpactVerified(portalId) + } catch (error: unknown) { + if (error instanceof APIError) { + CustomLogger.error({ obj: error, message: error.message }) + } else { + console.error(error) + } + + // report to sentry if any errors + captureMessage( + `PortalImpactVerificationService#checkImpactedInvoiceForPortal :: Error fetching invoices from Assembly. ${portal.portalId}`, + { + tags: { + key: 'portalImpact', // can be used to search like "key:portalImpact" + }, + extra: { + portalId: portal.portalId, + error, + }, + level: 'error', + }, + ) + } + } + + async markPortalImpactVerified(portalId: string) { + console.info( + 'PortalImpactVerificationService#markPortalImpactVerified :: Marking portal impact verification as verified', + ) + await this.db + .update(PortalImpactVerification) + .set({ + isVerified: true, + }) + .where(eq(PortalImpactVerification.portalId, portalId)) + + console.info( + `PortalImpactVerificationService#markPortalImpactVerified :: Portal ${portalId} marked as verified`, + ) + } + + async getUnverifiedByPortalId(portalId: string) { + return await this.db.query.PortalImpactVerification.findFirst({ + where: (PortalImpactVerification, { eq, and }) => + and( + eq(PortalImpactVerification.portalId, portalId), + eq(PortalImpactVerification.isVerified, false), + ), + }) + } + + async getLogsNotInPortal(portalId: string, invoiceNumbers: string[]) { + const logs = await this.db.query.QBSyncLog.findMany({ + where: (logs) => + and( + inArray(logs.invoiceNumber, invoiceNumbers), + ne(logs.portalId, portalId), + ), + columns: { + portalId: true, + invoiceNumber: true, + copilotId: true, + }, + }) + console.info( + `Result of syncLogs based on above invoice number list: ${JSON.stringify(logs)}`, + ) + return logs + } +} diff --git a/src/app/api/quickbooks/syncLog/syncLog.service.ts b/src/app/api/quickbooks/syncLog/syncLog.service.ts index b0c08b0..d8750f9 100644 --- a/src/app/api/quickbooks/syncLog/syncLog.service.ts +++ b/src/app/api/quickbooks/syncLog/syncLog.service.ts @@ -219,4 +219,10 @@ export class SyncLogService extends BaseService { return json2csv(data) } + + async getAllForWorkspace(portalId: string) { + return await this.db.query.QBSyncLog.findMany({ + where: (logs, { eq }) => eq(logs.portalId, portalId), + }) + } } diff --git a/src/cmd/checkWorkspaceInvoice/index.ts b/src/cmd/checkWorkspaceInvoice/index.ts new file mode 100644 index 0000000..a33d803 --- /dev/null +++ b/src/cmd/checkWorkspaceInvoice/index.ts @@ -0,0 +1,34 @@ +import { authenticateWithToken } from '@/app/api/core/utils/authenticate' +import { PortalImpactVerificationService } from '@/app/api/quickbooks/portalImpactVerification-temp/portalImpactVerification.service' +import { z } from 'zod' + +/** + * Command: `yarn run cmd:check-impact-on-workspace -- --token={token}` + * Description: this script is created to check if the invoices are incorrectly synced in other workspaces. + * This workspace simply returns the list of the invoice numbers that might have been incorrectly synced. + * This script does not include the check of those invoice numbers with our sync log table (qb_sync_logs). + * Might have to manually check if there are any such invoices. + * */ +;(async function run() { + try { + const args = process.argv.slice(2) + const tokenArg = args.find((a) => a.startsWith('--token=')) + const token = tokenArg?.split('=')[1] + + if (!token) { + throw new Error('No token provided') + } + + const tokenParsed = z.string().parse(token) + const user = await authenticateWithToken(tokenParsed) + + const checkInvoiceService = new PortalImpactVerificationService(user) + await checkInvoiceService.startProcess() + + console.info('\n Successfully checked invoices for impact 🎉') + process.exit(1) + } catch (error) { + console.error(error) + process.exit(1) + } +})() diff --git a/src/cmd/seedPortalImpactVerification.ts b/src/cmd/seedPortalImpactVerification.ts new file mode 100644 index 0000000..4fb5f98 --- /dev/null +++ b/src/cmd/seedPortalImpactVerification.ts @@ -0,0 +1,21 @@ +import { + PortalImpactCreateSchemaType, + PortalImpactVerification, +} from '@/db/schema/portalImpactVerification' +import { db } from '@/db' +import { impacteWorkspaces } from '@/config' +;(async function run() { + console.info('Seeding Portal Impact Verification table...') + + const portalIds = impacteWorkspaces.split(',') + const insertPayload: PortalImpactCreateSchemaType[] = portalIds.map((id) => ({ + portalId: id, + })) + await db + .insert(PortalImpactVerification) + .values(insertPayload) + .onConflictDoNothing({ target: PortalImpactVerification.portalId }) + + console.info('Seeding completed 🌱') + process.exit(1) +})() diff --git a/src/config/index.ts b/src/config/index.ts index dcefff4..537f87d 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -36,3 +36,4 @@ export const intuitApiMinorVersion = export const supabaseProjectUrl = process.env.NEXT_PUBLIC_SUPABASE_PROJECT_URL || '' export const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '' +export const impacteWorkspaces = process.env.IMPACTED_WORKSPACES || '' diff --git a/src/db/migrations/20260107120036_create_portal_impact_verification_table.sql b/src/db/migrations/20260107120036_create_portal_impact_verification_table.sql new file mode 100644 index 0000000..6f51cf5 --- /dev/null +++ b/src/db/migrations/20260107120036_create_portal_impact_verification_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE "portal_impact_verifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "portal_id" varchar(32) NOT NULL, + "is_verified" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "portal_impact_verifications_portal_id_unique" UNIQUE("portal_id") +); diff --git a/src/db/migrations/meta/20260107120036_snapshot.json b/src/db/migrations/meta/20260107120036_snapshot.json new file mode 100644 index 0000000..1fc5f4f --- /dev/null +++ b/src/db/migrations/meta/20260107120036_snapshot.json @@ -0,0 +1,1001 @@ +{ + "id": "dd8e259c-d34f-489e-8282-97e8ab0f8653", + "prevId": "0bf08d18-1252-400a-8467-3522ccb6bfb1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.portal_impact_verifications": { + "name": "portal_impact_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "is_verified": { + "name": "is_verified", + "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": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portal_impact_verifications_portal_id_unique": { + "name": "portal_impact_verifications_portal_id_unique", + "nullsNotDistinct": false, + "columns": [ + "portal_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + }, + "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": {}, + "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_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": {}, + "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 + }, + "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 + }, + "price_id": { + "name": "price_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "unit_price": { + "name": "unit_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "copilot_unit_price": { + "name": "copilot_unit_price", + "type": "numeric", + "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": {}, + "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 + }, + "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(255)", + "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(100)", + "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 + }, + "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 + }, + "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 + } + }, + "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", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info" + ] + } + }, + "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 651dd22..9d0a579 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1766066307157, "tag": "20251218135827_add_attempts_is_suspended_category_columns", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1767787236762, + "tag": "20260107120036_create_portal_impact_verification_table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 5e3cbfb..cb999ac 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -6,6 +6,7 @@ import { QBConnectionLogs } from '@/db/schema/qbConnectionLogs' import { QBCustomers } from '@/db/schema/qbCustomers' import { QBSetting } from '@/db/schema/qbSettings' import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { PortalImpactVerification } from '@/db/schema/portalImpactVerification' export const schema = { QBInvoiceSync, @@ -16,4 +17,5 @@ export const schema = { QBCustomers, QBSetting, QBSyncLog, + PortalImpactVerification, } diff --git a/src/db/schema/portalImpactVerification.ts b/src/db/schema/portalImpactVerification.ts new file mode 100644 index 0000000..caf187c --- /dev/null +++ b/src/db/schema/portalImpactVerification.ts @@ -0,0 +1,28 @@ +import { timestamps } from '@/db/helper/column.helper' +import { pgTable as table } from 'drizzle-orm/pg-core' +import * as t from 'drizzle-orm/pg-core' +import { createInsertSchema, createSelectSchema } from 'drizzle-zod' +import { z } from 'zod' + +const { deletedAt, ...newTimestamps } = timestamps + +export const PortalImpactVerification = table('portal_impact_verifications', { + id: t.uuid().defaultRandom().primaryKey(), + portalId: t.varchar('portal_id', { length: 32 }).unique().notNull(), + isVerified: t.boolean('is_verified').default(false).notNull(), + ...newTimestamps, +}) + +export const PortalImpactCreateSchema = createInsertSchema( + PortalImpactVerification, +) +export type PortalImpactCreateSchemaType = z.infer< + typeof PortalImpactCreateSchema +> + +export const PortalImpactSelectSchema = createSelectSchema( + PortalImpactVerification, +) +export type PortalImpactSelectSchemaType = z.infer< + typeof PortalImpactSelectSchema +> diff --git a/src/trigger/checkImpactedInvoiceForPortal.ts b/src/trigger/checkImpactedInvoiceForPortal.ts new file mode 100644 index 0000000..e54a18e --- /dev/null +++ b/src/trigger/checkImpactedInvoiceForPortal.ts @@ -0,0 +1,23 @@ +import User from '@/app/api/core/models/User.model' +import { PortalImpactVerificationService } from '@/app/api/quickbooks/portalImpactVerification-temp/portalImpactVerification.service' +import { QBPortalConnectionSelectSchemaType } from '@/db/schema/qbPortalConnections' +import { task } from '@trigger.dev/sdk' + +type CheckImpactedPortalType = { + user: User + portal: QBPortalConnectionSelectSchemaType +} + +export const checkIncorrectlySyncedInvoiceForPortal = task({ + id: 'check-incorrectly-synced-invoice-for-portal', + run: async (payload: CheckImpactedPortalType) => { + console.info( + '\nresyncFailedRecords#checkIncorrectlySyncedInvoiceForPortal :: Portal impact check started\n', + ) + const service = new PortalImpactVerificationService(payload.user) + await service.checkImpactedInvoiceForPortal(payload.portal) + console.info( + '\nresyncFailedRecords#checkIncorrectlySyncedInvoiceForPortal :: Portal impact check completed\n', + ) + }, +}) diff --git a/src/trigger/resyncFailedRecords.ts b/src/trigger/resyncFailedRecords.ts index 478b1ea..ef848ff 100644 --- a/src/trigger/resyncFailedRecords.ts +++ b/src/trigger/resyncFailedRecords.ts @@ -5,7 +5,7 @@ export const processResyncForFailedRecords = task({ id: 'process-resync-for-failed-records', machine: 'small-2x', run: async () => { - console.log( + console.info( 'resyncFailedRecords#processResyncForFailedRecords :: Trigger resync task', ) const cronService = new CronService()