From 1dc37d8809abc6450aef310fd030d81d02f0423f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 20 May 2026 14:25:31 +0545 Subject: [PATCH 01/10] feat(OUT-3275): GET/PATCH /api/quickbooks/accounts and supporting helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the server side of the settings dashboard account-ref editor: a single /api/quickbooks/accounts route handling GET (lists income/expense/bank accounts from QBO plus the portal's currently-selected refs) and PATCH (validates each provided ref against QBO and updates qb_portal_connections scoped by portalId). - IntuitAPI.getAccountsForProductMapping runs three flat queries (no OR, parens, or IN — QBO's parser rejects all three on AccountType). - _getAnAccount SELECT now includes AccountType so PATCH validation can read it; QBAccountRowSchema tightened to require AccountType. - TokenService.updateAccountRefs validates AccountType per bucket in parallel before delegating to updateQBPortalConnection. - Response uses an allowlist (QBPortalConnectionSelectSchema.pick) so token fields can't leak. - patchFetcher helper mirrors postFetcher (throws on non-OK). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../accounts/accounts.controller.ts | 41 ++++++++++++ .../quickbooks/accounts/accounts.service.ts | 37 +++++++++++ src/app/api/quickbooks/accounts/route.ts | 10 +++ src/app/api/quickbooks/token/token.service.ts | 62 ++++++++++++++++++- src/helper/fetch.helper.ts | 17 +++++ src/type/common.ts | 31 ++++++++++ src/type/dto/intuitAPI.dto.ts | 2 + src/utils/intuitAPI.ts | 45 ++++++++++++++ 8 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 src/app/api/quickbooks/accounts/accounts.controller.ts create mode 100644 src/app/api/quickbooks/accounts/accounts.service.ts create mode 100644 src/app/api/quickbooks/accounts/route.ts diff --git a/src/app/api/quickbooks/accounts/accounts.controller.ts b/src/app/api/quickbooks/accounts/accounts.controller.ts new file mode 100644 index 00000000..612b9900 --- /dev/null +++ b/src/app/api/quickbooks/accounts/accounts.controller.ts @@ -0,0 +1,41 @@ +import authenticate from '@/app/api/core/utils/authenticate' +import { NextRequest, NextResponse } from 'next/server' +import httpStatus from 'http-status' +import { AccountService } from '@/app/api/quickbooks/accounts/accounts.service' +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import { AccountRefsUpdateSchema } from '@/type/common' +import { QBPortalConnectionSelectSchema } from '@/db/schema/qbPortalConnections' + +// Explicit allowlist of what is safe to return — anything else (especially +// token/secret fields) is dropped. Adding a new column to QBPortalConnection +// does NOT widen this surface unless the column is added here too. +const SafePortalConnectionSchema = QBPortalConnectionSelectSchema.pick({ + id: true, + portalId: true, + incomeAccountRef: true, + expenseAccountRef: true, + assetAccountRef: true, +}) + +export async function listAccounts(req: NextRequest) { + const user = await authenticate(req) + const service = new AccountService(user) + const data = await service.listAccountsForProductMapping() + return NextResponse.json(data) +} + +export async function updateAccountRefs(req: NextRequest) { + const user = await authenticate(req) + const body = await req.json() + const payload = AccountRefsUpdateSchema.parse(body) + + const service = new TokenService(user) + const conn = await service.updateAccountRefs(payload) + + const safe = SafePortalConnectionSchema.parse(conn) + + return NextResponse.json( + { portalConnection: safe }, + { status: httpStatus.OK }, + ) +} diff --git a/src/app/api/quickbooks/accounts/accounts.service.ts b/src/app/api/quickbooks/accounts/accounts.service.ts new file mode 100644 index 00000000..f873294e --- /dev/null +++ b/src/app/api/quickbooks/accounts/accounts.service.ts @@ -0,0 +1,37 @@ +import { BaseService } from '@/app/api/core/services/base.service' +import { getPortalTokens } from '@/db/service/token.service' +import APIError from '@/app/api/core/exceptions/api' +import httpStatus from 'http-status' +import IntuitAPI from '@/utils/intuitAPI' +import { AccountsListResponse } from '@/type/common' + +export class AccountService extends BaseService { + async listAccountsForProductMapping(): Promise { + let tokens + try { + tokens = await getPortalTokens(this.user.workspaceId) + } catch { + throw new APIError( + httpStatus.NOT_FOUND, + 'AccountService#listAccountsForProductMapping | no portal connection', + ) + } + + const intuitApi = new IntuitAPI(tokens) + const { income, expense, asset } = + await intuitApi.getAccountsForProductMapping() + + return { + options: { + income: income.map((a) => ({ id: a.Id, name: a.Name })), + expense: expense.map((a) => ({ id: a.Id, name: a.Name })), + asset: asset.map((a) => ({ id: a.Id, name: a.Name })), + }, + selected: { + incomeAccountRef: tokens.incomeAccountRef, + expenseAccountRef: tokens.expenseAccountRef, + assetAccountRef: tokens.assetAccountRef, + }, + } + } +} diff --git a/src/app/api/quickbooks/accounts/route.ts b/src/app/api/quickbooks/accounts/route.ts new file mode 100644 index 00000000..e4bf65ba --- /dev/null +++ b/src/app/api/quickbooks/accounts/route.ts @@ -0,0 +1,10 @@ +import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' +import { + listAccounts, + updateAccountRefs, +} from '@/app/api/quickbooks/accounts/accounts.controller' + +export const maxDuration = 300 // 5 minutes + +export const GET = withErrorHandler(listAccounts) +export const PATCH = withErrorHandler(updateAccountRefs) diff --git a/src/app/api/quickbooks/token/token.service.ts b/src/app/api/quickbooks/token/token.service.ts index da2d9966..5a851198 100644 --- a/src/app/api/quickbooks/token/token.service.ts +++ b/src/app/api/quickbooks/token/token.service.ts @@ -12,8 +12,16 @@ import { QBPortalConnectionUpdateSchemaType, } from '@/db/schema/qbPortalConnections' import { QBSetting, QBSettingsUpdateSchemaType } from '@/db/schema/qbSettings' -import { getPortalConnection } from '@/db/service/token.service' -import { AccountType, ChangeEnableStatusRequestType } from '@/type/common' +import { + getPortalConnection, + getPortalTokens, +} from '@/db/service/token.service' +import { + AccountRefsUpdateSchema, + AccountRefsUpdateType, + AccountType, + ChangeEnableStatusRequestType, +} from '@/type/common' import IntuitAPI from '@/utils/intuitAPI' import CustomLogger from '@/utils/logger' import dayjs from 'dayjs' @@ -90,6 +98,56 @@ export class TokenService extends BaseService { return token } + async updateAccountRefs(payload: AccountRefsUpdateType) { + const parsed = AccountRefsUpdateSchema.parse(payload) + + let tokens + try { + tokens = await getPortalTokens(this.user.workspaceId) + } catch { + throw new APIError( + httpStatus.NOT_FOUND, + 'TokenService#updateAccountRefs | no portal connection', + ) + } + + const intuitApi = new IntuitAPI(tokens) + + const checks: Array<[keyof AccountRefsUpdateType, (t: string) => boolean]> = + [] + if (parsed.incomeAccountRef) + checks.push(['incomeAccountRef', (t) => t === 'Income']) + if (parsed.expenseAccountRef) + checks.push(['expenseAccountRef', (t) => t === 'Expense']) + if (parsed.assetAccountRef) + checks.push(['assetAccountRef', (t) => t === 'Bank']) + + await Promise.all( + checks.map(async ([field, isValidType]) => { + const id = parsed[field]! + const account = await intuitApi.getAnAccount(undefined, id) + if (!account) { + throw new APIError( + httpStatus.BAD_REQUEST, + `${field} account not found`, + ) + } + const acctType = account.AccountType + if (!isValidType(acctType)) { + throw new APIError( + httpStatus.BAD_REQUEST, + `${field} has an incompatible account type`, + ) + } + }), + ) + + return await this.updateQBPortalConnection( + parsed, + eq(QBPortalConnection.portalId, this.user.workspaceId), + ) + } + async turnOffSync(intuitRealmId: string) { const portalId = this.user.workspaceId // update db sync status for the defined portal diff --git a/src/helper/fetch.helper.ts b/src/helper/fetch.helper.ts index e8f89d2b..9b35cf46 100644 --- a/src/helper/fetch.helper.ts +++ b/src/helper/fetch.helper.ts @@ -91,6 +91,23 @@ export const postFetcher = async ( return response.json() } +export const patchFetcher = async ( + url: string, + headers: Record, + body: Record, + opts: FetcherOptions = {}, +) => { + const response = await fetch(url, { + method: 'PATCH', + headers, + body: JSON.stringify(body), + signal: resolveSignal(opts), + }) + + if (!response.ok) throw await buildHttpFetchError(response, url) + return response.json() +} + export const getFetcher = async ( url: string, headers: Record, diff --git a/src/type/common.ts b/src/type/common.ts index 434a1a06..252b42f2 100644 --- a/src/type/common.ts +++ b/src/type/common.ts @@ -317,6 +317,37 @@ export type ProductSettingType = Required< Pick > & { id?: string } +export type AccountOption = { id: string; name: string } + +export type AccountsListResponse = { + options: { + income: AccountOption[] + expense: AccountOption[] + asset: AccountOption[] + } + selected: { + incomeAccountRef: string + expenseAccountRef: string + assetAccountRef: string + } +} + +export const AccountRefsUpdateSchema = z + .object({ + incomeAccountRef: z.string().min(1).optional(), + expenseAccountRef: z.string().min(1).optional(), + assetAccountRef: z.string().min(1).optional(), + }) + .refine( + (v) => v.incomeAccountRef || v.expenseAccountRef || v.assetAccountRef, + { + message: + 'At least one of incomeAccountRef, expenseAccountRef, or assetAccountRef must be provided', + }, + ) + +export type AccountRefsUpdateType = z.infer + export enum TransactionType { INVOICE = 'Invoice', } diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index e7119745..7066ff6d 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -204,6 +204,8 @@ export const QBAccountRowSchema = z.object({ Name: z.string(), SyncToken: z.string(), Active: z.boolean(), + AccountType: z.string(), + AccountSubType: z.string().optional(), }) export type QBAccountRowType = z.infer diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index c8d3f5c2..5e001848 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -120,6 +120,7 @@ export const QB_ACCOUNT_COLUMNS = [ 'Name', 'SyncToken', 'Active', + 'AccountType', ] as const satisfies ReadonlyArray type GetACustomerOverloads = { @@ -333,6 +334,48 @@ export default class IntuitAPI { return parsed.Account?.[0] } + async _getAccountsForProductMapping(): Promise<{ + income: QBAccountRowType[] + expense: QBAccountRowType[] + asset: QBAccountRowType[] + }> { + CustomLogger.info({ + message: `IntuitAPI#getAccountsForProductMapping | start for realmId: ${this.tokens.intuitRealmId}`, + }) + // QBO's query parser does not support OR, parentheses for grouping, or + // IN on AccountType. We work around with: one query per AccountType, and + // JS-side filtering on AccountSubType for the income bucket. + const incomeQuery = + "SELECT Id, Name, SyncToken, Active, AccountType, AccountSubType FROM Account WHERE AccountType = 'Income' AND AccountSubType = 'SalesOfProductIncome' AND Active = true" + const expenseQuery = + "SELECT Id, Name, SyncToken, Active, AccountType FROM Account WHERE AccountType = 'Expense' AND Active = true" + const assetQuery = + "SELECT Id, Name, SyncToken, Active, AccountType FROM Account WHERE AccountType = 'Bank' AND Active = true" + + const [incomeRaw, expenseRaw, assetOtherRaw] = await Promise.all([ + this.customQuery(incomeQuery), + this.customQuery(expenseQuery), + this.customQuery(assetQuery), + ]) + + if (!incomeRaw || !expenseRaw || !assetOtherRaw) + throw new APIError( + httpStatus.BAD_REQUEST, + 'IntuitAPI#getAccountsForProductMapping | no response from QBO', + ) + + const parse = (raw: unknown): QBAccountRowType[] => { + const parsed = QBAccountQueryResponseSchema.parse(raw) + return parsed.Account ?? [] + } + + return { + income: parse(incomeRaw), + expense: parse(expenseRaw), + asset: parse(assetOtherRaw), + } + } + /** * Either displayName or id must be provided */ @@ -993,6 +1036,8 @@ export default class IntuitAPI { createCustomer = this.wrapWithRetry(this._createCustomer) createItem = this.wrapWithRetry(this._createItem) getSingleIncomeAccount = this._getSingleIncomeAccount.bind(this) + // bind-only: three inner customQuery calls each already retry; wrapping would amplify. + getAccountsForProductMapping = this._getAccountsForProductMapping.bind(this) getACustomer: GetACustomerOverloads = this._getACustomer.bind( this, ) as unknown as GetACustomerOverloads From 6b997122f41be27e55a59088e6fc27e4b20433e3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 20 May 2026 14:25:49 +0545 Subject: [PATCH 02/10] feat(OUT-3275): Other Settings accordion with QBO account dropdowns Adds a third accordion section below Invoice Details that lets the user pick which QBO accounts the integration uses for income, expense, and bank. Skips the /accounts fetch entirely when QB isn't connected (syncFlag off or no portal connection) and surfaces a "Connect to QuickBooks" message inline instead. - useOtherSettings hook backs the section via SWR; submits only changed fields through patchFetcher and mutates the cache on success. - Custom dropdown component mirrors the QuickBooks-items dropdown pattern (button + click-outside-to-close panel) instead of a native