Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts

.trigger
.trigger

docs
1 change: 1 addition & 0 deletions src/action/quickbooks.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/app/api/quickbooks/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -238,6 +239,7 @@ export class AuthService extends BaseService {
setting,
serviceItemRef,
clientFeeRef,
bankAccountRef,
isSuspended,
} = portalQBToken

Expand All @@ -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
Expand All @@ -281,6 +284,7 @@ export class AuthService extends BaseService {
assetAccountRef,
serviceItemRef,
clientFeeRef,
bankAccountRef,
}

// Refresh token if expired
Expand Down
25 changes: 24 additions & 1 deletion src/app/api/quickbooks/invoice/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -904,7 +928,6 @@ export class InvoiceService extends BaseService {
},
],
}
const intuitApi = new IntuitAPI(qbTokenInfo)
const paymentService = new PaymentService(this.user)

const customerDisplayName =
Expand Down
93 changes: 93 additions & 0 deletions src/app/api/quickbooks/payment/payment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 || [] })
}
4 changes: 4 additions & 0 deletions src/app/api/quickbooks/setting/bank-account/route.ts
Original file line number Diff line number Diff line change
@@ -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)
71 changes: 64 additions & 7 deletions src/app/api/quickbooks/setting/setting.controller.ts
Original file line number Diff line number Diff line change
@@ -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'

Check warning on line 8 in src/app/api/quickbooks/setting/setting.controller.ts

View workflow job for this annotation

GitHub Actions / Run linters

'QBSettingsUpdateSchemaType' is defined but never used. Allowed unused vars must match /^_/u
import { getPortalConnection } from '@/db/service/token.service'
import { eq } from 'drizzle-orm'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
Expand All @@ -22,12 +28,25 @@
'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) {
Expand All @@ -39,15 +58,53 @@

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 })
}
Loading
Loading