diff --git a/src/app/api/core/types/log.ts b/src/app/api/core/types/log.ts index 0ff23eff..1e1c6ce3 100644 --- a/src/app/api/core/types/log.ts +++ b/src/app/api/core/types/log.ts @@ -30,5 +30,9 @@ export enum EventType { export enum FailedRecordCategoryType { AUTH = 'auth', ACCOUNT = 'account', + RATE_LIMIT = 'rate_limit', + VALIDATION = 'validation', + QB_API_ERROR = 'qb_api_error', + MAPPING_NOT_FOUND = 'mapping_not_found', OTHERS = 'others', } diff --git a/src/app/api/core/utils/withErrorHandler.ts b/src/app/api/core/utils/withErrorHandler.ts index 838b8bf5..c84eeefb 100644 --- a/src/app/api/core/utils/withErrorHandler.ts +++ b/src/app/api/core/utils/withErrorHandler.ts @@ -4,12 +4,14 @@ import { StatusableError, } from '@/type/CopilotApiError' import APIError from '@/app/api/core/exceptions/api' +import { FailedRecordCategoryType } from '@/app/api/core/types/log' import httpStatus from 'http-status' import { NextRequest, NextResponse } from 'next/server' import { ZodError, ZodFormattedError } from 'zod' import { isAxiosError } from '@/app/api/core/exceptions/custom' import * as Sentry from '@sentry/nextjs' -import { RetryableError } from '@/utils/error' +import { getMessageAndCodeFromError, RetryableError } from '@/utils/error' +import { getCategory } from '@/utils/synclog' type RequestHandler = (req: NextRequest, params: any) => Promise @@ -74,13 +76,27 @@ export const withErrorHandler = (handler: RequestHandler): RequestHandler => { status = error.response.status } - // if error is from Copilot or Intuit API (API error), then send the error message to Sentry - if ( + // Send categorized errors to Sentry with context tags + if (error instanceof ZodError) { + Sentry.withScope((scope) => { + scope.setTag('errorCategory', FailedRecordCategoryType.VALIDATION) + Sentry.captureException(error) + }) + } else if ( error instanceof APIError || error instanceof CopilotApiError || isAxiosError(error) ) { - Sentry.captureException(error) + const errorWithCode = getMessageAndCodeFromError(error) + const category = getCategory(errorWithCode) + + Sentry.withScope((scope) => { + scope.setTag('errorCategory', category) + if (errorWithCode.source && errorWithCode.source !== 'unknown') { + scope.setTag('errorSource', errorWithCode.source) + } + Sentry.captureException(error) + }) } return NextResponse.json({ error: message, errors }, { status }) diff --git a/src/app/api/quickbooks/cron/cron.service.ts b/src/app/api/quickbooks/cron/cron.service.ts index dc3b8be2..3c159537 100644 --- a/src/app/api/quickbooks/cron/cron.service.ts +++ b/src/app/api/quickbooks/cron/cron.service.ts @@ -10,6 +10,7 @@ import { getAllActivePortalConnections } from '@/db/service/token.service' import { CopilotAPI } from '@/utils/copilotAPI' import { encodePayload } from '@/utils/crypto' import CustomLogger from '@/utils/logger' +import * as Sentry from '@sentry/nextjs' import dayjs from 'dayjs' import { and, eq, lt } from 'drizzle-orm' @@ -18,29 +19,34 @@ export default class CronService { workspaceId: string, qbConnectionTokens: QBConnectionProperties, ) { - const payload = { - workspaceId, - } - const token = encodePayload(copilotAPIKey, payload) + return await Sentry.withScope(async (scope) => { + scope.setTag('portalId', workspaceId) + scope.setTag('workspaceId', workspaceId) - // check if token is valid or not - const copilot = new CopilotAPI(token) - const tokenPayload = await copilot.getTokenPayload() - CustomLogger.info({ - obj: { copilotApiCronToken: token, tokenPayload }, - message: - 'CronService#_scheduleSinglePortal | Copilot API token and payload', - }) - if (!tokenPayload) throw new APIError(500, 'Encoded token is not valid') // this should trigger p-retry and re-run the function + const payload = { + workspaceId, + } + const token = encodePayload(copilotAPIKey, payload) - const user = new User(token, tokenPayload) - user.qbConnection = qbConnectionTokens - const syncService = new SyncService(user) + // check if token is valid or not + const copilot = new CopilotAPI(token) + const tokenPayload = await copilot.getTokenPayload() + CustomLogger.info({ + obj: { copilotApiCronToken: token, tokenPayload }, + message: + 'CronService#_scheduleSinglePortal | Copilot API token and payload', + }) + if (!tokenPayload) throw new APIError(500, 'Encoded token is not valid') // this should trigger p-retry and re-run the function + + const user = new User(token, tokenPayload) + user.qbConnection = qbConnectionTokens + const syncService = new SyncService(user) - // TODO: update this on QB tech debt milestone - // const { suspended } = await syncService.checkAndSuspendAccount() - // if (suspended) return - return await syncService.syncFailedRecords() + // TODO: update this on QB tech debt milestone + // const { suspended } = await syncService.checkAndSuspendAccount() + // if (suspended) return + return await syncService.syncFailedRecords() + }) } async rerunFailedSync() { diff --git a/src/app/api/quickbooks/customer/customer.service.ts b/src/app/api/quickbooks/customer/customer.service.ts index 457993d8..2c3e2052 100644 --- a/src/app/api/quickbooks/customer/customer.service.ts +++ b/src/app/api/quickbooks/customer/customer.service.ts @@ -15,6 +15,7 @@ import { QBCustomerCreatePayloadType } from '@/type/dto/intuitAPI.dto' import { InvoiceCreatedResponseType } from '@/type/dto/webhook.dto' import { CopilotAPI } from '@/utils/copilotAPI' import IntuitAPI from '@/utils/intuitAPI' +import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceSpecialCharsForQB } from '@/utils/string' import { and, eq, isNull } from 'drizzle-orm' import httpStatus from 'http-status' @@ -365,6 +366,10 @@ export class CustomerService extends BaseService { customer = undefined } + addSyncBreadcrumb('Customer search in QBO', { + found: !!customer, + }) + // 3. if not found, create a new client in the QB if (!customer) { console.info( @@ -390,6 +395,9 @@ export class CustomerService extends BaseService { const customerRes = await intuitApiService.createCustomer(customerPayload) customer = customerRes + addSyncBreadcrumb('Customer created in QBO', { + qbCustomerId: customer.Id, + }) console.info( `InvoiceService#WebhookInvoiceCreated | Customer created in QB with ID: ${customer.Id}.`, ) diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index dc2a5798..33494e79 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -47,6 +47,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { convert } from 'html-to-text' import httpStatus from 'http-status' import { z } from 'zod' +import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceSpecialCharsForQB } from '@/utils/string' import { AccountTypeObj } from '@/constant/qbConnection' @@ -538,6 +539,10 @@ export class InvoiceService extends BaseService { qbTokenInfo: IntuitAPITokensType, ): Promise { const invoiceResource = payload.data + addSyncBreadcrumb('Invoice creation started', { + invoiceNumber: invoiceResource.number, + portalId: this.user.workspaceId, + }) // Check if the invoice with ID already exists in the db. This check is done in this function as it is also called from re-sync failed function const existingInvoice = await this.getInvoiceByNumber( @@ -575,6 +580,10 @@ export class InvoiceService extends BaseService { intuitApiService, ) + addSyncBreadcrumb('Customer resolved', { + existingMapping: !!existingCustomer, + }) + let customer, existingCustomerMapId = existingCustomer?.id if (!existingCustomer) { @@ -731,6 +740,9 @@ export class InvoiceService extends BaseService { } // 6. create invoice in QB + addSyncBreadcrumb('Creating invoice in QBO', { + invoiceNumber: invoiceResource.number, + }) const invoiceRes = await intuitApiService.createInvoice(qbInvoicePayload) const invoicePayload = { @@ -804,6 +816,9 @@ export class InvoiceService extends BaseService { payload: InvoiceResponseType, qbTokenInfo: IntuitAPITokensType, ): Promise { + addSyncBreadcrumb('Invoice paid flow started', { + invoiceNumber: payload.data.number, + }) // 1. check if the status of invoice is already paid in sync table const invoiceSync = await this.getInvoiceByNumber(payload.data.number, [ 'id', @@ -926,6 +941,9 @@ export class InvoiceService extends BaseService { payload: InvoiceVoidedResponse, qbTokenInfo: IntuitAPITokensType, ): Promise { + addSyncBreadcrumb('Invoice voided flow started', { + invoiceNumber: payload.number, + }) // 1. check if the status of invoice is already paid in sync table const invoiceSync = await this.getInvoiceByNumber(payload.number, [ 'id', @@ -1013,6 +1031,9 @@ export class InvoiceService extends BaseService { payload: InvoiceDeletedResponse, qbTokenInfo: IntuitAPITokensType, ): Promise { + addSyncBreadcrumb('Invoice deleted flow started', { + invoiceNumber: payload.number, + }) const syncedInvoice = await this.getInvoiceByNumber(payload.number, [ 'id', 'qbInvoiceId', diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index 627611ae..af20bed5 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -33,6 +33,7 @@ import { getDeletedAtForAuthAccountCategoryLog, getCategory, } from '@/utils/synclog' +import { addSyncBreadcrumb } from '@/utils/sentry' import dayjs from 'dayjs' import { z } from 'zod' import httpStatus from 'http-status' @@ -95,6 +96,9 @@ export class PaymentService extends BaseService { }, ): Promise { const parsedQbPayload = QBPaymentCreatePayloadSchema.parse(qbPaymentPayload) + addSyncBreadcrumb('Creating payment in QBO', { + invoiceNumber: invoiceInfo.invoiceNumber, + }) // to save error sync log when payment creation fails in QB try { const qbPaymentRes = await intuitApi.createPayment(parsedQbPayload) @@ -152,6 +156,7 @@ export class PaymentService extends BaseService { ) { const parsedPayload = QBPurchaseCreatePayloadSchema.parse(payload) + addSyncBreadcrumb('Creating expense for absorbed fees') console.info( 'PaymentService#webhookPaymentSucceeded | Creating expense for absorbed fees', ) @@ -192,6 +197,10 @@ export class PaymentService extends BaseService { invoice: InvoiceResponse | undefined, ): Promise { const paymentResource = parsedPaymentSucceedResource.data + addSyncBreadcrumb('Payment succeeded flow started', { + paymentId: paymentResource.id, + invoiceId: paymentResource.invoiceId, + }) if (!paymentResource.feeAmount) throw new APIError(httpStatus.BAD_REQUEST, 'Fee amount is not found') diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index f2ac6869..176a2fa4 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -33,6 +33,7 @@ import httpStatus from 'http-status' import { QBSyncLog, QBSyncLogWithEntityType } from '@/db/schema/qbSyncLogs' import User from '@/app/api/core/models/User.model' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' +import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceBeforeParens, replaceSpecialCharsForQB } from '@/utils/string' import { AccountTypeObj } from '@/constant/qbConnection' import { TokenService } from '@/app/api/quickbooks/token/token.service' @@ -365,6 +366,9 @@ export class ProductService extends BaseService { qbTokenInfo: IntuitAPITokensType, ): Promise { const productResource = resource.data + addSyncBreadcrumb('Product updated flow started', { + productId: productResource.id, + }) // 01. get all the mapped product ids with qb id const mappedConditions = @@ -506,6 +510,10 @@ export class ProductService extends BaseService { qbTokenInfo: IntuitAPITokensType, ): Promise { const priceResource = resource.data + addSyncBreadcrumb('Price created flow started', { + priceId: priceResource.id, + productId: priceResource.productId, + }) const intuitApi = new IntuitAPI(qbTokenInfo) await this.db.transaction(async (tx) => { @@ -527,6 +535,9 @@ export class ProductService extends BaseService { (product) => product.priceId === priceResource.id, ).length + addSyncBreadcrumb('Product mapping check', { + alreadyMapped: !!productWithPriceCount, + }) if (productWithPriceCount && productWithPriceCount > 0) { console.info('Product already mapped with price') return diff --git a/src/app/api/quickbooks/sync/sync.service.ts b/src/app/api/quickbooks/sync/sync.service.ts index 9e3fcfa3..6d1f78aa 100644 --- a/src/app/api/quickbooks/sync/sync.service.ts +++ b/src/app/api/quickbooks/sync/sync.service.ts @@ -430,6 +430,34 @@ export class SyncService extends BaseService { }) break } + + // Report to Sentry after the final attempt so breadcrumbs from processing are included + if (resyncAttemtps.isLastAttempt) { + const currentLog = await this.syncLogService.getOne( + eq(QBSyncLog.id, log.id), + ) + if (currentLog?.status === LogStatus.FAILED) { + captureMessage( + `SyncService#intiateSync | Records exceeded max retry count. Portal Id: ${this.user.workspaceId}.`, + { + tags: { + key: 'exceedMaxAttempts', + portalId: this.user.workspaceId, + entityType: log.entityType, + eventType: log.eventType, + errorCategory: currentLog.category, + }, + extra: { + LogId: log.id, + invoiceNumber: log.invoiceNumber, + errorMessage: currentLog.errorMessage, + attempt: MAX_ATTEMPTS, + }, + level: 'error', + }, + ) + } + } } } @@ -541,7 +569,7 @@ export class SyncService extends BaseService { message: `SyncService#checkAndUpdateAttempt | Reached max attempts. Not syncing the record with assembly id: ${log.copilotId}`, obj: { workspaceId: this.user.workspaceId }, }) - return { maxAttempts: true } + return { maxAttempts: true, isLastAttempt: false } } const attempt = log.attempt + 1 @@ -552,28 +580,12 @@ export class SyncService extends BaseService { eq(QBSyncLog.id, log.id), ) - // report to sentry if any records has exceeded max retry count - if (attempt == MAX_ATTEMPTS) { - captureMessage( - `SyncService#checkAndUpdateAttempt | Records exceeded max retry count. Portal Id: ${this.user.workspaceId}.`, - { - tags: { - key: 'exceedMaxAttempts', // can be used to search like "key:exceedMaxAttempts" - }, - extra: { - LogId: log.id, // shown in "Additional Data" section in Sentry - }, - level: 'error', - }, - ) - } - CustomLogger.info({ message: `SyncService#checkAndUpdateAttempt | Attempt: ${attempt}`, obj: { workspaceId: this.user.workspaceId }, }) - return { maxAttempts: false } + return { maxAttempts: false, isLastAttempt: attempt === MAX_ATTEMPTS } } private async updateFailedSyncLog( diff --git a/src/app/api/quickbooks/webhook/webhook.controller.ts b/src/app/api/quickbooks/webhook/webhook.controller.ts index fb3d709d..fedab968 100644 --- a/src/app/api/quickbooks/webhook/webhook.controller.ts +++ b/src/app/api/quickbooks/webhook/webhook.controller.ts @@ -1,23 +1,31 @@ import authenticate from '@/app/api/core/utils/authenticate' import { AuthService } from '@/app/api/quickbooks/auth/auth.service' import { WebhookService } from '@/app/api/quickbooks/webhook/webhook.service' +import * as Sentry from '@sentry/nextjs' import { NextRequest, NextResponse } from 'next/server' export const maxDuration = 300 // 5 minutes export async function captureWebhookEvent(req: NextRequest) { - console.info('\n\n####### Webhook triggered #######') - const user = await authenticate(req) - const authService = new AuthService(user) - const payload = await req.json() + return Sentry.withScope(async (scope) => { + console.info('\n\n####### Webhook triggered #######') + const user = await authenticate(req) + scope.setTag('portalId', user.workspaceId) + scope.setTag('workspaceId', user.workspaceId) - const qbTokenInfo = await authService.getQBPortalConnection(user.workspaceId) - user.qbConnection = { - serviceItemRef: qbTokenInfo.serviceItemRef, - clientFeeRef: qbTokenInfo.clientFeeRef, - } - const webhookService = new WebhookService(user) - await webhookService.handleWebhookEvent(payload, qbTokenInfo) + const authService = new AuthService(user) + const payload = await req.json() - return NextResponse.json({ ok: true }) + const qbTokenInfo = await authService.getQBPortalConnection( + user.workspaceId, + ) + user.qbConnection = { + serviceItemRef: qbTokenInfo.serviceItemRef, + clientFeeRef: qbTokenInfo.clientFeeRef, + } + const webhookService = new WebhookService(user) + await webhookService.handleWebhookEvent(payload, qbTokenInfo) + + return NextResponse.json({ ok: true }) + }) } diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 41e00b42..2629a8ee 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -29,6 +29,7 @@ import { getDeletedAtForAuthAccountCategoryLog, getCategory, } from '@/utils/synclog' +import { addSyncBreadcrumb } from '@/utils/sentry' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' @@ -46,6 +47,10 @@ export class WebhookService extends BaseService { } const payload = parsedBody.data + addSyncBreadcrumb('Webhook event received', { + eventType: payload.eventType, + portalId: this.user.workspaceId, + }) CustomLogger.info({ obj: { payload }, message: 'WebhookService#handleWebhookEvent | Webhook payload received', diff --git a/src/db/migrations/20260409070000_add_new_failed_record_categories.sql b/src/db/migrations/20260409070000_add_new_failed_record_categories.sql new file mode 100644 index 00000000..dbfc63f9 --- /dev/null +++ b/src/db/migrations/20260409070000_add_new_failed_record_categories.sql @@ -0,0 +1,4 @@ +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'rate_limit';--> statement-breakpoint +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'validation';--> statement-breakpoint +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'qb_api_error';--> statement-breakpoint +ALTER TYPE "public"."failed_record_category_types" ADD VALUE IF NOT EXISTS 'mapping_not_found'; diff --git a/src/db/migrations/meta/20260409070000_snapshot.json b/src/db/migrations/meta/20260409070000_snapshot.json new file mode 100644 index 00000000..5c4e1807 --- /dev/null +++ b/src/db/migrations/meta/20260409070000_snapshot.json @@ -0,0 +1,986 @@ +{ + "id": "a3c7d892-5e1f-4b8a-9c6d-2f4e8a1b3d5c", + "prevId": "1f41b71d-f21a-44c6-af6a-4fda7762d670", + "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_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", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "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 e05b8225..707be347 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1775465579546, "tag": "20260406085259_add_customer_type_column_in_customers_table", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1775955600000, + "tag": "20260409070000_add_new_failed_record_categories", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/utils/error.ts b/src/utils/error.ts index f4043689..33259698 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -3,6 +3,7 @@ import { isAxiosError } from '@/app/api/core/exceptions/custom' import { CopilotApiError, MessagableError } from '@/type/CopilotApiError' import { IntuitAPIErrorMessage } from '@/utils/intuitAPI' import httpStatus from 'http-status' +import { ZodError } from 'zod' export type IntuitErrorType = { Message: string @@ -14,6 +15,8 @@ export type IntuitErrorType = { export type ErrorMessageAndCode = { message: string code: number + source?: 'intuit' | 'copilot' | 'unknown' + isValidationError?: boolean } export const getMessageAndCodeFromError = ( @@ -25,20 +28,40 @@ export const getMessageAndCodeFromError = ( const code: number = httpStatus.INTERNAL_SERVER_ERROR // Build a proper response based on the type of Error encountered - if (error instanceof CopilotApiError) { - return { message: error.body.message || message, code: error.status } + if (error instanceof ZodError) { + return { + message: error.message, + code: httpStatus.UNPROCESSABLE_ENTITY, + source: 'unknown', + isValidationError: true, + } + } else if (error instanceof CopilotApiError) { + return { + message: error.body.message || message, + code: error.status, + source: 'copilot', + } } else if (error instanceof APIError) { let errorMessage = error.message || message - if (error.message.includes(IntuitAPIErrorMessage)) { + const isIntuitError = error.message.includes(IntuitAPIErrorMessage) + if (isIntuitError) { errorMessage = (error.errors?.[0] as IntuitErrorType).Detail } - return { message: errorMessage, code: error.status } + return { + message: errorMessage, + code: error.status, + source: isIntuitError ? 'intuit' : 'unknown', + } } else if (error instanceof Error && error.message) { - return { message: error.message, code } + return { message: error.message, code, source: 'unknown' } } else if (isAxiosError(error)) { - return { message: error.response.data.error, code: error.response.status } + return { + message: error.response.data.error, + code: error.response.status, + source: 'unknown', + } } - return { message, code } + return { message, code, source: 'unknown' } } export class RetryableError extends Error { diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 0926c74c..56f7bf8a 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,15 +1,17 @@ import util from 'util' +const isDev = process.env.NODE_ENV === 'development' + const CustomLogger = { info({ message, obj }: { message: string; obj?: any }) { const consoleBody = [message] - if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: true })) + if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: isDev })) console.info(...consoleBody) }, error({ message, obj }: { message: string; obj?: any }) { const consoleBody = [message] - if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: true })) + if (obj) consoleBody.push(util.inspect(obj, { depth: null, colors: isDev })) console.error(...consoleBody) }, diff --git a/src/utils/sentry.ts b/src/utils/sentry.ts new file mode 100644 index 00000000..46a429f0 --- /dev/null +++ b/src/utils/sentry.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs' + +export function addSyncBreadcrumb( + message: string, + data?: Record, +) { + Sentry.addBreadcrumb({ + category: 'sync', + message, + data, + level: 'info', + }) +} diff --git a/src/utils/synclog.ts b/src/utils/synclog.ts index 504f2cd3..a39836f5 100644 --- a/src/utils/synclog.ts +++ b/src/utils/synclog.ts @@ -3,6 +3,9 @@ import { AccountErrorCodes } from '@/constant/intuitErrorCode' import { refreshTokenExpireMessage } from '@/utils/auth' import { ErrorMessageAndCode } from '@/utils/error' +const MAPPING_NOT_FOUND_PATTERN = + /(?:mapping|customer|product|invoice|item|price).*not found/i + export function getCategory(errorWithCode?: ErrorMessageAndCode) { if (!errorWithCode) return FailedRecordCategoryType.OTHERS if (errorWithCode.code && AccountErrorCodes.includes(errorWithCode.code)) { @@ -11,6 +14,18 @@ export function getCategory(errorWithCode?: ErrorMessageAndCode) { if (errorWithCode.message === refreshTokenExpireMessage) { return FailedRecordCategoryType.AUTH } + if (errorWithCode.code === 429) { + return FailedRecordCategoryType.RATE_LIMIT + } + if (errorWithCode.isValidationError) { + return FailedRecordCategoryType.VALIDATION + } + if (errorWithCode.source === 'intuit') { + return FailedRecordCategoryType.QB_API_ERROR + } + if (MAPPING_NOT_FOUND_PATTERN.test(errorWithCode.message)) { + return FailedRecordCategoryType.MAPPING_NOT_FOUND + } return FailedRecordCategoryType.OTHERS }