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/src/action/quickbooks.action.ts b/src/action/quickbooks.action.ts index 60bf0770..77f49f72 100644 --- a/src/action/quickbooks.action.ts +++ b/src/action/quickbooks.action.ts @@ -65,6 +65,7 @@ export async function checkForNonUsCompany(portalId: string) { assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, + bankAccountRef: portalConnection.bankAccountRef, } const intuitApi = new IntuitAPI(tokenInfo) diff --git a/src/app/api/quickbooks/auth/auth.service.ts b/src/app/api/quickbooks/auth/auth.service.ts index b84ecfea..28f97aa9 100644 --- a/src/app/api/quickbooks/auth/auth.service.ts +++ b/src/app/api/quickbooks/auth/auth.service.ts @@ -139,6 +139,7 @@ export class AuthService extends BaseService { assetAccountRef: insertPayload.assetAccountRef, serviceItemRef: existingToken?.serviceItemRef || null, clientFeeRef: existingToken?.clientFeeRef || null, + bankAccountRef: existingToken?.bankAccountRef || null, }) // handle accounts const createPayload = await this.handleAccountReferences( @@ -238,6 +239,7 @@ export class AuthService extends BaseService { setting, serviceItemRef, clientFeeRef, + bankAccountRef, isSuspended, } = portalQBToken @@ -260,6 +262,7 @@ export class AuthService extends BaseService { assetAccountRef: '', serviceItemRef: '', clientFeeRef: '', + bankAccountRef: null, } // if sync is false but it has been enabled then don't throw error. We have to log in this case @@ -281,6 +284,7 @@ export class AuthService extends BaseService { assetAccountRef, serviceItemRef, clientFeeRef, + 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..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' @@ -887,11 +888,34 @@ 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 && + isPortalInBankDepositABTest(this.user.workspaceId) + + const intuitApi = new IntuitAPI(qbTokenInfo) + + let depositToAccountRef: { value: string } | undefined + if (useBankDepositFlow) { + const undepositedFundsRef = await intuitApi.getUndepositedFundsAccountId() + depositToAccountRef = { value: undepositedFundsRef } + } + const qbPaymentPayload = { TotalAmt: invoiceAmount, CustomerRef: { value: existingCustomer.qbCustomerId, }, + ...(depositToAccountRef && { + DepositToAccountRef: depositToAccountRef, + }), Line: [ { Amount: invoiceAmount, @@ -904,7 +928,6 @@ export class InvoiceService extends BaseService { }, ], } - const intuitApi = new IntuitAPI(qbTokenInfo) const paymentService = new PaymentService(this.user) const customerDisplayName = diff --git a/src/app/api/quickbooks/payment/payment.service.ts b/src/app/api/quickbooks/payment/payment.service.ts index af20bed5..df34bdd4 100644 --- a/src/app/api/quickbooks/payment/payment.service.ts +++ b/src/app/api/quickbooks/payment/payment.service.ts @@ -25,10 +25,13 @@ import { QBPaymentCreatePayloadType, QBPurchaseCreatePayloadSchema, QBPurchaseCreatePayloadType, + QBDepositCreatePayloadSchema, + QBDepositCreatePayloadType, } from '@/type/dto/intuitAPI.dto' 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, @@ -191,6 +194,96 @@ 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 { + 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, + TxnLineId: '0', + }, + ], + }, + { + Amount: -opts.feeAmount, + DetailType: 'DepositLineDetail' as const, + DepositLineDetail: { + AccountRef: { value: opts.expenseAccountRef }, + }, + Description: 'Assembly processing fees', + }, + ], + } + + const parsedPayload = QBDepositCreatePayloadSchema.parse(depositPayload) + const res = await intuitApi.createDeposit(parsedPayload) + + 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}`, + }) + + addSyncBreadcrumb('Bank deposit created in QBO', { + depositId: res.Deposit?.Id, + invoiceNumber: opts.invoiceNumber, + }) + + try { + await this.logSync( + opts.paymentId, + { + qbInvoiceId: res.Deposit?.Id, + invoiceNumber: opts.invoiceNumber, + }, + EventType.SUCCEEDED, + 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) { + CustomLogger.error({ + obj: error, + message: `PaymentService#createBankDepositForPayment | Failed to log sync for deposit ${res.Deposit?.Id}, but deposit was created in QBO`, + }) + throw error + } + } + async webhookPaymentSucceeded( parsedPaymentSucceedResource: PaymentSucceededResponseType, qbTokenInfo: IntuitAPITokensType, 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 b3b42ada..17d740ef 100644 --- a/src/app/api/quickbooks/setting/setting.controller.ts +++ b/src/app/api/quickbooks/setting/setting.controller.ts @@ -1,6 +1,12 @@ +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' +import { isPortalInBankDepositABTest } from '@/utils/abTesting' +import { db } from '@/db' +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' @@ -22,12 +28,25 @@ 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') } 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 + } + + const bankDepositEnabled = isPortalInBankDepositABTest(user.workspaceId) + + return NextResponse.json({ setting, bankAccountRef, bankDepositEnabled }) } export async function updateSettings(req: NextRequest) { @@ -39,15 +58,53 @@ export async function updateSettings(req: NextRequest) { const parsedType = z.nativeEnum(SettingType).parse(type) + const parsed = SettingRequestSchema.parse(body) + 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 = { - ...SettingRequestSchema.parse(body), + ...settingFields, + ...(isBankDepositAB && { bankDepositFeeFlag }), ...(parsedType === SettingType.INVOICE ? { initialInvoiceSettingMap: true } : { initialProductSettingMap: true }), } - const setting = await settingService.updateQBSettings( - payload, - eq(QBSetting.portalId, user.workspaceId), - ) + + // 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 = + isBankDepositAB && 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 db436add..37bbbe57 100644 --- a/src/app/api/quickbooks/webhook/webhook.service.ts +++ b/src/app/api/quickbooks/webhook/webhook.service.ts @@ -14,21 +14,25 @@ import { InvoiceDeletedResponseSchema, InvoiceResponseSchema, PaymentSucceededResponseSchema, + PaymentSucceededResponseType, PriceCreatedResponseSchema, ProductUpdatedResponseSchema, 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 { 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' @@ -396,7 +400,7 @@ export class WebhookService extends BaseService { payload: unknown, qbTokenInfo: IntuitAPITokensType, ) { - await sleep(7000) // 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 = @@ -413,7 +417,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 +429,15 @@ export class WebhookService extends BaseService { return } + const useBankDepositFlow = + setting.bankDepositFeeFlag && + isPortalInBankDepositABTest(this.user.workspaceId) + const idempotencyEventType = 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) { @@ -435,25 +447,38 @@ 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) - // 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 +488,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, + 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 +508,96 @@ 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: 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') + + // 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: 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: ${invoiceId}. The invoice.paid event may not have been processed yet.`, + ) + } + + 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 + + addSyncBreadcrumb('Bank deposit payment resolved', { + qbPaymentId, + grossAmount, + platformFee, + }) + + // 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 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, + feeAmount: platformFee, + bankAccountRef, + expenseAccountRef, + txnDate: parsedPaymentSucceedResource.data.createdAt.split('T')[0], + invoiceNumber: invoice.number, + paymentId, + }) + } } diff --git a/src/cmd/renameQbAccount/renameQbAccount.service.ts b/src/cmd/renameQbAccount/renameQbAccount.service.ts index e9d16847..6afbeb95 100644 --- a/src/cmd/renameQbAccount/renameQbAccount.service.ts +++ b/src/cmd/renameQbAccount/renameQbAccount.service.ts @@ -161,6 +161,7 @@ export class RenameQbAccountService extends BaseService { assetAccountRef: portal.assetAccountRef, serviceItemRef: portal.serviceItemRef, clientFeeRef: portal.clientFeeRef, + bankAccountRef: portal.bankAccountRef, } } } diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index cd482bd3..815ae3ce 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -42,6 +42,10 @@ export default function SettingAccordion({ isLoading, changeSettings, showButton: showInvoiceButton, + bankDepositEnabled, + bankAccounts, + isBankAccountsLoading, + selectBankAccount, } = useInvoiceDetailSettings() const accordionItems = [ @@ -72,6 +76,10 @@ 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 0eb8701f..59d0d20a 100644 --- a/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx +++ b/src/components/dashboard/settings/sections/invoice/InvoiceDetail.tsx @@ -1,25 +1,54 @@ 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 + bankDepositEnabled: boolean + bankAccounts: BankAccountType[] + isBankAccountsLoading: boolean + selectBankAccount: (ref: string) => void } export default function InvoiceDetail({ settingState, changeSettings, isLoading, + bankDepositEnabled, + 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 ( <>
@@ -28,11 +57,118 @@ 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 + // Only cascade when the bank deposit feature is visible to the user + if ( + bankDepositEnabled && + !newValue && + settingState.bankDepositFeeFlag + ) { + changeSettings('bankDepositFeeFlag', false) + } + }} />
+ {bankDepositEnabled && settingState.absorbedFeeFlag && ( +
+ + changeSettings( + 'bankDepositFeeFlag', + !settingState.bankDepositFeeFlag, + ) + } + /> +
+ )} + {bankDepositEnabled && + 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. +

+ )} +
+ )}
id.trim()) + .filter(Boolean) + // Supabase export const supabaseProjectUrl = process.env.NEXT_PUBLIC_SUPABASE_PROJECT_URL || '' 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/20260420090412_snapshot.json b/src/db/migrations/meta/20260420090412_snapshot.json new file mode 100644 index 00000000..4dabe532 --- /dev/null +++ b/src/db/migrations/meta/20260420090412_snapshot.json @@ -0,0 +1,999 @@ +{ + "id": "9eb854f3-0544-46b9-8b4f-6ad7457aaac9", + "prevId": "1c9fbc27-72b3-4a6e-860c-6e3f247135d5", + "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 + }, + "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", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_name": { + "name": "copilot_name", + "type": "varchar", + "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", + "primaryKey": false, + "notNull": false + }, + "customer_name": { + "name": "customer_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "customer_email": { + "name": "customer_email", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tax_amount": { + "name": "tax_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "fee_amount": { + "name": "fee_amount", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "product_name": { + "name": "product_name", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "product_price": { + "name": "product_price", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "qb_item_name": { + "name": "qb_item_name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "copilot_price_id": { + "name": "copilot_price_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "failed_record_category_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'others'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.connection_statuses": { + "name": "connection_statuses", + "schema": "public", + "values": [ + "pending", + "success", + "error" + ] + }, + "public.invoice_statuses": { + "name": "invoice_statuses", + "schema": "public", + "values": [ + "draft", + "open", + "paid", + "void", + "deleted" + ] + }, + "public.entity_types": { + "name": "entity_types", + "schema": "public", + "values": [ + "invoice", + "product", + "payment" + ] + }, + "public.event_types": { + "name": "event_types", + "schema": "public", + "values": [ + "created", + "updated", + "paid", + "voided", + "deleted", + "succeeded", + "mapped", + "unmapped" + ] + }, + "public.failed_record_category_types": { + "name": "failed_record_category_types", + "schema": "public", + "values": [ + "auth", + "account", + "rate_limit", + "validation", + "qb_api_error", + "mapping_not_found", + "others" + ] + }, + "public.log_statuses": { + "name": "log_statuses", + "schema": "public", + "values": [ + "success", + "failed", + "info" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index f0fa6a41..7aa00afb 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": 1776675852536, + "tag": "20260420090412_add_bank_deposit_fee_column", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/qbPortalConnections.ts b/src/db/schema/qbPortalConnections.ts index b35e4ed0..4176ef03 100644 --- a/src/db/schema/qbPortalConnections.ts +++ b/src/db/schema/qbPortalConnections.ts @@ -28,6 +28,7 @@ export const QBPortalConnection = table( .notNull(), clientFeeRef: t.varchar('client_fee_ref', { length: 100 }), serviceItemRef: t.varchar('service_item_ref', { length: 100 }), + bankAccountRef: t.varchar('bank_account_ref', { length: 100 }), isSuspended: t.boolean('is_suspended').notNull().default(false), ...timestamps, }, diff --git a/src/db/schema/qbSettings.ts b/src/db/schema/qbSettings.ts index 2d2d580b..18eca40f 100644 --- a/src/db/schema/qbSettings.ts +++ b/src/db/schema/qbSettings.ts @@ -15,6 +15,10 @@ export const QBSetting = table('qb_settings', { .references(() => QBPortalConnection.portalId, { onDelete: 'cascade' }) .notNull(), absorbedFeeFlag: t.boolean('absorbed_fee_flag').default(false).notNull(), + bankDepositFeeFlag: t + .boolean('bank_deposit_fee_flag') + .default(false) + .notNull(), useCompanyNameFlag: t.boolean('company_name_flag').default(false).notNull(), createNewProductFlag: t .boolean('create_new_product_flag') diff --git a/src/db/service/token.service.ts b/src/db/service/token.service.ts index 5f55d9c1..bf781b4f 100644 --- a/src/db/service/token.service.ts +++ b/src/db/service/token.service.ts @@ -73,5 +73,6 @@ export const getPortalTokens = async ( assetAccountRef: portalConnection.assetAccountRef, serviceItemRef: portalConnection.serviceItemRef, clientFeeRef: portalConnection.clientFeeRef, + bankAccountRef: portalConnection.bankAccountRef, } } diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index a070d20a..9009ee4a 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -506,10 +506,17 @@ export const useMapItem = ( } } +export type BankAccountType = { + Id: string + Name: string +} + export const useInvoiceDetailSettings = () => { - const initialInvoiceSetting = { + const initialInvoiceSetting: InvoiceSettingType = { absorbedFeeFlag: false, + bankDepositFeeFlag: false, useCompanyNameFlag: false, + bankAccountRef: null, } const { token, setAppParams } = useApp() const [settingState, setSettingState] = useState( @@ -519,6 +526,7 @@ export const useInvoiceDetailSettings = () => { const [intialSettingState, setIntialSettingState] = useState< InvoiceSettingType | undefined >() + const { data: setting, error, @@ -528,9 +536,20 @@ export const useInvoiceDetailSettings = () => { revalidateOnMount: false, }) - const changeSettings = async ( + const bankDepositEnabled = setting?.bankDepositEnabled ?? false + + const { data: bankAccountsData, isLoading: isBankAccountsLoading } = + useSwrHelper( + bankDepositEnabled && 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, @@ -538,16 +557,27 @@ 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]) + 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(() => { 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, @@ -561,16 +591,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) } } @@ -587,6 +622,10 @@ export const useInvoiceDetailSettings = () => { error, isLoading, showButton, + bankDepositEnabled, + bankAccounts, + isBankAccountsLoading, + selectBankAccount, } } diff --git a/src/type/common.ts b/src/type/common.ts index 434a1a06..61d78bdf 100644 --- a/src/type/common.ts +++ b/src/type/common.ts @@ -276,6 +276,8 @@ export const SettingRequestSchema = z id: z.string().optional(), 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(), }) @@ -288,6 +290,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,8 +319,11 @@ export const SettingRequestSchema = z export type SettingRequestType = z.infer export type InvoiceSettingType = Required< - Pick -> & { id?: string } + Pick< + SettingRequestType, + 'absorbedFeeFlag' | 'bankDepositFeeFlag' | 'useCompanyNameFlag' + > +> & { 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 2082df2d..6b910ceb 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,40 @@ export type QBPurchaseCreatePayloadType = z.infer< typeof QBPurchaseCreatePayloadSchema > +export const QBDepositLineSchema = z.union([ + z.object({ + Amount: z.number(), + LinkedTxn: z.array( + z.object({ + TxnId: z.string(), + TxnType: z.literal('Payment'), + TxnLineId: z.string(), + }), + ), + }), + z.object({ + Amount: z.number(), + DetailType: z.literal('DepositLineDetail'), + DepositLineDetail: z.object({ + AccountRef: QBNameValueSchema, + }), + Description: z.string().optional(), + }), +]) + +export const QBDepositCreatePayloadSchema = z.object({ + DepositToAccountRef: z.object({ + value: z.string(), + }), + PrivateNote: z.string().optional(), + TxnDate: z.string(), + Line: z.array(QBDepositLineSchema), +}) + +export type QBDepositCreatePayloadType = z.infer< + typeof QBDepositCreatePayloadSchema +> + export const QBDeletePayloadSchema = z.object({ SyncToken: z.string(), Id: z.string(), 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) +} diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 1bfdc850..ec35fffb 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,7 @@ export type IntuitAPITokensType = Pick< | 'assetAccountRef' | 'serviceItemRef' | 'clientFeeRef' + | 'bankAccountRef' > & { isSuspended?: boolean } export type BaseResponseType = { @@ -810,6 +812,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 }, @@ -867,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 { @@ -941,6 +997,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) diff --git a/src/utils/tokenRefresh.ts b/src/utils/tokenRefresh.ts index 7c14c480..6563bd62 100644 --- a/src/utils/tokenRefresh.ts +++ b/src/utils/tokenRefresh.ts @@ -40,6 +40,7 @@ export async function getRefreshedQbTokenInfo( assetAccountRef, serviceItemRef, clientFeeRef, + bankAccountRef, } = portalConnection CustomLogger.info({ @@ -58,6 +59,7 @@ export async function getRefreshedQbTokenInfo( assetAccountRef, serviceItemRef, clientFeeRef, + bankAccountRef, } const updatedPayload: QBPortalConnectionUpdateSchemaType = {