From 9763a74fa7ef60b37339a854ce282934523145ba Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 23 Mar 2026 14:51:49 +0545 Subject: [PATCH] feat(OUT-3433): backfill missed invoices in QBO - [x] sync missed invoices when charged draft invoices in Assembly - [x] accurate type definition --- package.json | 3 +- src/app/api/core/constants/limit.ts | 3 +- .../api/quickbooks/invoice/invoice.service.ts | 55 +++++- .../api/quickbooks/payment/payment.service.ts | 6 + .../api/quickbooks/webhook/webhook.service.ts | 11 +- src/cmd/syncMissedInvoices/index.ts | 68 +++++++ .../syncMissedInvoices.service.ts | 186 ++++++++++++++++++ src/type/common.ts | 13 +- src/type/dto/intuitAPI.dto.ts | 5 + src/type/dto/webhook.dto.ts | 16 +- src/utils/copilotAPI.ts | 14 +- src/utils/intuitAPI.ts | 34 ++++ 12 files changed, 389 insertions(+), 25 deletions(-) create mode 100644 src/cmd/syncMissedInvoices/index.ts create mode 100644 src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts diff --git a/package.json b/package.json index 2e40e2bf..e1d57ab0 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "supabase:dev": "supabase start --ignore-health-check", "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", - "cmd:backfill-product-info": "tsx src/cmd/backfillProductInfo/index.ts" + "cmd:backfill-product-info": "tsx src/cmd/backfillProductInfo/index.ts", + "cmd:sync-missed-invoices": "tsx src/cmd/syncMissedInvoices/index.ts" }, "dependencies": { "@sentry/nextjs": "^9.13.0", diff --git a/src/app/api/core/constants/limit.ts b/src/app/api/core/constants/limit.ts index a08a9a28..7b5e0e0b 100644 --- a/src/app/api/core/constants/limit.ts +++ b/src/app/api/core/constants/limit.ts @@ -1,2 +1,3 @@ export const MAX_PRODUCT_LIST_LIMIT = 1_000 -export const MAX_INVOICE_LIST_LIMIT = 10_000 +export const MAX_INVOICE_LIST_LIMIT = 5_000 +export const MAX_ASSEMBLY_RESOURCE_LIST_LIMIT = 10_000 diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 18baf17f..37eb210f 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -685,9 +685,12 @@ export class InvoiceService extends BaseService { return acc + item.Amount }, 0) let actualTotalAmount = subtotal - const totalTax = parseFloat( - ((subtotal * invoiceResource.taxPercentage) / 100).toFixed(2), - ) + const totalTax = + (invoiceResource.taxPercentage + ? parseFloat( + ((subtotal * invoiceResource.taxPercentage) / 100).toFixed(2), + ) + : invoiceResource.taxAmount) || 0 // check if invoice is paid. This needs to be done after actualTotalAmount and totalTax calculation to avoid miscalculation if (invoiceResource.status === InvoiceStatus.PAID) { @@ -1128,4 +1131,50 @@ export class InvoiceService extends BaseService { ) return z.string().parse(incomeAccountRef) } + + async checkIfInvoiceExistsInQBO( + invoiceResource: InvoiceCreatedResponseType, + qbTokenInfo: IntuitAPITokensType, + ): Promise<{ exists: boolean }> { + console.info( + 'InvoiceService#checkIfInvoiceExistsInQBO | Checking if invoice exists in QBO', + ) + const invoice = invoiceResource.data + const intuitApi = new IntuitAPI(qbTokenInfo) + const qbInvoice = await intuitApi.getInvoice(invoice.number) + + if (!qbInvoice) { + console.info( + 'InvoiceService#checkIfInvoiceExistsInQBO | No invoice found in QBO', + ) + return { exists: false } + } + + const customerService = new CustomerService(this.user) + + const { recipientInfo } = await customerService.getRecipientInfo({ + clientId: invoice.clientId, + companyId: invoice.companyId, + }) + + await this.logSync( + invoice.id, + { + qbInvoiceId: qbInvoice.Id, + invoiceNumber: invoice.number, + }, + EventType.CREATED, + { + amount: (invoice.lineItems[0].amount * 100).toFixed(2), + taxAmount: invoice.taxAmount ? invoice.taxAmount.toFixed(2) : '0', + customerName: recipientInfo.displayName, + customerEmail: recipientInfo.email, + errorMessage: '', + }, + ) + console.info( + 'InvoiceService#checkIfInvoiceExistsInQBO | Invoice exists in QBO', + ) + return { exists: true } + } } diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index 9f7a868c..627611ae 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -1,3 +1,4 @@ +import APIError from '@/app/api/core/exceptions/api' import User from '@/app/api/core/models/User.model' import { BaseService } from '@/app/api/core/services/base.service' import { SyncableEntity } from '@/app/api/core/types/invoice' @@ -34,6 +35,7 @@ import { } from '@/utils/synclog' import dayjs from 'dayjs' import { z } from 'zod' +import httpStatus from 'http-status' export class PaymentService extends BaseService { private syncLogService: SyncLogService @@ -190,6 +192,10 @@ export class PaymentService extends BaseService { invoice: InvoiceResponse | undefined, ): Promise { const paymentResource = parsedPaymentSucceedResource.data + + if (!paymentResource.feeAmount) + throw new APIError(httpStatus.BAD_REQUEST, 'Fee amount is not found') + const intuitApi = new IntuitAPI(qbTokenInfo) const tokenService = new TokenService(this.user) const assetAccountRef = await tokenService.checkAndUpdateAccountStatus( diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index c2c8d522..bb99d5fe 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -385,7 +385,7 @@ export class WebhookService extends BaseService { payload: unknown, qbTokenInfo: IntuitAPITokensType, ) { - await sleep(1000) // Payment succeed event can sometimes trigger before invoice created. + await sleep(7000) // Payment succeed event can sometimes trigger before invoice created. console.info('###### PAYMENT SUCCEEDED ######') const parsedPaymentSucceed = @@ -397,8 +397,9 @@ export class WebhookService extends BaseService { return } const parsedPaymentSucceedResource = parsedPaymentSucceed.data + const feeAmount = parsedPaymentSucceedResource.data.feeAmount - if (parsedPaymentSucceedResource.data.feeAmount.paidByPlatform > 0) { + 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']) @@ -444,6 +445,7 @@ export class WebhookService extends BaseService { } catch (error: unknown) { const errorWithCode = getMessageAndCodeFromError(error) const errorMessage = errorWithCode.message + const feeAmount = parsedPaymentSucceedResource.data.feeAmount await syncLogService.updateOrCreateQBSyncLog({ portalId: this.user.workspaceId, @@ -451,10 +453,7 @@ export class WebhookService extends BaseService { eventType: EventType.SUCCEEDED, status: LogStatus.FAILED, copilotId: parsedPaymentSucceedResource.data.id, - feeAmount: - parsedPaymentSucceedResource.data.feeAmount.paidByPlatform.toFixed( - 2, - ), + feeAmount: feeAmount ? feeAmount.paidByPlatform.toFixed(2) : '0', remark: 'Absorbed fees', qbItemName: 'Assembly Fees', errorMessage, diff --git a/src/cmd/syncMissedInvoices/index.ts b/src/cmd/syncMissedInvoices/index.ts new file mode 100644 index 00000000..2eb1c6e2 --- /dev/null +++ b/src/cmd/syncMissedInvoices/index.ts @@ -0,0 +1,68 @@ +import APIError from '@/app/api/core/exceptions/api' +import User from '@/app/api/core/models/User.model' +import { SyncMissedInvoicesService } from '@/cmd/syncMissedInvoices/syncMissedInvoices.service' +import { copilotAPIKey } from '@/config' +import { PortalConnectionWithSettingType } from '@/db/schema/qbPortalConnections' +import { getAllActivePortalConnections } from '@/db/service/token.service' +import { CopilotAPI } from '@/utils/copilotAPI' +import { encodePayload } from '@/utils/crypto' +import CustomLogger from '@/utils/logger' + +/** + * This script is used to sync missed invoices that have payment records but no invoice records in QBO. + */ + +// command to run the script: `yarn run cmd:sync-missed-invoices` +;(async function run() { + try { + console.info('SyncMissedInvoices#run | Starting sync missed invoices') + const activeConnections = await getAllActivePortalConnections() + + if (!activeConnections.length) { + console.info('No active connection found') + process.exit(0) + } + + for (const connection of activeConnections) { + if (!connection.setting?.syncFlag || !connection.setting?.isEnabled) { + console.info( + 'Skipping connection: ' + JSON.stringify(connection.portalId), + ) + continue + } + + console.info( + `\n\n\n ########### Processing for PORTAL: ${connection.portalId} #############`, + ) + + await initiateProcess(connection) + } + + console.info('\n Sync missed invoices completed successfully') + process.exit(0) + } catch (error) { + console.error(error) + process.exit(1) + } +})() + +async function initiateProcess(connection: PortalConnectionWithSettingType) { + console.info('Generating token for the portal') + const payload = { + workspaceId: connection.portalId, + } + const token = encodePayload(copilotAPIKey, payload) + + const copilot = new CopilotAPI(token) + const tokenPayload = await copilot.getTokenPayload() + CustomLogger.info({ + obj: { copilotApiCronToken: token, tokenPayload }, + message: + 'syncMissedInvoices#initiateProcess | Copilot API token and payload', + }) + if (!tokenPayload) throw new APIError(500, 'Encoded token is not valid') + + const user = new User(token, tokenPayload) + const syncMissedService = new SyncMissedInvoicesService(user) + await syncMissedService.syncMissedInvoicesForPortal() +} diff --git a/src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts b/src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts new file mode 100644 index 00000000..46b6e170 --- /dev/null +++ b/src/cmd/syncMissedInvoices/syncMissedInvoices.service.ts @@ -0,0 +1,186 @@ +import APIError from '@/app/api/core/exceptions/api' +import { EntityType, LogStatus } from '@/app/api/core/types/log' +import { BaseService } from '@/app/api/core/services/base.service' +import { withRetry } from '@/app/api/core/utils/withRetry' +import { AuthService } from '@/app/api/quickbooks/auth/auth.service' +import { InvoiceService } from '@/app/api/quickbooks/invoice/invoice.service' +import { QBSyncLog } from '@/db/schema/qbSyncLogs' +import { StatusableError } from '@/type/CopilotApiError' +import { CopilotAPI } from '@/utils/copilotAPI' +import CustomLogger from '@/utils/logger' +import { and, eq, gte, or, sql } from 'drizzle-orm' +import httpStatus from 'http-status' +import { InvoiceStatus } from '@/app/api/core/types/invoice' + +export class SyncMissedInvoicesService extends BaseService { + async _syncMissedInvoicesForPortal() { + try { + console.info( + `SyncMissedInvoicesService#syncMissedInvoicesForPortal :: Processing portal: ${this.user.workspaceId}`, + ) + + // 1. Query missed payment records (payments with no corresponding invoice sync log) + const missedRecords = await this.getMissedPaymentRecords() + + if (missedRecords.length === 0) { + console.info(`No missed records for portal ${this.user.workspaceId}`) + return + } + + console.info( + `Found ${missedRecords.length} missed invoice records for portal ${this.user.workspaceId}`, + ) + + // 2. Fetch all invoices from Copilot for this portal (single API call) + const copilotApi = new CopilotAPI(this.user.token) + const allInvoices = await copilotApi.getInvoices(this.user.workspaceId) + const allPayments = await copilotApi.getPayments() + + if (!allInvoices || allInvoices.length === 0) { + console.info( + `No invoices found in Copilot for portal ${this.user.workspaceId}`, + ) + return + } + + // 3. Get QB connection tokens + const authService = new AuthService(this.user) + const qbTokenInfo = await authService.getQBPortalConnection( + this.user.workspaceId, + ) + + if (!qbTokenInfo.accessToken || !qbTokenInfo.refreshToken) { + console.info( + `No access token found for portal: ${this.user.workspaceId}`, + ) + return + } + + // 4. Process each missed record + const invoiceService = new InvoiceService(this.user) + let successCount = 0, + failCount = 0, + skipCount = 0 + + for (const record of missedRecords) { + const payment = allPayments?.data?.find( + (payment) => payment.id === record.copilotId, + ) + + if (!payment) { + console.info( + `Payment not found in Copilot for id: ${record.copilotId}. Skipping.`, + ) + skipCount++ + continue + } + + const invoice = allInvoices.find((inv) => inv.id === payment.invoiceId) + + if (!invoice) { + console.info( + `Invoice not found in Copilot for number: ${record.invoiceNumber}. Skipping.`, + ) + skipCount++ + continue + } + + try { + // check if the invoice exists in QBO + const invoiceCheck = await invoiceService.checkIfInvoiceExistsInQBO( + { data: invoice }, + qbTokenInfo, + ) + + if (!invoiceCheck.exists) { + await invoiceService.webhookInvoiceCreated( + { data: invoice }, + qbTokenInfo, + ) + + if (invoice.status === InvoiceStatus.VOID) { + await invoiceService.webhookInvoiceVoided(invoice, qbTokenInfo) + } + } else { + console.info( + `Invoice already exists in QBO for number: ${record.invoiceNumber}. Skipping.`, + ) + skipCount++ + } + + // Update the payment sync log record with the invoice number if it was missing + if (!record.invoiceNumber) { + await this.db + .update(QBSyncLog) + .set({ invoiceNumber: invoice.number }) + .where(eq(QBSyncLog.id, record.id)) + console.info( + `Updated payment record ${record.id} with invoice_number: ${invoice.number}`, + ) + + if (invoiceCheck.exists) continue + } + + successCount++ + console.info(`Synced invoice: ${invoice.number}`) + } catch (error) { + failCount++ + CustomLogger.error({ + message: `SyncMissedInvoicesService#syncMissedInvoicesForPortal | Failed to sync invoice: ${record.invoiceNumber}`, + obj: { error, invoiceNumber: record.invoiceNumber }, + }) + } + } + + console.info( + `Portal ${this.user.workspaceId} summary: ${successCount} synced, ${failCount} failed, ${skipCount} skipped`, + ) + } catch (error: unknown) { + if (error instanceof APIError) { + throw error + } + const assemblyError = error as StatusableError + const status = assemblyError.status || httpStatus.BAD_REQUEST + if (status === httpStatus.FORBIDDEN) { + console.info( + `Assembly sdk returns forbidden for the portal ${this.user.workspaceId}`, + ) + return + } + throw error + } + } + + private async getMissedPaymentRecords() { + return await this.db + .select() + .from(QBSyncLog) + .where( + and( + eq(QBSyncLog.portalId, this.user.workspaceId), + eq(QBSyncLog.entityType, EntityType.PAYMENT), + eq(QBSyncLog.status, LogStatus.SUCCESS), + gte(QBSyncLog.createdAt, new Date('2026-01-01')), + or( + sql`${QBSyncLog.invoiceNumber} NOT IN ( + SELECT ${QBSyncLog.invoiceNumber} FROM ${QBSyncLog} + WHERE ${QBSyncLog.entityType} = ${EntityType.INVOICE} + and ${QBSyncLog.portalId} = ${this.user.workspaceId} + )`, + eq(QBSyncLog.invoiceNumber, ''), + ), + ), + ) + .orderBy(QBSyncLog.createdAt) + } + + private wrapWithRetry( + fn: (...args: Args) => Promise, + ): (...args: Args) => Promise { + return (...args: Args): Promise => withRetry(fn.bind(this), args) + } + + syncMissedInvoicesForPortal = this.wrapWithRetry( + this._syncMissedInvoicesForPortal, + ) +} diff --git a/src/type/common.ts b/src/type/common.ts index bb7706e0..967d8615 100644 --- a/src/type/common.ts +++ b/src/type/common.ts @@ -331,7 +331,8 @@ export const InvoiceResponseSchema = z.object({ companyId: z.string().uuid().or(z.literal('')), // allow uuid or empty string status: z.nativeEnum(InvoiceStatus), total: z.number(), - taxPercentage: z.number().default(0), + taxPercentage: z.number().default(0).nullable(), + taxAmount: z.number().default(0).nullable(), sentDate: z.string().datetime().nullish(), dueDate: z.string().datetime().nullish(), paymentMethodPreferences: z.array( @@ -347,10 +348,12 @@ const PaymentResponseSchema = z.object({ id: z.string(), invoiceId: z.string(), status: z.nativeEnum(PaymentStatus), - feeAmount: z.object({ - paidByPlatform: z.number(), - paidByClient: z.number(), - }), + feeAmount: z + .object({ + paidByPlatform: z.number(), + paidByClient: z.number(), + }) + .nullable(), }) export const PaymentsResponseSchema = z.object({ data: z.array(PaymentResponseSchema).optional(), diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 44a7185b..78d56bf0 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -249,3 +249,8 @@ export const QBItemsResponseSchema = z.array( }), ) export type QBItemsResponseType = z.infer + +export const SingleIdAndTokenResponseSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), +}) diff --git a/src/type/dto/webhook.dto.ts b/src/type/dto/webhook.dto.ts index 6b1a1327..e6f8bb8c 100644 --- a/src/type/dto/webhook.dto.ts +++ b/src/type/dto/webhook.dto.ts @@ -34,7 +34,8 @@ export const InvoiceCreatedResponseSchema = z.object({ companyId: z.string().uuid().or(z.literal('')), // allow uuid or empty string status: z.nativeEnum(InvoiceStatus), total: z.number(), - taxPercentage: z.number().default(0), + taxPercentage: z.number().default(0).nullable(), + taxAmount: z.number().default(0).nullable(), sentDate: z.string().datetime().nullish(), dueDate: z.string().datetime().nullish(), paymentMethodPreferences: z @@ -116,7 +117,8 @@ export const InvoiceResponseSchema = z.object({ number: z.string(), status: z.nativeEnum(InvoiceStatus), total: z.number(), - taxPercentage: z.number().default(0), + taxPercentage: z.number().default(0).nullable(), + taxAmount: z.number().default(0).nullable(), // recipientId: z.string(), clientId: z.string().uuid().or(z.literal('')), // allow uuid or empty string companyId: z.string().uuid().or(z.literal('')), // allow uuid or empty string @@ -131,10 +133,12 @@ export const PaymentSucceededResponseSchema = z.object({ status: z.nativeEnum(PaymentStatus), paymentMethod: z.string(), brand: z.string(), - feeAmount: z.object({ - paidByPlatform: z.number(), - paidByClient: z.number(), - }), + feeAmount: z + .object({ + paidByPlatform: z.number(), + paidByClient: z.number(), + }) + .nullable(), createdAt: z.string().datetime(), }), }) diff --git a/src/utils/copilotAPI.ts b/src/utils/copilotAPI.ts index 389dfeb1..4d37b904 100644 --- a/src/utils/copilotAPI.ts +++ b/src/utils/copilotAPI.ts @@ -49,7 +49,10 @@ import { copilotApi } from 'copilot-node-sdk' import { z } from 'zod' import { API_DOMAIN } from '@/constant/domains' import httpStatus from 'http-status' -import { MAX_INVOICE_LIST_LIMIT } from '@/app/api/core/constants/limit' +import { + MAX_ASSEMBLY_RESOURCE_LIST_LIMIT, + MAX_INVOICE_LIST_LIMIT, +} from '@/app/api/core/constants/limit' export class CopilotAPI { copilot: SDK @@ -435,10 +438,15 @@ export class CopilotAPI { return z.array(InvoiceResponseSchema).parse(data.data) } - async _getPayments(invoiceId: string): Promise { + async _getPayments( + invoiceId?: string, + ): Promise { console.info('CopilotAPI#getPayments | token =', this.token) return PaymentsResponseSchema.parse( - await this.copilot.listPayments({ invoiceId }), + await this.copilot.listPayments({ + invoiceId, + limit: MAX_ASSEMBLY_RESOURCE_LIST_LIMIT.toString(), + }), ) } diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 0d3a314b..2716589e 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -26,6 +26,7 @@ import { CustomerQueryResponseType, CustomerQueryResponseSchema, QBItemsResponseSchema, + SingleIdAndTokenResponseSchema, } from '@/type/dto/intuitAPI.dto' import { RetryableError } from '@/utils/error' import CustomLogger from '@/utils/logger' @@ -572,6 +573,38 @@ export default class IntuitAPI { return payment } + async _getInvoice(invoiceNumber: string) { + CustomLogger.info({ + obj: { invoiceNumber }, + message: `IntuitAPI#getInvoice | invoice query start for realmId: ${this.tokens.intuitRealmId}. `, + }) + const query = `select Id, SyncToken, DocNumber from Invoice where DocNumber = '${invoiceNumber}' maxresults 1` + const invoice = await this.customQuery(query) + + if (!invoice) + throw new APIError( + httpStatus.BAD_REQUEST, + 'IntuitAPI#getInvoice | message = no response', + ) + + if (invoice?.Fault) { + CustomLogger.error({ obj: invoice.Fault?.Error, message: 'Error: ' }) + throw new APIError( + invoice.Fault?.Error?.code || httpStatus.BAD_REQUEST, + `${IntuitAPIErrorMessage}getInvoice`, + invoice.Fault?.Error, + ) + } + + if (!invoice.Invoice) return null + + CustomLogger.info({ + obj: { response: invoice.Invoice }, + message: `IntuitAPI#getInvoice | invoice fetched with doc number = ${invoiceNumber}.`, + }) + return SingleIdAndTokenResponseSchema.parse(invoice.Invoice[0]) + } + async _voidInvoice(payload: QBDestructiveInvoicePayloadSchema) { CustomLogger.info({ obj: { payload }, @@ -882,6 +915,7 @@ export default class IntuitAPI { customerSparseUpdate = this.wrapWithRetry(this._customerSparseUpdate) itemFullUpdate = this.wrapWithRetry(this._itemFullUpdate) createPayment = this.wrapWithRetry(this._createPayment) + getInvoice = this.wrapWithRetry(this._getInvoice) voidInvoice = this.wrapWithRetry(this._voidInvoice) deleteInvoice = this.wrapWithRetry(this._deleteInvoice) getAnAccount: {