From 94475465c9ed1b9b2a07ced96e4ec9cf72d03bdc Mon Sep 17 00:00:00 2001 From: Adam Schwartz <77259180+aschwartz91@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:56:03 -0400 Subject: [PATCH 01/10] feat(OUT-3604): add QBO bank deposits for automatic bank reconciliation When payments are received, optionally create QBO Bank Deposits that match the net amount deposited to the customer's bank (after Stripe fees), enabling automatic bank transaction matching. Payments are routed through Undeposited Funds, then a Bank Deposit moves the net amount to the bank account with the fee recorded as a negative line. This is behind a new opt-in bankDepositFeeFlag setting that requires absorbedFeeFlag to also be enabled. Co-Authored-By: Claude Opus 4.6 --- src/app/api/core/types/log.ts | 1 + src/app/api/quickbooks/auth/auth.service.ts | 9 + .../api/quickbooks/invoice/invoice.service.ts | 26 + .../api/quickbooks/payment/payment.service.ts | 71 ++ .../quickbooks/setting/setting.controller.ts | 6 +- src/app/api/quickbooks/token/token.service.ts | 28 + .../api/quickbooks/webhook/webhook.service.ts | 110 +- .../renameQbAccount.service.ts | 2 + .../sections/invoice/InvoiceDetail.tsx | 26 +- src/constant/qbConnection.ts | 1 + ...415184949_add_bank_deposit_fee_columns.sql | 3 + .../meta/20260415184949_snapshot.json | 1001 +++++++++++++++++ src/db/schema/qbPortalConnections.ts | 4 + src/db/schema/qbSettings.ts | 4 + src/db/service/token.service.ts | 2 + src/hook/useSettings.ts | 1 + src/type/common.ts | 13 +- src/type/dto/intuitAPI.dto.ts | 37 + src/utils/intuitAPI.ts | 34 + 19 files changed, 1363 insertions(+), 16 deletions(-) create mode 100644 src/db/migrations/20260415184949_add_bank_deposit_fee_columns.sql create mode 100644 src/db/migrations/meta/20260415184949_snapshot.json diff --git a/src/app/api/core/types/log.ts b/src/app/api/core/types/log.ts index 1e1c6ce3..0555a9c7 100644 --- a/src/app/api/core/types/log.ts +++ b/src/app/api/core/types/log.ts @@ -19,6 +19,7 @@ export enum EventType { SUCCEEDED = 'succeeded', MAPPED = 'mapped', UNMAPPED = 'unmapped', + DEPOSITED = 'deposited', } /** diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index b84ecfea..8cdee0f6 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -139,6 +139,9 @@ export class AuthService extends BaseService { assetAccountRef: insertPayload.assetAccountRef, serviceItemRef: existingToken?.serviceItemRef || null, clientFeeRef: existingToken?.clientFeeRef || null, + undepositedFundsAccountRef: + existingToken?.undepositedFundsAccountRef || null, + bankAccountRef: existingToken?.bankAccountRef || null, }) // handle accounts const createPayload = await this.handleAccountReferences( @@ -238,6 +241,8 @@ export class AuthService extends BaseService { setting, serviceItemRef, clientFeeRef, + undepositedFundsAccountRef, + bankAccountRef, isSuspended, } = portalQBToken @@ -260,6 +265,8 @@ export class AuthService extends BaseService { assetAccountRef: '', serviceItemRef: '', clientFeeRef: '', + undepositedFundsAccountRef: null, + bankAccountRef: null, } // if sync is false but it has been enabled then don't throw error. We have to log in this case @@ -281,6 +288,8 @@ export class AuthService extends BaseService { assetAccountRef, serviceItemRef, clientFeeRef, + undepositedFundsAccountRef, + bankAccountRef, } // Refresh token if expired diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 92b5a245..8012f5ce 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -887,11 +887,37 @@ export class InvoiceService extends BaseService { } const invoiceAmount = Number(z.string().parse(invoiceLog.amount)) / 100 + + // Check if bank deposit fee flow is enabled — if so, route payment through Undeposited Funds + const settingService = new SettingService(this.user) + const setting = await settingService.getOneByPortalId([ + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + ]) + const useBankDepositFlow = + setting?.absorbedFeeFlag && setting?.bankDepositFeeFlag + + let depositToAccountRef: { value: string } | undefined + if (useBankDepositFlow) { + const tokenService = new TokenService(this.user) + const undepositedFundsRef = + await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.UndepositedFunds, + qbTokenInfo.intuitRealmId, + new IntuitAPI(qbTokenInfo), + qbTokenInfo.undepositedFundsAccountRef ?? undefined, + ) + depositToAccountRef = { value: undepositedFundsRef } + } + const qbPaymentPayload = { TotalAmt: invoiceAmount, CustomerRef: { value: existingCustomer.qbCustomerId, }, + ...(depositToAccountRef && { + DepositToAccountRef: depositToAccountRef, + }), Line: [ { Amount: invoiceAmount, diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index af20bed5..5b591dca 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -25,6 +25,8 @@ import { QBPaymentCreatePayloadType, QBPurchaseCreatePayloadSchema, QBPurchaseCreatePayloadType, + QBDepositCreatePayloadSchema, + QBDepositCreatePayloadType, } from '@/type/dto/intuitAPI.dto' import { PaymentSucceededResponseType } from '@/type/dto/webhook.dto' import { getMessageAndCodeFromError } from '@/utils/error' @@ -191,6 +193,75 @@ export class PaymentService extends BaseService { } } + async createBankDepositForPayment( + intuitApi: IntuitAPI, + opts: { + qbPaymentId: string + grossAmount: number + feeAmount: number + bankAccountRef: string + expenseAccountRef: string + txnDate: string + invoiceNumber: string + paymentId: string + }, + ): Promise { + const netAmount = opts.grossAmount - opts.feeAmount + + const depositPayload: QBDepositCreatePayloadType = { + DepositToAccountRef: { value: opts.bankAccountRef }, + TxnDate: opts.txnDate, + Line: [ + { + Amount: opts.grossAmount, + LinkedTxn: [ + { TxnId: opts.qbPaymentId, TxnType: 'Payment' as const }, + ], + }, + { + Amount: -opts.feeAmount, + DetailType: 'DepositLineDetail' as const, + DepositLineDetail: { + AccountRef: { value: opts.expenseAccountRef }, + }, + Description: 'Stripe processing fee', + }, + ], + } + + const parsedPayload = QBDepositCreatePayloadSchema.parse(depositPayload) + + console.info( + `PaymentService#createBankDepositForPayment | Creating bank deposit: gross=${opts.grossAmount}, fee=${opts.feeAmount}, net=${netAmount}`, + ) + + const res = await intuitApi.createDeposit(parsedPayload) + + try { + await this.logSync( + opts.paymentId, + { + qbInvoiceId: res.Deposit.Id, + invoiceNumber: opts.invoiceNumber, + }, + EventType.DEPOSITED, + EntityType.PAYMENT, + { + amount: (opts.grossAmount * 100).toFixed(2), + feeAmount: (opts.feeAmount * 100).toFixed(2), + remark: 'Bank deposit with fee deduction', + qbItemName: 'Assembly Fees', + errorMessage: '', + }, + ) + } catch (error: unknown) { + console.error( + 'PaymentService#createBankDepositForPayment | Failed to log sync, but deposit was created in QBO', + ) + throw error + } + } + async webhookPaymentSucceeded( parsedPaymentSucceedResource: PaymentSucceededResponseType, qbTokenInfo: IntuitAPITokensType, diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index b3b42ada..9d01e4b2 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -22,7 +22,11 @@ export async function getSettings(req: NextRequest) { 'initialProductSettingMap', ) if (parsedType.data === SettingType.INVOICE) - returningFields.push('absorbedFeeFlag', 'useCompanyNameFlag') + returningFields.push( + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + 'useCompanyNameFlag', + ) if (parsedType.data === SettingType.PRODUCT) returningFields.push('createNewProductFlag') } diff --git a/src/app/api/quickbooks/token/token.service.ts b/src/app/api/quickbooks/token/token.service.ts index da2d9966..9663b3d6 100644 --- a/src/app/api/quickbooks/token/token.service.ts +++ b/src/app/api/quickbooks/token/token.service.ts @@ -156,6 +156,9 @@ export class TokenService extends BaseService { case AccountTypeObj.Asset: payload = { assetAccountRef: accountRef } break + case AccountTypeObj.UndepositedFunds: + payload = { undepositedFundsAccountRef: accountRef } + break default: throw new APIError( httpStatus.BAD_REQUEST, @@ -266,6 +269,29 @@ export class TokenService extends BaseService { return assetAccRef.Id } + private async getUndepositedFundsAccountRef( + intuitApi: IntuitAPI, + ): Promise { + // "Undeposited Funds" is a system account in every QBO company. + // Look up by subtype first (more reliable than name if user renamed it). + const query = `SELECT Id FROM Account WHERE AccountSubType = 'UndepositedFunds' AND Active = true maxresults 1` + const result = await intuitApi.customQuery(query) + if (result?.Account?.[0]?.Id) { + return result.Account[0].Id + } + + // Fallback: try by name + const byName = await intuitApi.getAnAccount('Undeposited Funds') + if (byName?.Id) { + return byName.Id + } + + throw new APIError( + httpStatus.INTERNAL_SERVER_ERROR, + 'TokenService#getUndepositedFundsAccountRef | Undeposited Funds account not found in QuickBooks', + ) + } + private async restoreAccountRef( accountType: AccountType, intuitApi: IntuitAPI, @@ -277,6 +303,8 @@ export class TokenService extends BaseService { return this.getOrCreateExpenseAccountRef(intuitApi) case AccountTypeObj.Asset: return this.getOrCreateAssetAccountRef(intuitApi) + case AccountTypeObj.UndepositedFunds: + return this.getUndepositedFundsAccountRef(intuitApi) default: throw new APIError( httpStatus.BAD_REQUEST, diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index db436add..2b77e4f6 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -19,10 +19,12 @@ import { WebhookEventResponseSchema, WebhookEventResponseType, } from '@/type/dto/webhook.dto' +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { AccountTypeObj } from '@/constant/qbConnection' import { validateAccessToken } from '@/utils/auth' import { CopilotAPI } from '@/utils/copilotAPI' import { ErrorMessageAndCode, getMessageAndCodeFromError } from '@/utils/error' -import { IntuitAPITokensType } from '@/utils/intuitAPI' +import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' import CustomLogger from '@/utils/logger' import { sleep } from '@/utils/sleep' import { @@ -413,7 +415,10 @@ export class WebhookService extends BaseService { if (feeAmount?.paidByPlatform && feeAmount.paidByPlatform > 0) { // check if absorbed fee flag is true const settingService = new SettingService(this.user) - const setting = await settingService.getOneByPortalId(['absorbedFeeFlag']) + const setting = await settingService.getOneByPortalId([ + 'absorbedFeeFlag', + 'bankDepositFeeFlag', + ]) if (!setting?.absorbedFeeFlag) { console.info( @@ -422,10 +427,15 @@ export class WebhookService extends BaseService { return } + const useBankDepositFlow = setting.bankDepositFeeFlag + const idempotencyEventType = useBankDepositFlow + ? EventType.DEPOSITED + : EventType.SUCCEEDED + const syncLogService = new SyncLogService(this.user) const syncLog = await syncLogService.getOneByCopilotIdAndEventType({ copilotId: parsedPaymentSucceedResource.data.id, - eventType: EventType.SUCCEEDED, + eventType: idempotencyEventType, entityType: EntityType.PAYMENT, }) if (syncLog?.status === LogStatus.SUCCESS) { @@ -447,13 +457,24 @@ export class WebhookService extends BaseService { try { validateAccessToken(qbTokenInfo) - // only track if the fee amount is paid by platform const paymentService = new PaymentService(this.user) - await paymentService.webhookPaymentSucceeded( - parsedPaymentSucceedResource, - qbTokenInfo, - invoice, - ) + + if (useBankDepositFlow) { + // Bank deposit flow: create a QBO Bank Deposit that matches the net bank amount + await this.handleBankDepositFlow( + parsedPaymentSucceedResource, + qbTokenInfo, + invoice, + paymentService, + ) + } else { + // Legacy flow: create a standalone expense for absorbed fees + await paymentService.webhookPaymentSucceeded( + parsedPaymentSucceedResource, + qbTokenInfo, + invoice, + ) + } } catch (error: unknown) { CustomLogger.error({ message: 'Webhook handler failed', obj: error }) const errorWithCode = getMessageAndCodeFromError(error) @@ -463,12 +484,14 @@ export class WebhookService extends BaseService { await syncLogService.updateOrCreateQBSyncLog({ portalId: this.user.workspaceId, entityType: EntityType.PAYMENT, - eventType: EventType.SUCCEEDED, + eventType: idempotencyEventType, status: LogStatus.FAILED, copilotId: parsedPaymentSucceedResource.data.id, invoiceNumber: invoice.number, feeAmount: feeAmount ? feeAmount.paidByPlatform.toFixed(2) : '0', - remark: 'Absorbed fees', + remark: useBankDepositFlow + ? 'Bank deposit with fee deduction' + : 'Absorbed fees', qbItemName: 'Assembly Fees', errorMessage, deletedAt: getDeletedAtForAuthAccountCategoryLog(errorWithCode), @@ -481,4 +504,69 @@ export class WebhookService extends BaseService { } } } + + /** + * Creates a QBO Bank Deposit that moves the payment from Undeposited Funds + * to the customer's bank account, deducting Stripe fees. + * This makes the deposit amount match the actual bank transaction. + */ + private async handleBankDepositFlow( + parsedPaymentSucceedResource: { data: { id: string; invoiceId: string; feeAmount: { paidByPlatform: number; paidByClient: number } | null; createdAt: string } }, + qbTokenInfo: IntuitAPITokensType, + invoice: { number: string }, + paymentService: PaymentService, + ) { + const feeAmount = parsedPaymentSucceedResource.data.feeAmount + if (!feeAmount) + throw new APIError(httpStatus.BAD_REQUEST, 'Fee amount is not found') + + // Look up the QBO Payment ID from the sync log (created by webhookInvoicePaid) + const syncLogService = new SyncLogService(this.user) + const paidSyncLog = await syncLogService.getOneByCopilotIdAndEventType({ + copilotId: parsedPaymentSucceedResource.data.invoiceId, + eventType: EventType.PAID, + entityType: EntityType.INVOICE, + }) + + if (!paidSyncLog?.quickbooksId) { + throw new APIError( + httpStatus.NOT_FOUND, + `QBO Payment not found in sync log for invoice: ${parsedPaymentSucceedResource.data.invoiceId}. The invoice.paid event may not have been processed yet.`, + ) + } + + const qbPaymentId = paidSyncLog.quickbooksId + const grossAmount = Number(paidSyncLog.amount) / 100 + const platformFee = feeAmount.paidByPlatform / 100 + + // Get or verify account refs + const intuitApi = new IntuitAPI(qbTokenInfo) + const tokenService = new TokenService(this.user) + + const expenseAccountRef = await tokenService.checkAndUpdateAccountStatus( + AccountTypeObj.Expense, + qbTokenInfo.intuitRealmId, + intuitApi, + qbTokenInfo.expenseAccountRef, + ) + + const bankAccountRef = qbTokenInfo.bankAccountRef + if (!bankAccountRef) { + throw new APIError( + httpStatus.BAD_REQUEST, + 'Bank account ref is not configured. Please select a bank account in the QuickBooks integration settings.', + ) + } + + await paymentService.createBankDepositForPayment(intuitApi, { + qbPaymentId, + grossAmount, + feeAmount: platformFee, + bankAccountRef, + expenseAccountRef, + txnDate: parsedPaymentSucceedResource.data.createdAt.split('T')[0], + invoiceNumber: invoice.number, + paymentId: parsedPaymentSucceedResource.data.id, + }) + } } diff --git a/src/cmd/renameQbAccount/renameQbAccount.service.ts b/src/cmd/renameQbAccount/renameQbAccount.service.ts index e9d16847..b2bcf08a 100644 --- a/src/cmd/renameQbAccount/renameQbAccount.service.ts +++ b/src/cmd/renameQbAccount/renameQbAccount.service.ts @@ -161,6 +161,8 @@ export class RenameQbAccountService extends BaseService { assetAccountRef: portal.assetAccountRef, serviceItemRef: portal.serviceItemRef, clientFeeRef: portal.clientFeeRef, + undepositedFundsAccountRef: portal.undepositedFundsAccountRef, + bankAccountRef: portal.bankAccountRef, } } } diff --git a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx index 0eb8701f..5c93dccd 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -28,11 +28,31 @@ export default function InvoiceDetail({ label="Add absorbed fees to an Expense Account in QuickBooks" description="Record Assembly processing fees as expenses in the 'Assembly Processing Fees' expense account in QuickBooks." checked={settingState.absorbedFeeFlag} - onChange={() => - changeSettings('absorbedFeeFlag', !settingState.absorbedFeeFlag) - } + onChange={() => { + const newValue = !settingState.absorbedFeeFlag + changeSettings('absorbedFeeFlag', newValue) + // Turn off bank deposit flag if absorbed fees is being disabled + if (!newValue && settingState.bankDepositFeeFlag) { + changeSettings('bankDepositFeeFlag', false) + } + }} /> + {settingState.absorbedFeeFlag && ( +
+ + changeSettings( + 'bankDepositFeeFlag', + !settingState.bankDepositFeeFlag, + ) + } + /> +
+ )}
statement-breakpoint +ALTER TABLE "qb_portal_connections" ADD COLUMN "bank_account_ref" varchar(100);--> statement-breakpoint +ALTER TABLE "qb_settings" ADD COLUMN "bank_deposit_fee_flag" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/20260415184949_snapshot.json b/src/db/migrations/meta/20260415184949_snapshot.json new file mode 100644 index 00000000..49cf2656 --- /dev/null +++ b/src/db/migrations/meta/20260415184949_snapshot.json @@ -0,0 +1,1001 @@ +{ + "id": "2140b645-01c8-4153-b18f-eb1bf7ae6c5d", + "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 + }, + "undeposited_funds_account_ref": { + "name": "undeposited_funds_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "bank_account_ref": { + "name": "bank_account_ref", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_suspended": { + "name": "is_suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_qb_portal_connections_portal_id_idx": { + "name": "uq_qb_portal_connections_portal_id_idx", + "columns": [ + { + "expression": "portal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_product_sync": { + "name": "qb_product_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "bank_deposit_fee_flag": { + "name": "bank_deposit_fee_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "company_name_flag": { + "name": "company_name_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "create_new_product_flag": { + "name": "create_new_product_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_invoice_setting_map": { + "name": "initial_invoice_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "initial_product_setting_map": { + "name": "initial_product_setting_map", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sync_flag": { + "name": "sync_flag", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "qb_settings_portal_id_qb_portal_connections_portal_id_fk": { + "name": "qb_settings_portal_id_qb_portal_connections_portal_id_fk", + "tableFrom": "qb_settings", + "tableTo": "qb_portal_connections", + "columnsFrom": [ + "portal_id" + ], + "columnsTo": [ + "portal_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.qb_sync_logs": { + "name": "qb_sync_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "portal_id": { + "name": "portal_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "entity_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invoice'" + }, + "event_type": { + "name": "event_type", + "type": "event_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "status": { + "name": "status", + "type": "log_statuses", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'success'" + }, + "sync_at": { + "name": "sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "copilot_id": { + "name": "copilot_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "quickbooks_id": { + "name": "quickbooks_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "invoice_number": { + "name": "invoice_number", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "remark": { + "name": "remark", + "type": "varchar(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/schema/qbPortalConnections.ts b/src/db/schema/qbPortalConnections.ts index b35e4ed0..a35882ec 100644 --- a/src/db/schema/qbPortalConnections.ts +++ b/src/db/schema/qbPortalConnections.ts @@ -28,6 +28,10 @@ export const QBPortalConnection = table( .notNull(), clientFeeRef: t.varchar('client_fee_ref', { length: 100 }), serviceItemRef: t.varchar('service_item_ref', { length: 100 }), + undepositedFundsAccountRef: t.varchar('undeposited_funds_account_ref', { + length: 100, + }), + bankAccountRef: t.varchar('bank_account_ref', { length: 100 }), isSuspended: t.boolean('is_suspended').notNull().default(false), ...timestamps, }, diff --git a/src/db/schema/qbSettings.ts b/src/db/schema/qbSettings.ts index 2d2d580b..18eca40f 100644 --- a/src/db/schema/qbSettings.ts +++ b/src/db/schema/qbSettings.ts @@ -15,6 +15,10 @@ export const QBSetting = table('qb_settings', { .references(() => QBPortalConnection.portalId, { onDelete: 'cascade' }) .notNull(), absorbedFeeFlag: t.boolean('absorbed_fee_flag').default(false).notNull(), + bankDepositFeeFlag: t + .boolean('bank_deposit_fee_flag') + .default(false) + .notNull(), useCompanyNameFlag: t.boolean('company_name_flag').default(false).notNull(), createNewProductFlag: t .boolean('create_new_product_flag') diff --git a/src/db/service/token.service.ts b/src/db/service/token.service.ts index 5f55d9c1..c4643b17 100644 --- a/src/db/service/token.service.ts +++ b/src/db/service/token.service.ts @@ -73,5 +73,7 @@ export const getPortalTokens = async ( assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, + undepositedFundsAccountRef: portalConnection.undepositedFundsAccountRef, + bankAccountRef: portalConnection.bankAccountRef, } } diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index a070d20a..1606a20d 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -509,6 +509,7 @@ export const useMapItem = ( export const useInvoiceDetailSettings = () => { const initialInvoiceSetting = { absorbedFeeFlag: false, + bankDepositFeeFlag: false, useCompanyNameFlag: false, } const { token, setAppParams } = useApp() diff --git a/src/type/common.ts b/src/type/common.ts index 434a1a06..899f698d 100644 --- a/src/type/common.ts +++ b/src/type/common.ts @@ -276,6 +276,7 @@ export const SettingRequestSchema = z id: z.string().optional(), type: z.nativeEnum(SettingType), absorbedFeeFlag: z.boolean().optional(), + bankDepositFeeFlag: z.boolean().optional(), useCompanyNameFlag: z.boolean().optional(), createNewProductFlag: z.boolean().optional(), }) @@ -288,6 +289,13 @@ export const SettingRequestSchema = z message: 'absorbedFeeFlag is required when type is invoice', }) } + if (typeof val.bankDepositFeeFlag !== 'boolean') { + ctx.addIssue({ + path: ['bankDepositFeeFlag'], + code: z.ZodIssueCode.custom, + message: 'bankDepositFeeFlag is required when type is invoice', + }) + } if (typeof val.useCompanyNameFlag !== 'boolean') { ctx.addIssue({ path: ['useCompanyNameFlag'], @@ -310,7 +318,10 @@ export const SettingRequestSchema = z export type SettingRequestType = z.infer export type InvoiceSettingType = Required< - Pick + Pick< + SettingRequestType, + 'absorbedFeeFlag' | 'bankDepositFeeFlag' | 'useCompanyNameFlag' + > > & { id?: string } export type ProductSettingType = Required< diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 2082df2d..4e176f29 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -113,6 +113,11 @@ export const QBPaymentCreatePayloadSchema = z.object({ CustomerRef: z.object({ value: z.string(), }), + DepositToAccountRef: z + .object({ + value: z.string(), + }) + .optional(), Line: z.array( z.object({ Amount: z.number(), @@ -194,6 +199,38 @@ export type QBPurchaseCreatePayloadType = z.infer< typeof QBPurchaseCreatePayloadSchema > +export const QBDepositLineSchema = z.union([ + z.object({ + Amount: z.number(), + LinkedTxn: z.array( + z.object({ + TxnId: z.string(), + TxnType: z.literal('Payment'), + }), + ), + }), + z.object({ + Amount: z.number(), + DetailType: z.literal('DepositLineDetail'), + DepositLineDetail: z.object({ + AccountRef: QBNameValueSchema, + }), + Description: z.string().optional(), + }), +]) + +export const QBDepositCreatePayloadSchema = z.object({ + DepositToAccountRef: z.object({ + value: z.string(), + }), + TxnDate: z.string(), + Line: z.array(QBDepositLineSchema), +}) + +export type QBDepositCreatePayloadType = z.infer< + typeof QBDepositCreatePayloadSchema +> + export const QBDeletePayloadSchema = z.object({ SyncToken: z.string(), Id: z.string(), diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 1bfdc850..097898f1 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -13,6 +13,7 @@ import { QBPaymentCreatePayloadType, QBAccountCreatePayloadType, QBPurchaseCreatePayloadType, + QBDepositCreatePayloadType, QBDeletePayloadType, QBDestructiveInvoicePayloadSchema, QBNameValueSchemaType, @@ -43,6 +44,8 @@ export type IntuitAPITokensType = Pick< | 'assetAccountRef' | 'serviceItemRef' | 'clientFeeRef' + | 'undepositedFundsAccountRef' + | 'bankAccountRef' > & { isSuspended?: boolean } export type BaseResponseType = { @@ -810,6 +813,36 @@ export default class IntuitAPI { return purchase } + async _createDeposit(payload: QBDepositCreatePayloadType) { + CustomLogger.info({ + obj: { payload }, + message: `IntuitAPI#createDeposit | Deposit create start for realmId: ${this.tokens.intuitRealmId}.`, + }) + const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/deposit?minorversion=${intuitApiMinorVersion}` + const deposit = await this.postFetchWithHeaders(url, payload) + + if (!deposit) + throw new APIError( + httpStatus.BAD_REQUEST, + 'IntuitAPI#createDeposit | message = no response', + ) + + if (deposit?.Fault) { + CustomLogger.error({ obj: deposit.Fault?.Error, message: 'Error: ' }) + throw new APIError( + deposit.Fault?.Error?.code || httpStatus.BAD_REQUEST, + `${IntuitAPIErrorMessage}createDeposit`, + deposit.Fault?.Error, + ) + } + + CustomLogger.info({ + obj: { response: deposit.Deposit }, + message: `IntuitAPI#createDeposit | Deposit created with Id = ${deposit.Deposit?.Id}.`, + }) + return deposit + } + async _deletePurchase(payload: QBDeletePayloadType) { CustomLogger.info({ obj: { payload }, @@ -941,6 +974,7 @@ export default class IntuitAPI { createAccount = this.wrapWithRetry(this._createAccount) updateAccount = this.wrapWithRetry(this._updateAccount) createPurchase = this.wrapWithRetry(this._createPurchase) + createDeposit = this.wrapWithRetry(this._createDeposit) deletePayment = this.wrapWithRetry(this._deletePayment) deletePurchase = this.wrapWithRetry(this._deletePurchase) getCompanyInfo = this.wrapWithRetry(this._getCompanyInfo) From c3646b16da9f88639c04bafcc5178ccc78aa446d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Apr 2026 19:28:38 +0545 Subject: [PATCH 02/10] fix(OUT-3604): backend fixes for bank deposit flow and bank account selector - Add GET endpoint for QBO bank accounts (/setting/bank-account) - Consolidate bankAccountRef write into settings POST endpoint (single API call) - Fix missing undepositedFundsAccountRef/bankAccountRef in token refresh and action - Fix deposit payload: add required TxnLineId to LinkedTxn - Fix Undeposited Funds lookup: query by AccountSubType instead of getOrCreate - Replace inline type with PaymentSucceededResponseType in handleBankDepositFlow - Add Sentry breadcrumbs and structured logging to bank deposit flow - Move deposit log after API call, use CustomLogger - Make PrivateNote optional in deposit schema - Add bankAccountRef to SettingRequestSchema and InvoiceSettingType - Add design doc for IntuitAPI token refresh (future ticket) Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/bank-deposit-flow.md | 91 +++++++++++++++++++ docs/intuit-api-token-refresh.md | 49 ++++++++++ src/action/quickbooks.action.ts | 2 + .../api/quickbooks/payment/payment.service.ts | 42 +++++++-- .../quickbooks/product/product.controller.ts | 2 +- .../bank-account/bank-account.controller.ts | 21 +++++ .../quickbooks/setting/bank-account/route.ts | 4 + .../quickbooks/setting/setting.controller.ts | 30 +++++- src/app/api/quickbooks/token/token.service.ts | 12 +-- .../api/quickbooks/webhook/webhook.service.ts | 34 +++++-- ...17071636_add_bank_deposite_fee_column.sql} | 1 + ...shot.json => 20260417071636_snapshot.json} | 19 ++-- src/db/migrations/meta/_journal.json | 7 ++ src/type/common.ts | 3 +- src/type/dto/intuitAPI.dto.ts | 2 + src/utils/tokenRefresh.ts | 4 + 16 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 docs/bank-deposit-flow.md create mode 100644 docs/intuit-api-token-refresh.md create mode 100644 src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts create mode 100644 src/app/api/quickbooks/setting/bank-account/route.ts rename src/db/migrations/{20260415184949_add_bank_deposit_fee_columns.sql => 20260417071636_add_bank_deposite_fee_column.sql} (79%) rename src/db/migrations/meta/{20260415184949_snapshot.json => 20260417071636_snapshot.json} (98%) diff --git a/docs/bank-deposit-flow.md b/docs/bank-deposit-flow.md new file mode 100644 index 00000000..4766ab32 --- /dev/null +++ b/docs/bank-deposit-flow.md @@ -0,0 +1,91 @@ +# Bank Deposit Flow (OUT-3604) + +## Webhook-to-QBO Flow + +```mermaid +sequenceDiagram + participant Stripe + participant Assembly as Assembly/Copilot + participant App as QB Sync App + participant DB as Database + participant QBO as QuickBooks Online + + Note over Stripe,QBO: Step 1: Invoice Paid + + Stripe->>Assembly: Payment captured + Assembly->>App: Webhook: invoice.paid + App->>DB: Check settings (absorbedFeeFlag, bankDepositFeeFlag) + + alt bankDepositFeeFlag ON + App->>DB: Look up undepositedFundsAccountRef (cached) + alt Not cached + App->>QBO: Query Undeposited Funds account + QBO-->>App: Account ID + App->>DB: Cache undepositedFundsAccountRef + end + App->>QBO: Create Payment ($100)
LinkedTxn → Invoice
DepositToAccountRef → Undeposited Funds + else bankDepositFeeFlag OFF (legacy) + App->>QBO: Create Payment ($100)
LinkedTxn → Invoice
No DepositToAccountRef (QBO default) + end + + QBO-->>App: Payment created (QBO Payment ID) + App->>DB: Save sync log (EventType.PAID, quickbooksId = QBO Payment ID) + + Note over Stripe,QBO: Step 2: Payment Succeeded (fee info available) + + Assembly->>App: Webhook: payment.succeeded
feeAmount: { paidByPlatform: $2.90 } + App->>DB: Check settings (absorbedFeeFlag, bankDepositFeeFlag) + + alt bankDepositFeeFlag ON (Bank Deposit flow) + App->>DB: Look up sync log (EventType.PAID) → get QBO Payment ID + App->>DB: Look up bankAccountRef, expenseAccountRef + App->>QBO: Create Bank Deposit
Line 1: +$100 (LinkedTxn → Payment)
Line 2: -$2.90 (Stripe fee → Expense Account)
DepositToAccountRef → Bank Account
Net deposit = $97.10 + QBO-->>App: Deposit created + App->>DB: Save sync log (EventType.DEPOSITED) + else bankDepositFeeFlag OFF (legacy flow) + App->>QBO: Create Purchase (Expense) for $2.90 + QBO-->>App: Purchase created + App->>DB: Save sync log (EventType.SUCCEEDED) + end + + Note over QBO: Bank Feed Auto-Reconciliation + Note over QBO: Bank Deposit ($97.10) matches
real Stripe deposit ($97.10) +``` + +## QBO Entity Relationships + +```mermaid +graph LR + INV[Invoice
$100.00] -->|LinkedTxn| PAY[Payment
$100.00
→ Undeposited Funds] + PAY -->|LinkedTxn| DEP[Bank Deposit] + DEP -->|Line 1| GROSS[+$100.00
from Payment] + DEP -->|Line 2| FEE[-$2.90
Stripe Fee → Expense Account] + DEP -->|DepositToAccountRef| BANK[Bank Account
Net: $97.10] + + style INV fill:#4a90d9,color:#fff + style PAY fill:#f5a623,color:#fff + style DEP fill:#7ed321,color:#fff + style BANK fill:#50e3c2,color:#fff +``` + +## Old vs New Flow Comparison + +```mermaid +graph TD + subgraph OLD["Old Flow (bankDepositFeeFlag OFF)"] + O_INV[Invoice $100] -->|invoice.paid| O_PAY[Payment $100
→ QBO Default Account] + O_STRIPE[Bank Feed: $97.10] -.->|No match| O_PAY + O_FEE[payment.succeeded] -->|Separate expense| O_EXP[Purchase $2.90] + end + + subgraph NEW["New Flow (bankDepositFeeFlag ON)"] + N_INV[Invoice $100] -->|invoice.paid| N_PAY[Payment $100
→ Undeposited Funds] + N_PAY -->|payment.succeeded| N_DEP[Bank Deposit
+$100 - $2.90 = $97.10
→ Bank Account] + N_STRIPE[Bank Feed: $97.10] -.->|Auto-match| N_DEP + end + + style O_STRIPE fill:#e74c3c,color:#fff + style N_STRIPE fill:#2ecc71,color:#fff + style OLD fill:#fff3f3 + style NEW fill:#f0fff0 +``` diff --git a/docs/intuit-api-token-refresh.md b/docs/intuit-api-token-refresh.md new file mode 100644 index 00000000..c06838a8 --- /dev/null +++ b/docs/intuit-api-token-refresh.md @@ -0,0 +1,49 @@ +# Intuit API: Auto Token Refresh on 401 + +## Problem + +The `IntuitAPI` class sets the access token once at construction and never updates it. +If the token expires mid-request or between construction and usage, the Intuit API +returns HTTP 401. The current code has three issues: + +1. **`fetch.helper.ts`** — `getFetcher`/`postFetcher` do not check `response.ok`. + A 401 response is parsed as JSON and passed through as if it were valid data. + +2. **Silent null propagation** — When `customQuery` receives a 401 error body, it + often lacks a `QueryResponse` key, so it returns `undefined`. Callers like + `getAnAccount` treat this as "account not found" and return `null`, which triggers + the restore/create path instead of surfacing the real auth error. + +3. **No 401 retry** — `withRetry` (pRetry wrapper) only retries on HTTP 429 + (rate limit). Expired token errors are not retried or refreshed. + +## Solution + +Instead of throwing on non-200, we refresh the access token and retry the request. +This is handled inside `IntuitAPI`'s two HTTP methods (`getFetchWithHeader` and +`postFetchWithHeaders`), keeping it transparent to all callers. + +### Changes + +1. **Add optional `portalId` to `IntuitAPI` constructor** — needed by + `getRefreshedQbTokenInfo()` to look up the refresh token and persist new tokens. + +2. **Replace usage of `fetch.helper` in IntuitAPI with direct `fetch`** — so we + can inspect `response.status` before parsing JSON. + +3. **On HTTP 401: refresh token and retry once** — call `getRefreshedQbTokenInfo`, + update `this.tokens` and `this.headers`, retry the original request. Only retry + once to avoid infinite loops. + +4. **On other non-200 (not 401): throw immediately** — surface the real error + instead of silently returning null. + +5. **No change to `fetch.helper.ts`** — it is used by frontend SWR and other code, + so we avoid breaking those callers. + +### Why handle it in `getFetchWithHeader`/`postFetchWithHeaders`? + +- All IntuitAPI methods go through these two functions. +- Keeps retry + refresh logic in one place. +- Callers (services, controllers) don't need any changes. +- The `portalId` is already available in every calling context. diff --git a/src/action/quickbooks.action.ts b/src/action/quickbooks.action.ts index 60bf0770..9d994fdb 100644 --- a/src/action/quickbooks.action.ts +++ b/src/action/quickbooks.action.ts @@ -65,6 +65,8 @@ export async function checkForNonUsCompany(portalId: string) { assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, + undepositedFundsAccountRef: portalConnection.undepositedFundsAccountRef, + bankAccountRef: portalConnection.bankAccountRef, } const intuitApi = new IntuitAPI(tokenInfo) diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index 5b591dca..02d02555 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -31,6 +31,7 @@ import { import { PaymentSucceededResponseType } from '@/type/dto/webhook.dto' import { getMessageAndCodeFromError } from '@/utils/error' import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' +import CustomLogger from '@/utils/logger' import { getDeletedAtForAuthAccountCategoryLog, getCategory, @@ -206,16 +207,26 @@ export class PaymentService extends BaseService { paymentId: string }, ): Promise { - const netAmount = opts.grossAmount - opts.feeAmount + addSyncBreadcrumb('Creating bank deposit in QBO', { + invoiceNumber: opts.invoiceNumber, + qbPaymentId: opts.qbPaymentId, + grossAmount: opts.grossAmount, + feeAmount: opts.feeAmount, + }) const depositPayload: QBDepositCreatePayloadType = { DepositToAccountRef: { value: opts.bankAccountRef }, + PrivateNote: `Payout for invoice number: ${opts.invoiceNumber}`, TxnDate: opts.txnDate, Line: [ { Amount: opts.grossAmount, LinkedTxn: [ - { TxnId: opts.qbPaymentId, TxnType: 'Payment' as const }, + { + TxnId: opts.qbPaymentId, + TxnType: 'Payment' as const, + TxnLineId: '0', + }, ], }, { @@ -224,18 +235,28 @@ export class PaymentService extends BaseService { DepositLineDetail: { AccountRef: { value: opts.expenseAccountRef }, }, - Description: 'Stripe processing fee', + Description: 'Assembly processing fees', }, ], } const parsedPayload = QBDepositCreatePayloadSchema.parse(depositPayload) + const res = await intuitApi.createDeposit(parsedPayload) - console.info( - `PaymentService#createBankDepositForPayment | Creating bank deposit: gross=${opts.grossAmount}, fee=${opts.feeAmount}, net=${netAmount}`, - ) + CustomLogger.info({ + obj: { + depositId: res.Deposit?.Id, + grossAmount: opts.grossAmount, + feeAmount: opts.feeAmount, + netAmount: opts.grossAmount - opts.feeAmount, + }, + message: `PaymentService#createBankDepositForPayment | Bank deposit created for invoice ${opts.invoiceNumber}`, + }) - const res = await intuitApi.createDeposit(parsedPayload) + addSyncBreadcrumb('Bank deposit created in QBO', { + depositId: res.Deposit?.Id, + invoiceNumber: opts.invoiceNumber, + }) try { await this.logSync( @@ -255,9 +276,10 @@ export class PaymentService extends BaseService { }, ) } catch (error: unknown) { - console.error( - 'PaymentService#createBankDepositForPayment | Failed to log sync, but deposit was created in QBO', - ) + CustomLogger.error({ + obj: error, + message: `PaymentService#createBankDepositForPayment | Failed to log sync for deposit ${res.Deposit?.Id}, but deposit was created in QBO`, + }) throw error } } diff --git a/src/app/api/quickbooks/product/product.controller.ts b/src/app/api/quickbooks/product/product.controller.ts index 1d797e19..91f837e3 100644 --- a/src/app/api/quickbooks/product/product.controller.ts +++ b/src/app/api/quickbooks/product/product.controller.ts @@ -10,7 +10,7 @@ export async function getFlattenProducts(req: NextRequest) { const productService = new ProductService(user) const searchParams = req.nextUrl.searchParams const nextToken = searchParams.get('nextToken') || undefined - const limit = Number(searchParams.get('limit')) || MAX_PRODUCT_LIST_LIMIT + const limit = Number(searchParams.get('limit')) || 1 const products = await productService.getFlattenProductList(limit, nextToken) return NextResponse.json(products) } diff --git a/src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts b/src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts new file mode 100644 index 00000000..aba55cbd --- /dev/null +++ b/src/app/api/quickbooks/setting/bank-account/bank-account.controller.ts @@ -0,0 +1,21 @@ +import authenticate from '@/app/api/core/utils/authenticate' +import { AuthService } from '@/app/api/quickbooks/auth/auth.service' +import IntuitAPI from '@/utils/intuitAPI' +import { NextRequest, NextResponse } from 'next/server' + +export async function getBankAccounts(req: NextRequest) { + const user = await authenticate(req) + const authService = new AuthService(user) + const qbTokenInfo = await authService.getQBPortalConnection( + user.workspaceId, + true, + ) + if (!qbTokenInfo || !qbTokenInfo.accessToken) { + throw new Error('Tokens expired. Reauthorization required.') + } + const intuitApi = new IntuitAPI(qbTokenInfo) + const result = await intuitApi.customQuery( + `SELECT Id, Name FROM Account WHERE AccountType = 'Bank' AND Active = true`, + ) + return NextResponse.json({ accounts: result?.Account || [] }) +} diff --git a/src/app/api/quickbooks/setting/bank-account/route.ts b/src/app/api/quickbooks/setting/bank-account/route.ts new file mode 100644 index 00000000..78c06f9f --- /dev/null +++ b/src/app/api/quickbooks/setting/bank-account/route.ts @@ -0,0 +1,4 @@ +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' +import { getBankAccounts } from '@/app/api/quickbooks/setting/bank-account/bank-account.controller' + +export const GET = withErrorHandler(getBankAccounts) diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index 9d01e4b2..b2336756 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,6 +1,9 @@ import authenticate from '@/app/api/core/utils/authenticate' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { QBPortalConnection } from '@/db/schema/qbPortalConnections' import { QBSetting, QBSettingsUpdateSchemaType } from '@/db/schema/qbSettings' +import { getPortalConnection } from '@/db/service/token.service' import { eq } from 'drizzle-orm' import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' @@ -31,7 +34,14 @@ export async function getSettings(req: NextRequest) { returningFields.push('createNewProductFlag') } const setting = await settingService.getOneByPortalId(returningFields) - return NextResponse.json({ setting }) + + let bankAccountRef: string | null = null + if (parsedType.success && parsedType.data === SettingType.INVOICE) { + const portalConnection = await getPortalConnection(user.workspaceId) + bankAccountRef = portalConnection?.bankAccountRef || null + } + + return NextResponse.json({ setting, bankAccountRef }) } export async function updateSettings(req: NextRequest) { @@ -43,8 +53,11 @@ export async function updateSettings(req: NextRequest) { const parsedType = z.nativeEnum(SettingType).parse(type) + const parsed = SettingRequestSchema.parse(body) + const { bankAccountRef, ...settingFields } = parsed + const payload = { - ...SettingRequestSchema.parse(body), + ...settingFields, ...(parsedType === SettingType.INVOICE ? { initialInvoiceSettingMap: true } : { initialProductSettingMap: true }), @@ -53,5 +66,18 @@ export async function updateSettings(req: NextRequest) { payload, eq(QBSetting.portalId, user.workspaceId), ) + + // Write bankAccountRef to qb_portal_connections (separate table) + if ( + parsedType === SettingType.INVOICE && + typeof bankAccountRef !== 'undefined' + ) { + const tokenService = new TokenService(user) + await tokenService.updateQBPortalConnection( + { bankAccountRef }, + eq(QBPortalConnection.portalId, user.workspaceId), + ) + } + return NextResponse.json({ setting }, { status: httpStatus.CREATED }) } diff --git a/src/app/api/quickbooks/token/token.service.ts b/src/app/api/quickbooks/token/token.service.ts index 9663b3d6..345559bb 100644 --- a/src/app/api/quickbooks/token/token.service.ts +++ b/src/app/api/quickbooks/token/token.service.ts @@ -269,18 +269,18 @@ export class TokenService extends BaseService { return assetAccRef.Id } - private async getUndepositedFundsAccountRef( + private async getUndepositedFundsAccRef( intuitApi: IntuitAPI, ): Promise { - // "Undeposited Funds" is a system account in every QBO company. - // Look up by subtype first (more reliable than name if user renamed it). + // QBO enforces exactly one UndepositedFunds account per company — cannot create a second. + // Look up by subtype first (most reliable, works even if user renamed the account). const query = `SELECT Id FROM Account WHERE AccountSubType = 'UndepositedFunds' AND Active = true maxresults 1` const result = await intuitApi.customQuery(query) if (result?.Account?.[0]?.Id) { return result.Account[0].Id } - // Fallback: try by name + // Fallback: try by default name const byName = await intuitApi.getAnAccount('Undeposited Funds') if (byName?.Id) { return byName.Id @@ -288,7 +288,7 @@ export class TokenService extends BaseService { throw new APIError( httpStatus.INTERNAL_SERVER_ERROR, - 'TokenService#getUndepositedFundsAccountRef | Undeposited Funds account not found in QuickBooks', + 'TokenService#getUndepositedFundsAccRef | Undeposited Funds account not found in QuickBooks', ) } @@ -304,7 +304,7 @@ export class TokenService extends BaseService { case AccountTypeObj.Asset: return this.getOrCreateAssetAccountRef(intuitApi) case AccountTypeObj.UndepositedFunds: - return this.getUndepositedFundsAccountRef(intuitApi) + return this.getUndepositedFundsAccRef(intuitApi) default: throw new APIError( httpStatus.BAD_REQUEST, diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 2b77e4f6..082d4ae7 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -14,6 +14,7 @@ import { InvoiceDeletedResponseSchema, InvoiceResponseSchema, PaymentSucceededResponseSchema, + PaymentSucceededResponseType, PriceCreatedResponseSchema, ProductUpdatedResponseSchema, WebhookEventResponseSchema, @@ -398,7 +399,7 @@ export class WebhookService extends BaseService { payload: unknown, qbTokenInfo: IntuitAPITokensType, ) { - await sleep(7000) // Payment succeed event can sometimes trigger before invoice created. + await sleep(20000) // Payment succeed event can sometimes trigger before invoice created. console.info('###### PAYMENT SUCCEEDED ######') const parsedPaymentSucceed = @@ -511,11 +512,21 @@ export class WebhookService extends BaseService { * This makes the deposit amount match the actual bank transaction. */ private async handleBankDepositFlow( - parsedPaymentSucceedResource: { data: { id: string; invoiceId: string; feeAmount: { paidByPlatform: number; paidByClient: number } | null; createdAt: string } }, + parsedPaymentSucceedResource: PaymentSucceededResponseType, qbTokenInfo: IntuitAPITokensType, invoice: { number: string }, paymentService: PaymentService, ) { + const paymentId = parsedPaymentSucceedResource.data.id + const invoiceId = parsedPaymentSucceedResource.data.invoiceId + + addSyncBreadcrumb('Bank deposit flow started', { + paymentId, + invoiceId, + invoiceNumber: invoice.number, + portalId: this.user.workspaceId, + }) + const feeAmount = parsedPaymentSucceedResource.data.feeAmount if (!feeAmount) throw new APIError(httpStatus.BAD_REQUEST, 'Fee amount is not found') @@ -523,7 +534,7 @@ export class WebhookService extends BaseService { // Look up the QBO Payment ID from the sync log (created by webhookInvoicePaid) const syncLogService = new SyncLogService(this.user) const paidSyncLog = await syncLogService.getOneByCopilotIdAndEventType({ - copilotId: parsedPaymentSucceedResource.data.invoiceId, + copilotId: invoiceId, eventType: EventType.PAID, entityType: EntityType.INVOICE, }) @@ -531,7 +542,7 @@ export class WebhookService extends BaseService { if (!paidSyncLog?.quickbooksId) { throw new APIError( httpStatus.NOT_FOUND, - `QBO Payment not found in sync log for invoice: ${parsedPaymentSucceedResource.data.invoiceId}. The invoice.paid event may not have been processed yet.`, + `QBO Payment not found in sync log for invoice: ${invoiceId}. The invoice.paid event may not have been processed yet.`, ) } @@ -539,6 +550,12 @@ export class WebhookService extends BaseService { const grossAmount = Number(paidSyncLog.amount) / 100 const platformFee = feeAmount.paidByPlatform / 100 + addSyncBreadcrumb('Bank deposit payment resolved', { + qbPaymentId, + grossAmount, + platformFee, + }) + // Get or verify account refs const intuitApi = new IntuitAPI(qbTokenInfo) const tokenService = new TokenService(this.user) @@ -554,10 +571,15 @@ export class WebhookService extends BaseService { if (!bankAccountRef) { throw new APIError( httpStatus.BAD_REQUEST, - 'Bank account ref is not configured. Please select a bank account in the QuickBooks integration settings.', + `Bank account ref is not configured for portal ${this.user.workspaceId}. Please select a bank account in the QuickBooks integration settings.`, ) } + addSyncBreadcrumb('Bank deposit account refs verified', { + expenseAccountRef, + bankAccountRef, + }) + await paymentService.createBankDepositForPayment(intuitApi, { qbPaymentId, grossAmount, @@ -566,7 +588,7 @@ export class WebhookService extends BaseService { expenseAccountRef, txnDate: parsedPaymentSucceedResource.data.createdAt.split('T')[0], invoiceNumber: invoice.number, - paymentId: parsedPaymentSucceedResource.data.id, + paymentId, }) } } diff --git a/src/db/migrations/20260415184949_add_bank_deposit_fee_columns.sql b/src/db/migrations/20260417071636_add_bank_deposite_fee_column.sql similarity index 79% rename from src/db/migrations/20260415184949_add_bank_deposit_fee_columns.sql rename to src/db/migrations/20260417071636_add_bank_deposite_fee_column.sql index fabbb9e4..132aa9f8 100644 --- a/src/db/migrations/20260415184949_add_bank_deposit_fee_columns.sql +++ b/src/db/migrations/20260417071636_add_bank_deposite_fee_column.sql @@ -1,3 +1,4 @@ +ALTER TYPE "public"."event_types" ADD VALUE 'deposited';--> statement-breakpoint ALTER TABLE "qb_portal_connections" ADD COLUMN "undeposited_funds_account_ref" varchar(100);--> statement-breakpoint ALTER TABLE "qb_portal_connections" ADD COLUMN "bank_account_ref" varchar(100);--> statement-breakpoint ALTER TABLE "qb_settings" ADD COLUMN "bank_deposit_fee_flag" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/20260415184949_snapshot.json b/src/db/migrations/meta/20260417071636_snapshot.json similarity index 98% rename from src/db/migrations/meta/20260415184949_snapshot.json rename to src/db/migrations/meta/20260417071636_snapshot.json index 49cf2656..aadef47e 100644 --- a/src/db/migrations/meta/20260415184949_snapshot.json +++ b/src/db/migrations/meta/20260417071636_snapshot.json @@ -1,6 +1,6 @@ { - "id": "2140b645-01c8-4153-b18f-eb1bf7ae6c5d", - "prevId": "1f41b71d-f21a-44c6-af6a-4fda7762d670", + "id": "dbeaa706-8b28-4714-9dca-aab6516ff0d5", + "prevId": "1c9fbc27-72b3-4a6e-860c-6e3f247135d5", "version": "7", "dialect": "postgresql", "tables": { @@ -563,7 +563,7 @@ }, "name": { "name": "name", - "type": "varchar(100)", + "type": "varchar", "primaryKey": false, "notNull": false }, @@ -575,7 +575,7 @@ }, "copilot_name": { "name": "copilot_name", - "type": "varchar(100)", + "type": "varchar", "primaryKey": false, "notNull": false }, @@ -822,7 +822,7 @@ }, "remark": { "name": "remark", - "type": "varchar(255)", + "type": "varchar", "primaryKey": false, "notNull": false }, @@ -852,7 +852,7 @@ }, "product_name": { "name": "product_name", - "type": "varchar(100)", + "type": "varchar", "primaryKey": false, "notNull": false }, @@ -966,7 +966,8 @@ "deleted", "succeeded", "mapped", - "unmapped" + "unmapped", + "deposited" ] }, "public.failed_record_category_types": { @@ -975,6 +976,10 @@ "values": [ "auth", "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", "others" ] }, diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index f0fa6a41..ff709873 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1776326817946, "tag": "20260416080657_update_service_name_length_constraint", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1776410196223, + "tag": "20260417071636_add_bank_deposite_fee_column", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/type/common.ts b/src/type/common.ts index 899f698d..61d78bdf 100644 --- a/src/type/common.ts +++ b/src/type/common.ts @@ -277,6 +277,7 @@ export const SettingRequestSchema = z type: z.nativeEnum(SettingType), absorbedFeeFlag: z.boolean().optional(), bankDepositFeeFlag: z.boolean().optional(), + bankAccountRef: z.string().nullable().optional(), useCompanyNameFlag: z.boolean().optional(), createNewProductFlag: z.boolean().optional(), }) @@ -322,7 +323,7 @@ export type InvoiceSettingType = Required< SettingRequestType, 'absorbedFeeFlag' | 'bankDepositFeeFlag' | 'useCompanyNameFlag' > -> & { id?: string } +> & { id?: string; bankAccountRef?: string | null } export type ProductSettingType = Required< Pick diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 4e176f29..6b910ceb 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -206,6 +206,7 @@ export const QBDepositLineSchema = z.union([ z.object({ TxnId: z.string(), TxnType: z.literal('Payment'), + TxnLineId: z.string(), }), ), }), @@ -223,6 +224,7 @@ export const QBDepositCreatePayloadSchema = z.object({ DepositToAccountRef: z.object({ value: z.string(), }), + PrivateNote: z.string().optional(), TxnDate: z.string(), Line: z.array(QBDepositLineSchema), }) diff --git a/src/utils/tokenRefresh.ts b/src/utils/tokenRefresh.ts index 7c14c480..7583e4f4 100644 --- a/src/utils/tokenRefresh.ts +++ b/src/utils/tokenRefresh.ts @@ -40,6 +40,8 @@ export async function getRefreshedQbTokenInfo( assetAccountRef, serviceItemRef, clientFeeRef, + undepositedFundsAccountRef, + bankAccountRef, } = portalConnection CustomLogger.info({ @@ -58,6 +60,8 @@ export async function getRefreshedQbTokenInfo( assetAccountRef, serviceItemRef, clientFeeRef, + undepositedFundsAccountRef, + bankAccountRef, } const updatedPayload: QBPortalConnectionUpdateSchemaType = { From c23c13bac06dd5df0826c24bc165bf2eca297089 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Apr 2026 19:53:18 +0545 Subject: [PATCH 03/10] fix(OUT-3604): atomic settings write and null guard on deposit amount - Wrap qb_settings + qb_portal_connections writes in db.transaction to prevent partial state (bankDepositFeeFlag=true with bankAccountRef=null) - Add null guard on paidSyncLog.amount before division in handleBankDepositFlow - Add invoiceNumber to failed DEPOSITED sync log for future resync support Co-Authored-By: Claude Opus 4.6 (1M context) --- .../quickbooks/setting/setting.controller.ts | 37 ++++++++++++------- .../api/quickbooks/webhook/webhook.service.ts | 28 +++++++++----- 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index b2336756..b1d2ad43 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,6 +1,7 @@ import authenticate from '@/app/api/core/utils/authenticate' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { db } from '@/db' import { QBPortalConnection } from '@/db/schema/qbPortalConnections' import { QBSetting, QBSettingsUpdateSchemaType } from '@/db/schema/qbSettings' import { getPortalConnection } from '@/db/service/token.service' @@ -62,22 +63,30 @@ export async function updateSettings(req: NextRequest) { ? { initialInvoiceSettingMap: true } : { initialProductSettingMap: true }), } - const setting = await settingService.updateQBSettings( - payload, - eq(QBSetting.portalId, user.workspaceId), - ) - // Write bankAccountRef to qb_portal_connections (separate table) - if ( - parsedType === SettingType.INVOICE && - typeof bankAccountRef !== 'undefined' - ) { - const tokenService = new TokenService(user) - await tokenService.updateQBPortalConnection( - { bankAccountRef }, - eq(QBPortalConnection.portalId, user.workspaceId), + const writeBankAccountRef = + parsedType === SettingType.INVOICE && typeof bankAccountRef !== 'undefined' + + // Wrap both writes in a transaction to prevent partial state + // (e.g. bankDepositFeeFlag=true but bankAccountRef=null) + const setting = await db.transaction(async (tx) => { + settingService.setTransaction(tx) + const result = await settingService.updateQBSettings( + payload, + eq(QBSetting.portalId, user.workspaceId), ) - } + + if (writeBankAccountRef) { + const tokenService = new TokenService(user) + tokenService.setTransaction(tx) + await tokenService.updateQBPortalConnection( + { bankAccountRef }, + eq(QBPortalConnection.portalId, user.workspaceId), + ) + } + + return result + }) return NextResponse.json({ setting }, { status: httpStatus.CREATED }) } diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index 082d4ae7..a140733f 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -446,17 +446,19 @@ export class WebhookService extends BaseService { return } - const copilotApp = new CopilotAPI(this.user.token) - const invoice = await copilotApp.getInvoice( - parsedPaymentSucceedResource.data.invoiceId, - ) - if (!invoice) - throw new APIError( - httpStatus.NOT_FOUND, - `Invoice not found in Assembly for invoice id: ${parsedPaymentSucceedResource.data.invoiceId}`, + let invoice: Awaited> + try { + const copilotApp = new CopilotAPI(this.user.token) + invoice = await copilotApp.getInvoice( + parsedPaymentSucceedResource.data.invoiceId, ) - try { + if (!invoice) + throw new APIError( + httpStatus.NOT_FOUND, + `Invoice not found in Assembly for invoice id: ${parsedPaymentSucceedResource.data.invoiceId}`, + ) + validateAccessToken(qbTokenInfo) const paymentService = new PaymentService(this.user) @@ -488,7 +490,7 @@ export class WebhookService extends BaseService { eventType: idempotencyEventType, status: LogStatus.FAILED, copilotId: parsedPaymentSucceedResource.data.id, - invoiceNumber: invoice.number, + invoiceNumber: invoice?.number, feeAmount: feeAmount ? feeAmount.paidByPlatform.toFixed(2) : '0', remark: useBankDepositFlow ? 'Bank deposit with fee deduction' @@ -547,6 +549,12 @@ export class WebhookService extends BaseService { } const qbPaymentId = paidSyncLog.quickbooksId + if (!paidSyncLog.amount) { + throw new APIError( + httpStatus.INTERNAL_SERVER_ERROR, + `PAID sync log for invoice ${invoiceId} has no amount recorded`, + ) + } const grossAmount = Number(paidSyncLog.amount) / 100 const platformFee = feeAmount.paidByPlatform / 100 From 801e8183e4fc42416e1bac0865a95188ab575d36 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Apr 2026 19:58:06 +0545 Subject: [PATCH 04/10] fix(OUT-3604): revert max product list limit --- src/app/api/quickbooks/product/product.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/quickbooks/product/product.controller.ts b/src/app/api/quickbooks/product/product.controller.ts index 91f837e3..1d797e19 100644 --- a/src/app/api/quickbooks/product/product.controller.ts +++ b/src/app/api/quickbooks/product/product.controller.ts @@ -10,7 +10,7 @@ export async function getFlattenProducts(req: NextRequest) { const productService = new ProductService(user) const searchParams = req.nextUrl.searchParams const nextToken = searchParams.get('nextToken') || undefined - const limit = Number(searchParams.get('limit')) || 1 + const limit = Number(searchParams.get('limit')) || MAX_PRODUCT_LIST_LIMIT const products = await productService.getFlattenProductList(limit, nextToken) return NextResponse.json(products) } From db794d8956ba0f4ca78a1708cc683fa9bc67b04c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 20 Apr 2026 15:01:39 +0545 Subject: [PATCH 05/10] refactor(OUT-3604): remove undepositedFundsAccountRef column and caching Undeposited Funds is a QBO system account that always exists and cannot be deleted. Caching its ID in qb_portal_connections was unnecessary. - Add getUndepositedFundsAccountId() on IntuitAPI (live lookup by subtype) - Call it directly in invoice.service.ts instead of checkAndUpdateAccountStatus - Remove undepositedFundsAccountRef from schema, type, and all plumbing - Remove UndepositedFunds from AccountTypeObj, updateAccountMapping, restoreAccountRef - Reuse single IntuitAPI instance in webhookInvoicePaid - Update migration: drop undeposited_funds_account_ref column addition Co-Authored-By: Claude Opus 4.6 (1M context) --- src/action/quickbooks.action.ts | 1 - src/app/api/quickbooks/auth/auth.service.ts | 5 ---- .../api/quickbooks/invoice/invoice.service.ts | 12 ++------ src/app/api/quickbooks/token/token.service.ts | 28 ------------------- .../renameQbAccount.service.ts | 1 - src/constant/qbConnection.ts | 1 - ...417071636_add_bank_deposite_fee_column.sql | 4 --- ...0420090412_add_bank_deposit_fee_column.sql | 2 ++ ...shot.json => 20260420090412_snapshot.json} | 11 ++------ src/db/migrations/meta/_journal.json | 4 +-- src/db/schema/qbPortalConnections.ts | 3 -- src/db/service/token.service.ts | 1 - src/utils/intuitAPI.ts | 25 ++++++++++++++++- src/utils/tokenRefresh.ts | 2 -- 14 files changed, 33 insertions(+), 67 deletions(-) delete mode 100644 src/db/migrations/20260417071636_add_bank_deposite_fee_column.sql create mode 100644 src/db/migrations/20260420090412_add_bank_deposit_fee_column.sql rename src/db/migrations/meta/{20260417071636_snapshot.json => 20260420090412_snapshot.json} (98%) diff --git a/src/action/quickbooks.action.ts b/src/action/quickbooks.action.ts index 9d994fdb..77f49f72 100644 --- a/src/action/quickbooks.action.ts +++ b/src/action/quickbooks.action.ts @@ -65,7 +65,6 @@ export async function checkForNonUsCompany(portalId: string) { assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, - undepositedFundsAccountRef: portalConnection.undepositedFundsAccountRef, bankAccountRef: portalConnection.bankAccountRef, } diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index 8cdee0f6..28f97aa9 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -139,8 +139,6 @@ export class AuthService extends BaseService { assetAccountRef: insertPayload.assetAccountRef, serviceItemRef: existingToken?.serviceItemRef || null, clientFeeRef: existingToken?.clientFeeRef || null, - undepositedFundsAccountRef: - existingToken?.undepositedFundsAccountRef || null, bankAccountRef: existingToken?.bankAccountRef || null, }) // handle accounts @@ -241,7 +239,6 @@ export class AuthService extends BaseService { setting, serviceItemRef, clientFeeRef, - undepositedFundsAccountRef, bankAccountRef, isSuspended, } = portalQBToken @@ -265,7 +262,6 @@ export class AuthService extends BaseService { assetAccountRef: '', serviceItemRef: '', clientFeeRef: '', - undepositedFundsAccountRef: null, bankAccountRef: null, } @@ -288,7 +284,6 @@ export class AuthService extends BaseService { assetAccountRef, serviceItemRef, clientFeeRef, - undepositedFundsAccountRef, bankAccountRef, } diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index 8012f5ce..d6ba19fb 100644 --- a/src/app/api/quickbooks/invoice/invoice.service.ts +++ b/src/app/api/quickbooks/invoice/invoice.service.ts @@ -897,16 +897,11 @@ export class InvoiceService extends BaseService { const useBankDepositFlow = setting?.absorbedFeeFlag && setting?.bankDepositFeeFlag + const intuitApi = new IntuitAPI(qbTokenInfo) + let depositToAccountRef: { value: string } | undefined if (useBankDepositFlow) { - const tokenService = new TokenService(this.user) - const undepositedFundsRef = - await tokenService.checkAndUpdateAccountStatus( - AccountTypeObj.UndepositedFunds, - qbTokenInfo.intuitRealmId, - new IntuitAPI(qbTokenInfo), - qbTokenInfo.undepositedFundsAccountRef ?? undefined, - ) + const undepositedFundsRef = await intuitApi.getUndepositedFundsAccountId() depositToAccountRef = { value: undepositedFundsRef } } @@ -930,7 +925,6 @@ export class InvoiceService extends BaseService { }, ], } - const intuitApi = new IntuitAPI(qbTokenInfo) const paymentService = new PaymentService(this.user) const customerDisplayName = diff --git a/src/app/api/quickbooks/token/token.service.ts b/src/app/api/quickbooks/token/token.service.ts index 345559bb..da2d9966 100644 --- a/src/app/api/quickbooks/token/token.service.ts +++ b/src/app/api/quickbooks/token/token.service.ts @@ -156,9 +156,6 @@ export class TokenService extends BaseService { case AccountTypeObj.Asset: payload = { assetAccountRef: accountRef } break - case AccountTypeObj.UndepositedFunds: - payload = { undepositedFundsAccountRef: accountRef } - break default: throw new APIError( httpStatus.BAD_REQUEST, @@ -269,29 +266,6 @@ export class TokenService extends BaseService { return assetAccRef.Id } - private async getUndepositedFundsAccRef( - intuitApi: IntuitAPI, - ): Promise { - // QBO enforces exactly one UndepositedFunds account per company — cannot create a second. - // Look up by subtype first (most reliable, works even if user renamed the account). - const query = `SELECT Id FROM Account WHERE AccountSubType = 'UndepositedFunds' AND Active = true maxresults 1` - const result = await intuitApi.customQuery(query) - if (result?.Account?.[0]?.Id) { - return result.Account[0].Id - } - - // Fallback: try by default name - const byName = await intuitApi.getAnAccount('Undeposited Funds') - if (byName?.Id) { - return byName.Id - } - - throw new APIError( - httpStatus.INTERNAL_SERVER_ERROR, - 'TokenService#getUndepositedFundsAccRef | Undeposited Funds account not found in QuickBooks', - ) - } - private async restoreAccountRef( accountType: AccountType, intuitApi: IntuitAPI, @@ -303,8 +277,6 @@ export class TokenService extends BaseService { return this.getOrCreateExpenseAccountRef(intuitApi) case AccountTypeObj.Asset: return this.getOrCreateAssetAccountRef(intuitApi) - case AccountTypeObj.UndepositedFunds: - return this.getUndepositedFundsAccRef(intuitApi) default: throw new APIError( httpStatus.BAD_REQUEST, diff --git a/src/cmd/renameQbAccount/renameQbAccount.service.ts b/src/cmd/renameQbAccount/renameQbAccount.service.ts index b2bcf08a..6afbeb95 100644 --- a/src/cmd/renameQbAccount/renameQbAccount.service.ts +++ b/src/cmd/renameQbAccount/renameQbAccount.service.ts @@ -161,7 +161,6 @@ export class RenameQbAccountService extends BaseService { assetAccountRef: portal.assetAccountRef, serviceItemRef: portal.serviceItemRef, clientFeeRef: portal.clientFeeRef, - undepositedFundsAccountRef: portal.undepositedFundsAccountRef, bankAccountRef: portal.bankAccountRef, } } diff --git a/src/constant/qbConnection.ts b/src/constant/qbConnection.ts index 143c57ff..ca6ce522 100644 --- a/src/constant/qbConnection.ts +++ b/src/constant/qbConnection.ts @@ -2,5 +2,4 @@ export const AccountTypeObj = { Income: 'income', Expense: 'expense', Asset: 'asset', - UndepositedFunds: 'undepositedFunds', } as const diff --git a/src/db/migrations/20260417071636_add_bank_deposite_fee_column.sql b/src/db/migrations/20260417071636_add_bank_deposite_fee_column.sql deleted file mode 100644 index 132aa9f8..00000000 --- a/src/db/migrations/20260417071636_add_bank_deposite_fee_column.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TYPE "public"."event_types" ADD VALUE 'deposited';--> statement-breakpoint -ALTER TABLE "qb_portal_connections" ADD COLUMN "undeposited_funds_account_ref" varchar(100);--> statement-breakpoint -ALTER TABLE "qb_portal_connections" ADD COLUMN "bank_account_ref" varchar(100);--> statement-breakpoint -ALTER TABLE "qb_settings" ADD COLUMN "bank_deposit_fee_flag" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/20260420090412_add_bank_deposit_fee_column.sql b/src/db/migrations/20260420090412_add_bank_deposit_fee_column.sql new file mode 100644 index 00000000..793e8730 --- /dev/null +++ b/src/db/migrations/20260420090412_add_bank_deposit_fee_column.sql @@ -0,0 +1,2 @@ +ALTER TABLE "qb_portal_connections" ADD COLUMN "bank_account_ref" varchar(100);--> statement-breakpoint +ALTER TABLE "qb_settings" ADD COLUMN "bank_deposit_fee_flag" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/20260417071636_snapshot.json b/src/db/migrations/meta/20260420090412_snapshot.json similarity index 98% rename from src/db/migrations/meta/20260417071636_snapshot.json rename to src/db/migrations/meta/20260420090412_snapshot.json index aadef47e..4dabe532 100644 --- a/src/db/migrations/meta/20260417071636_snapshot.json +++ b/src/db/migrations/meta/20260420090412_snapshot.json @@ -1,5 +1,5 @@ { - "id": "dbeaa706-8b28-4714-9dca-aab6516ff0d5", + "id": "9eb854f3-0544-46b9-8b4f-6ad7457aaac9", "prevId": "1c9fbc27-72b3-4a6e-860c-6e3f247135d5", "version": "7", "dialect": "postgresql", @@ -468,12 +468,6 @@ "primaryKey": false, "notNull": false }, - "undeposited_funds_account_ref": { - "name": "undeposited_funds_account_ref", - "type": "varchar(100)", - "primaryKey": false, - "notNull": false - }, "bank_account_ref": { "name": "bank_account_ref", "type": "varchar(100)", @@ -966,8 +960,7 @@ "deleted", "succeeded", "mapped", - "unmapped", - "deposited" + "unmapped" ] }, "public.failed_record_category_types": { diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index ff709873..7aa00afb 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -117,8 +117,8 @@ { "idx": 16, "version": "7", - "when": 1776410196223, - "tag": "20260417071636_add_bank_deposite_fee_column", + "when": 1776675852536, + "tag": "20260420090412_add_bank_deposit_fee_column", "breakpoints": true } ] diff --git a/src/db/schema/qbPortalConnections.ts b/src/db/schema/qbPortalConnections.ts index a35882ec..4176ef03 100644 --- a/src/db/schema/qbPortalConnections.ts +++ b/src/db/schema/qbPortalConnections.ts @@ -28,9 +28,6 @@ export const QBPortalConnection = table( .notNull(), clientFeeRef: t.varchar('client_fee_ref', { length: 100 }), serviceItemRef: t.varchar('service_item_ref', { length: 100 }), - undepositedFundsAccountRef: t.varchar('undeposited_funds_account_ref', { - length: 100, - }), bankAccountRef: t.varchar('bank_account_ref', { length: 100 }), isSuspended: t.boolean('is_suspended').notNull().default(false), ...timestamps, diff --git a/src/db/service/token.service.ts b/src/db/service/token.service.ts index c4643b17..bf781b4f 100644 --- a/src/db/service/token.service.ts +++ b/src/db/service/token.service.ts @@ -73,7 +73,6 @@ export const getPortalTokens = async ( assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, - undepositedFundsAccountRef: portalConnection.undepositedFundsAccountRef, bankAccountRef: portalConnection.bankAccountRef, } } diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 097898f1..ec35fffb 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -44,7 +44,6 @@ export type IntuitAPITokensType = Pick< | 'assetAccountRef' | 'serviceItemRef' | 'clientFeeRef' - | 'undepositedFundsAccountRef' | 'bankAccountRef' > & { isSuspended?: boolean } @@ -900,6 +899,30 @@ export default class IntuitAPI { return parsedCompanyInfo.CompanyInfo[0] } + /** + * Look up the QBO system "Undeposited Funds" account. + * Every QBO company has exactly one — it cannot be deleted or recreated. + * Queries by AccountSubType first (survives user renames), falls back to name. + */ + async getUndepositedFundsAccountId(): Promise { + const result = await this.customQuery( + `SELECT Id FROM Account WHERE AccountSubType = 'UndepositedFunds' AND Active = true maxresults 1`, + ) + if (result?.Account?.[0]?.Id) { + return result.Account[0].Id + } + + const byName = await this.getAnAccount('Undeposited Funds') + if (byName?.Id) { + return byName.Id + } + + throw new APIError( + httpStatus.INTERNAL_SERVER_ERROR, + 'IntuitAPI#getUndepositedFundsAccountId | Undeposited Funds account not found in QuickBooks', + ) + } + private wrapWithRetry( fn: (...args: Args) => Promise, ): (...args: Args) => Promise { diff --git a/src/utils/tokenRefresh.ts b/src/utils/tokenRefresh.ts index 7583e4f4..6563bd62 100644 --- a/src/utils/tokenRefresh.ts +++ b/src/utils/tokenRefresh.ts @@ -40,7 +40,6 @@ export async function getRefreshedQbTokenInfo( assetAccountRef, serviceItemRef, clientFeeRef, - undepositedFundsAccountRef, bankAccountRef, } = portalConnection @@ -60,7 +59,6 @@ export async function getRefreshedQbTokenInfo( assetAccountRef, serviceItemRef, clientFeeRef, - undepositedFundsAccountRef, bankAccountRef, } From 20bbcb9c86de5c31bb1463aed8ae2ad94d4f71d6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 21 Apr 2026 11:21:21 +0545 Subject: [PATCH 06/10] chore(OUT-3604): remove docs/ from tracking and add to .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 +- docs/bank-deposit-flow.md | 91 -------------------------------- docs/intuit-api-token-refresh.md | 49 ----------------- 3 files changed, 3 insertions(+), 141 deletions(-) delete mode 100644 docs/bank-deposit-flow.md delete mode 100644 docs/intuit-api-token-refresh.md diff --git a/.gitignore b/.gitignore index 5211169f..b537a1a2 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -.trigger \ No newline at end of file +.trigger + +docs \ No newline at end of file diff --git a/docs/bank-deposit-flow.md b/docs/bank-deposit-flow.md deleted file mode 100644 index 4766ab32..00000000 --- a/docs/bank-deposit-flow.md +++ /dev/null @@ -1,91 +0,0 @@ -# Bank Deposit Flow (OUT-3604) - -## Webhook-to-QBO Flow - -```mermaid -sequenceDiagram - participant Stripe - participant Assembly as Assembly/Copilot - participant App as QB Sync App - participant DB as Database - participant QBO as QuickBooks Online - - Note over Stripe,QBO: Step 1: Invoice Paid - - Stripe->>Assembly: Payment captured - Assembly->>App: Webhook: invoice.paid - App->>DB: Check settings (absorbedFeeFlag, bankDepositFeeFlag) - - alt bankDepositFeeFlag ON - App->>DB: Look up undepositedFundsAccountRef (cached) - alt Not cached - App->>QBO: Query Undeposited Funds account - QBO-->>App: Account ID - App->>DB: Cache undepositedFundsAccountRef - end - App->>QBO: Create Payment ($100)
LinkedTxn → Invoice
DepositToAccountRef → Undeposited Funds - else bankDepositFeeFlag OFF (legacy) - App->>QBO: Create Payment ($100)
LinkedTxn → Invoice
No DepositToAccountRef (QBO default) - end - - QBO-->>App: Payment created (QBO Payment ID) - App->>DB: Save sync log (EventType.PAID, quickbooksId = QBO Payment ID) - - Note over Stripe,QBO: Step 2: Payment Succeeded (fee info available) - - Assembly->>App: Webhook: payment.succeeded
feeAmount: { paidByPlatform: $2.90 } - App->>DB: Check settings (absorbedFeeFlag, bankDepositFeeFlag) - - alt bankDepositFeeFlag ON (Bank Deposit flow) - App->>DB: Look up sync log (EventType.PAID) → get QBO Payment ID - App->>DB: Look up bankAccountRef, expenseAccountRef - App->>QBO: Create Bank Deposit
Line 1: +$100 (LinkedTxn → Payment)
Line 2: -$2.90 (Stripe fee → Expense Account)
DepositToAccountRef → Bank Account
Net deposit = $97.10 - QBO-->>App: Deposit created - App->>DB: Save sync log (EventType.DEPOSITED) - else bankDepositFeeFlag OFF (legacy flow) - App->>QBO: Create Purchase (Expense) for $2.90 - QBO-->>App: Purchase created - App->>DB: Save sync log (EventType.SUCCEEDED) - end - - Note over QBO: Bank Feed Auto-Reconciliation - Note over QBO: Bank Deposit ($97.10) matches
real Stripe deposit ($97.10) -``` - -## QBO Entity Relationships - -```mermaid -graph LR - INV[Invoice
$100.00] -->|LinkedTxn| PAY[Payment
$100.00
→ Undeposited Funds] - PAY -->|LinkedTxn| DEP[Bank Deposit] - DEP -->|Line 1| GROSS[+$100.00
from Payment] - DEP -->|Line 2| FEE[-$2.90
Stripe Fee → Expense Account] - DEP -->|DepositToAccountRef| BANK[Bank Account
Net: $97.10] - - style INV fill:#4a90d9,color:#fff - style PAY fill:#f5a623,color:#fff - style DEP fill:#7ed321,color:#fff - style BANK fill:#50e3c2,color:#fff -``` - -## Old vs New Flow Comparison - -```mermaid -graph TD - subgraph OLD["Old Flow (bankDepositFeeFlag OFF)"] - O_INV[Invoice $100] -->|invoice.paid| O_PAY[Payment $100
→ QBO Default Account] - O_STRIPE[Bank Feed: $97.10] -.->|No match| O_PAY - O_FEE[payment.succeeded] -->|Separate expense| O_EXP[Purchase $2.90] - end - - subgraph NEW["New Flow (bankDepositFeeFlag ON)"] - N_INV[Invoice $100] -->|invoice.paid| N_PAY[Payment $100
→ Undeposited Funds] - N_PAY -->|payment.succeeded| N_DEP[Bank Deposit
+$100 - $2.90 = $97.10
→ Bank Account] - N_STRIPE[Bank Feed: $97.10] -.->|Auto-match| N_DEP - end - - style O_STRIPE fill:#e74c3c,color:#fff - style N_STRIPE fill:#2ecc71,color:#fff - style OLD fill:#fff3f3 - style NEW fill:#f0fff0 -``` diff --git a/docs/intuit-api-token-refresh.md b/docs/intuit-api-token-refresh.md deleted file mode 100644 index c06838a8..00000000 --- a/docs/intuit-api-token-refresh.md +++ /dev/null @@ -1,49 +0,0 @@ -# Intuit API: Auto Token Refresh on 401 - -## Problem - -The `IntuitAPI` class sets the access token once at construction and never updates it. -If the token expires mid-request or between construction and usage, the Intuit API -returns HTTP 401. The current code has three issues: - -1. **`fetch.helper.ts`** — `getFetcher`/`postFetcher` do not check `response.ok`. - A 401 response is parsed as JSON and passed through as if it were valid data. - -2. **Silent null propagation** — When `customQuery` receives a 401 error body, it - often lacks a `QueryResponse` key, so it returns `undefined`. Callers like - `getAnAccount` treat this as "account not found" and return `null`, which triggers - the restore/create path instead of surfacing the real auth error. - -3. **No 401 retry** — `withRetry` (pRetry wrapper) only retries on HTTP 429 - (rate limit). Expired token errors are not retried or refreshed. - -## Solution - -Instead of throwing on non-200, we refresh the access token and retry the request. -This is handled inside `IntuitAPI`'s two HTTP methods (`getFetchWithHeader` and -`postFetchWithHeaders`), keeping it transparent to all callers. - -### Changes - -1. **Add optional `portalId` to `IntuitAPI` constructor** — needed by - `getRefreshedQbTokenInfo()` to look up the refresh token and persist new tokens. - -2. **Replace usage of `fetch.helper` in IntuitAPI with direct `fetch`** — so we - can inspect `response.status` before parsing JSON. - -3. **On HTTP 401: refresh token and retry once** — call `getRefreshedQbTokenInfo`, - update `this.tokens` and `this.headers`, retry the original request. Only retry - once to avoid infinite loops. - -4. **On other non-200 (not 401): throw immediately** — surface the real error - instead of silently returning null. - -5. **No change to `fetch.helper.ts`** — it is used by frontend SWR and other code, - so we avoid breaking those callers. - -### Why handle it in `getFetchWithHeader`/`postFetchWithHeaders`? - -- All IntuitAPI methods go through these two functions. -- Keeps retry + refresh logic in one place. -- Callers (services, controllers) don't need any changes. -- The `portalId` is already available in every calling context. From 47e104533546f245f14aa99a9f8310ac3d99c139 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 17 Apr 2026 19:31:54 +0545 Subject: [PATCH 07/10] feat(OUT-3609): add bank account selector dropdown to invoice settings UI - Add bank account dropdown in InvoiceDetail below bankDepositFeeFlag checkbox - Fetch QBO bank accounts via SWR (GET /api/quickbooks/setting/bank-account) - Include bankAccountRef in settingState for single-request save - Add click-outside-to-close, loading state, and amber warning when unselected - Pass bankAccounts, isBankAccountsLoading, selectBankAccount props via SettingAccordion Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dashboard/settings/SettingAccordion.tsx | 6 + .../sections/invoice/InvoiceDetail.tsx | 107 ++++++++++++++++++ src/hook/useSettings.ts | 67 ++++++++--- 3 files changed, 162 insertions(+), 18 deletions(-) diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index cd482bd3..88a097ba 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -42,6 +42,9 @@ export default function SettingAccordion({ isLoading, changeSettings, showButton: showInvoiceButton, + bankAccounts, + isBankAccountsLoading, + selectBankAccount, } = useInvoiceDetailSettings() const accordionItems = [ @@ -72,6 +75,9 @@ export default function SettingAccordion({ settingState={settingState} changeSettings={changeSettings} isLoading={isLoading} + bankAccounts={bankAccounts} + isBankAccountsLoading={isBankAccountsLoading} + selectBankAccount={selectBankAccount} /> ), }, diff --git a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx index 5c93dccd..cdfae987 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -1,25 +1,52 @@ import { useApp } from '@/app/context/AppContext' +import { BankAccountType } from '@/hook/useSettings' import { InvoiceSettingType } from '@/type/common' import { getWorkspaceLabel } from '@/utils/workspace' import { Checkbox, Spinner } from 'copilot-design-system' +import { useEffect, useRef, useState } from 'react' type InvoiceDetailProps = { settingState: InvoiceSettingType changeSettings: (flag: keyof InvoiceSettingType, state: boolean) => void isLoading: boolean + bankAccounts: BankAccountType[] + isBankAccountsLoading: boolean + selectBankAccount: (ref: string) => void } export default function InvoiceDetail({ settingState, changeSettings, isLoading, + bankAccounts, + isBankAccountsLoading, + selectBankAccount, }: InvoiceDetailProps) { const { workspace } = useApp() + const [isDropdownOpen, setIsDropdownOpen] = useState(false) + const dropdownRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsDropdownOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) if (isLoading) { return } + const selectedAccount = bankAccounts.find( + (acc) => acc.Id === settingState.bankAccountRef, + ) + return ( <>
@@ -53,6 +80,86 @@ export default function InvoiceDetail({ />
)} + {settingState.absorbedFeeFlag && settingState.bankDepositFeeFlag && ( +
+ +

+ Select the QuickBooks bank account where Stripe deposits land. +

+
+ + {isDropdownOpen && ( +
+ {isBankAccountsLoading ? ( +
+ +
+ ) : bankAccounts.length === 0 ? ( +
+ No bank accounts found in QuickBooks +
+ ) : ( + bankAccounts.map((account) => ( + + )) + )} +
+ )} +
+ {!isBankAccountsLoading && + !settingState.bankAccountRef && + bankAccounts.length > 0 && ( +

+ Please select a bank account to enable bank deposits. +

+ )} +
+ )}
{ - const initialInvoiceSetting = { + const initialInvoiceSetting: InvoiceSettingType = { absorbedFeeFlag: false, bankDepositFeeFlag: false, useCompanyNameFlag: false, + bankAccountRef: null, } const { token, setAppParams } = useApp() const [settingState, setSettingState] = useState( @@ -520,6 +526,7 @@ export const useInvoiceDetailSettings = () => { const [intialSettingState, setIntialSettingState] = useState< InvoiceSettingType | undefined >() + const { data: setting, error, @@ -529,9 +536,18 @@ export const useInvoiceDetailSettings = () => { revalidateOnMount: false, }) - const changeSettings = async ( + const { data: bankAccountsData, isLoading: isBankAccountsLoading } = + useSwrHelper( + settingState.bankDepositFeeFlag + ? `/api/quickbooks/setting/bank-account?token=${token}` + : null, + { suspense: false, revalidateOnMount: true }, + ) + const bankAccounts: BankAccountType[] = bankAccountsData?.accounts || [] + + const changeSettings = ( flag: keyof InvoiceSettingType, - state: boolean, + state: boolean | string | null, ) => { setSettingState((prev) => ({ ...prev, @@ -539,16 +555,23 @@ export const useInvoiceDetailSettings = () => { })) } + const selectBankAccount = (ref: string) => { + setSettingState((prev) => ({ ...prev, bankAccountRef: ref })) + } + useEffect(() => { if (!settingState || !intialSettingState) return - const showButton = !equal(intialSettingState, settingState) - setShowButton(showButton) - }, [settingState]) + setShowButton(!equal(intialSettingState, settingState)) + }, [settingState, intialSettingState]) useEffect(() => { if (setting && setting?.setting) { - setSettingState(setting.setting) - setIntialSettingState(structuredClone(setting.setting)) + const loaded: InvoiceSettingType = { + ...setting.setting, + bankAccountRef: setting.bankAccountRef || null, + } + setSettingState(loaded) + setIntialSettingState(structuredClone(loaded)) setAppParams((prev) => ({ ...prev, initialInvoiceSettingMapFlag: setting.setting.initialInvoiceSettingMap, @@ -562,16 +585,21 @@ export const useInvoiceDetailSettings = () => { const submitInvoiceSettings = async () => { setShowButton(false) - const res = await postFetcher( - `/api/quickbooks/setting?type=${SettingType.INVOICE}&token=${token}`, - {}, - { ...settingState, type: SettingType.INVOICE }, - ) - if (!res || res?.error) { - setShowButton(true) // show the update settings button if error - console.error('Error submitting Invoice settings', { res }) - } else { - mutate(`/api/quickbooks/setting?type=invoice&token=${token}`) + try { + const res = await postFetcher( + `/api/quickbooks/setting?type=${SettingType.INVOICE}&token=${token}`, + {}, + { ...settingState, type: SettingType.INVOICE }, + ) + if (res?.error) { + setShowButton(true) + console.error('Error submitting Invoice settings', { res }) + } else { + mutate(`/api/quickbooks/setting?type=invoice&token=${token}`) + } + } catch (err) { + setShowButton(true) + console.error('Error submitting Invoice settings', err) } } @@ -588,6 +616,9 @@ export const useInvoiceDetailSettings = () => { error, isLoading, showButton, + bankAccounts, + isBankAccountsLoading, + selectBankAccount, } } From 18f49d02a7603d340288c65829a41dbbf05d12d3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 20 Apr 2026 15:11:46 +0545 Subject: [PATCH 08/10] fix(OUT-3609): prevent settings save when bank deposit enabled without bank account Block the Confirm/Update button when bankDepositFeeFlag is on but no bank account is selected. Without a bank account, the webhook deposit flow throws on every payment.succeeded event. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hook/useSettings.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index 8f09e5e4..011fa4d8 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -561,7 +561,11 @@ export const useInvoiceDetailSettings = () => { useEffect(() => { if (!settingState || !intialSettingState) return - setShowButton(!equal(intialSettingState, settingState)) + const hasChanges = !equal(intialSettingState, settingState) + // Block submit if bank deposit is on but no bank account selected + const missingBankAccount = + settingState.bankDepositFeeFlag && !settingState.bankAccountRef + setShowButton(hasChanges && !missingBankAccount) }, [settingState, intialSettingState]) useEffect(() => { From 36bcfa4e37a4afa5355409d8d7bc00b8fd7cc146 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 20 Apr 2026 14:23:41 +0545 Subject: [PATCH 09/10] feat(OUT-3617): add AB testing gate for bank deposit feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AB_FEATURE_TESTING_PORTALS env var to config (empty = all portals) - Create isPortalInBankDepositABTest utility for portal eligibility check - Gate bank deposit flow in invoice.service and webhook.service - Gate settings UI: return bankDepositEnabled from GET, hide checkbox/dropdown - Remove EventType.DEPOSITED — use SUCCEEDED for both legacy and deposit flows - Fix cascade bug: only reset bankDepositFeeFlag when feature is visible - Increase payment.succeeded sleep to 25s Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/core/types/log.ts | 1 - .../api/quickbooks/invoice/invoice.service.ts | 5 +- .../api/quickbooks/payment/payment.service.ts | 2 +- .../quickbooks/setting/setting.controller.ts | 5 +- .../api/quickbooks/webhook/webhook.service.ts | 11 +- .../dashboard/settings/SettingAccordion.tsx | 2 + .../sections/invoice/InvoiceDetail.tsx | 169 +++++++++--------- src/config/index.ts | 8 + src/hook/useSettings.ts | 5 +- src/utils/abTesting.ts | 11 ++ 10 files changed, 129 insertions(+), 90 deletions(-) create mode 100644 src/utils/abTesting.ts diff --git a/src/app/api/core/types/log.ts b/src/app/api/core/types/log.ts index 0555a9c7..1e1c6ce3 100644 --- a/src/app/api/core/types/log.ts +++ b/src/app/api/core/types/log.ts @@ -19,7 +19,6 @@ export enum EventType { SUCCEEDED = 'succeeded', MAPPED = 'mapped', UNMAPPED = 'unmapped', - DEPOSITED = 'deposited', } /** diff --git a/src/app/api/quickbooks/invoice/invoice.service.ts b/src/app/api/quickbooks/invoice/invoice.service.ts index d6ba19fb..aac089da 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 { isPortalInBankDepositABTest } from '@/utils/abTesting' import { addSyncBreadcrumb } from '@/utils/sentry' import { replaceSpecialCharsForQB, truncateForQB } from '@/utils/string' import { AccountTypeObj } from '@/constant/qbConnection' @@ -895,7 +896,9 @@ export class InvoiceService extends BaseService { 'bankDepositFeeFlag', ]) const useBankDepositFlow = - setting?.absorbedFeeFlag && setting?.bankDepositFeeFlag + setting?.absorbedFeeFlag && + setting?.bankDepositFeeFlag && + isPortalInBankDepositABTest(this.user.workspaceId) const intuitApi = new IntuitAPI(qbTokenInfo) diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index 02d02555..bd3dc00e 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -265,7 +265,7 @@ export class PaymentService extends BaseService { qbInvoiceId: res.Deposit.Id, invoiceNumber: opts.invoiceNumber, }, - EventType.DEPOSITED, + EventType.SUCCEEDED, EntityType.PAYMENT, { amount: (opts.grossAmount * 100).toFixed(2), diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index b1d2ad43..f141d53e 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,6 +1,7 @@ import authenticate from '@/app/api/core/utils/authenticate' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { isPortalInBankDepositABTest } from '@/utils/abTesting' import { db } from '@/db' import { QBPortalConnection } from '@/db/schema/qbPortalConnections' import { QBSetting, QBSettingsUpdateSchemaType } from '@/db/schema/qbSettings' @@ -42,7 +43,9 @@ export async function getSettings(req: NextRequest) { bankAccountRef = portalConnection?.bankAccountRef || null } - return NextResponse.json({ setting, bankAccountRef }) + const bankDepositEnabled = isPortalInBankDepositABTest(user.workspaceId) + + return NextResponse.json({ setting, bankAccountRef, bankDepositEnabled }) } export async function updateSettings(req: NextRequest) { diff --git a/src/app/api/quickbooks/webhook/webhook.service.ts b/src/app/api/quickbooks/webhook/webhook.service.ts index a140733f..37bbbe57 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -32,6 +32,7 @@ import { getDeletedAtForAuthAccountCategoryLog, getCategory, } from '@/utils/synclog' +import { isPortalInBankDepositABTest } from '@/utils/abTesting' import { addSyncBreadcrumb } from '@/utils/sentry' import { and, eq } from 'drizzle-orm' import httpStatus from 'http-status' @@ -399,7 +400,7 @@ export class WebhookService extends BaseService { payload: unknown, qbTokenInfo: IntuitAPITokensType, ) { - await sleep(20000) // Payment succeed event can sometimes trigger before invoice created. + await sleep(25000) // Payment succeed event can sometimes trigger before invoice created. console.info('###### PAYMENT SUCCEEDED ######') const parsedPaymentSucceed = @@ -428,10 +429,10 @@ export class WebhookService extends BaseService { return } - const useBankDepositFlow = setting.bankDepositFeeFlag - const idempotencyEventType = useBankDepositFlow - ? EventType.DEPOSITED - : EventType.SUCCEEDED + const useBankDepositFlow = + setting.bankDepositFeeFlag && + isPortalInBankDepositABTest(this.user.workspaceId) + const idempotencyEventType = EventType.SUCCEEDED const syncLogService = new SyncLogService(this.user) const syncLog = await syncLogService.getOneByCopilotIdAndEventType({ diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index 88a097ba..815ae3ce 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -42,6 +42,7 @@ export default function SettingAccordion({ isLoading, changeSettings, showButton: showInvoiceButton, + bankDepositEnabled, bankAccounts, isBankAccountsLoading, selectBankAccount, @@ -75,6 +76,7 @@ export default function SettingAccordion({ settingState={settingState} changeSettings={changeSettings} isLoading={isLoading} + bankDepositEnabled={bankDepositEnabled} bankAccounts={bankAccounts} isBankAccountsLoading={isBankAccountsLoading} selectBankAccount={selectBankAccount} diff --git a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx index cdfae987..59d0d20a 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -9,6 +9,7 @@ type InvoiceDetailProps = { settingState: InvoiceSettingType changeSettings: (flag: keyof InvoiceSettingType, state: boolean) => void isLoading: boolean + bankDepositEnabled: boolean bankAccounts: BankAccountType[] isBankAccountsLoading: boolean selectBankAccount: (ref: string) => void @@ -18,6 +19,7 @@ export default function InvoiceDetail({ settingState, changeSettings, isLoading, + bankDepositEnabled, bankAccounts, isBankAccountsLoading, selectBankAccount, @@ -59,13 +61,18 @@ export default function InvoiceDetail({ const newValue = !settingState.absorbedFeeFlag changeSettings('absorbedFeeFlag', newValue) // Turn off bank deposit flag if absorbed fees is being disabled - if (!newValue && settingState.bankDepositFeeFlag) { + // Only cascade when the bank deposit feature is visible to the user + if ( + bankDepositEnabled && + !newValue && + settingState.bankDepositFeeFlag + ) { changeSettings('bankDepositFeeFlag', false) } }} />
- {settingState.absorbedFeeFlag && ( + {bankDepositEnabled && settingState.absorbedFeeFlag && (
)} - {settingState.absorbedFeeFlag && settingState.bankDepositFeeFlag && ( -
- -

- Select the QuickBooks bank account where Stripe deposits land. -

-
- - {isDropdownOpen && ( -
- {isBankAccountsLoading ? ( -
- -
- ) : bankAccounts.length === 0 ? ( -
- No bank accounts found in QuickBooks -
- ) : ( - bankAccounts.map((account) => ( - - )) - )} -
- )} + + {isBankAccountsLoading + ? 'Loading accounts...' + : selectedAccount + ? selectedAccount.Name + : 'Select a bank account...'} + + + + + + {isDropdownOpen && ( +
+ {isBankAccountsLoading ? ( +
+ +
+ ) : bankAccounts.length === 0 ? ( +
+ No bank accounts found in QuickBooks +
+ ) : ( + bankAccounts.map((account) => ( + + )) + )} +
+ )} +
+ {!isBankAccountsLoading && + !settingState.bankAccountRef && + bankAccounts.length > 0 && ( +

+ Please select a bank account to enable bank deposits. +

+ )}
- {!isBankAccountsLoading && - !settingState.bankAccountRef && - bankAccounts.length > 0 && ( -

- Please select a bank account to enable bank deposits. -

- )} -
- )} + )}
id.trim()) + .filter(Boolean) + // Supabase export const supabaseProjectUrl = process.env.NEXT_PUBLIC_SUPABASE_PROJECT_URL || '' diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index 011fa4d8..9009ee4a 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -536,9 +536,11 @@ export const useInvoiceDetailSettings = () => { revalidateOnMount: false, }) + const bankDepositEnabled = setting?.bankDepositEnabled ?? false + const { data: bankAccountsData, isLoading: isBankAccountsLoading } = useSwrHelper( - settingState.bankDepositFeeFlag + bankDepositEnabled && settingState.bankDepositFeeFlag ? `/api/quickbooks/setting/bank-account?token=${token}` : null, { suspense: false, revalidateOnMount: true }, @@ -620,6 +622,7 @@ export const useInvoiceDetailSettings = () => { error, isLoading, showButton, + bankDepositEnabled, bankAccounts, isBankAccountsLoading, selectBankAccount, diff --git a/src/utils/abTesting.ts b/src/utils/abTesting.ts new file mode 100644 index 00000000..9fc8e285 --- /dev/null +++ b/src/utils/abTesting.ts @@ -0,0 +1,11 @@ +import { abFeatureTestingPortals } from '@/config' + +/** + * Checks if a portal is eligible for the bank deposit feature. + * When AB_FEATURE_TESTING_PORTALS is empty/unset, the feature is available to all portals. + * When it has values, only listed portals get the feature. + */ +export function isPortalInBankDepositABTest(portalId: string): boolean { + if (abFeatureTestingPortals.length === 0) return true + return abFeatureTestingPortals.includes(portalId) +} From 98c76f4f6ff45678c152550edd25ead4005a2cc2 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Mon, 20 Apr 2026 19:03:02 +0545 Subject: [PATCH 10/10] fix(OUT-3617): AB-gate settings write path and backend validations - Strip bankDepositFeeFlag/bankAccountRef from payload for non-AB portals (prevents garbage state if portal is later added to AB list) - Reject bankDepositFeeFlag:true without bankAccountRef on the backend - Fix res.Deposit.Id optional chaining inconsistency in payment.service.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/quickbooks/payment/payment.service.ts | 2 +- .../quickbooks/setting/setting.controller.ts | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index bd3dc00e..df34bdd4 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -262,7 +262,7 @@ export class PaymentService extends BaseService { await this.logSync( opts.paymentId, { - qbInvoiceId: res.Deposit.Id, + qbInvoiceId: res.Deposit?.Id, invoiceNumber: opts.invoiceNumber, }, EventType.SUCCEEDED, diff --git a/src/app/api/quickbooks/setting/setting.controller.ts b/src/app/api/quickbooks/setting/setting.controller.ts index f141d53e..17d740ef 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,3 +1,4 @@ +import APIError from '@/app/api/core/exceptions/api' import authenticate from '@/app/api/core/utils/authenticate' import { SettingService } from '@/app/api/quickbooks/setting/setting.service' import { TokenService } from '@/app/api/quickbooks/token/token.service' @@ -58,17 +59,31 @@ export async function updateSettings(req: NextRequest) { const parsedType = z.nativeEnum(SettingType).parse(type) const parsed = SettingRequestSchema.parse(body) - const { bankAccountRef, ...settingFields } = parsed + const { bankAccountRef, bankDepositFeeFlag, ...settingFields } = parsed + const isBankDepositAB = + parsedType === SettingType.INVOICE && + isPortalInBankDepositABTest(user.workspaceId) + + // Strip bank deposit fields for portals not in the AB test const payload = { ...settingFields, + ...(isBankDepositAB && { bankDepositFeeFlag }), ...(parsedType === SettingType.INVOICE ? { initialInvoiceSettingMap: true } : { initialProductSettingMap: true }), } + // Reject bankDepositFeeFlag:true without a bankAccountRef + if (isBankDepositAB && bankDepositFeeFlag && !bankAccountRef) { + throw new APIError( + httpStatus.BAD_REQUEST, + 'bankAccountRef is required when bankDepositFeeFlag is enabled', + ) + } + const writeBankAccountRef = - parsedType === SettingType.INVOICE && typeof bankAccountRef !== 'undefined' + isBankDepositAB && typeof bankAccountRef !== 'undefined' // Wrap both writes in a transaction to prevent partial state // (e.g. bankDepositFeeFlag=true but bankAccountRef=null)