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..9b0f49e4 --- /dev/null +++ b/src/app/api/quickbooks/accounts/accounts.controller.ts @@ -0,0 +1,39 @@ +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 accountsResponse = await service.listAccountsForProductMapping() + return NextResponse.json(accountsResponse) +} + +export async function updateAccountRefs(req: NextRequest) { + const user = await authenticate(req) + const body = await req.json() + const accountRefs = AccountRefsUpdateSchema.parse(body) + + const service = new TokenService(user) + const updatedConnection = await service.updateAccountRefs(accountRefs) + + return NextResponse.json( + { portalConnection: SafePortalConnectionSchema.parse(updatedConnection) }, + { 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..71687ef2 --- /dev/null +++ b/src/app/api/quickbooks/accounts/accounts.service.ts @@ -0,0 +1,27 @@ +import { BaseService } from '@/app/api/core/services/base.service' +import { getPortalTokens } from '@/db/service/token.service' +import IntuitAPI from '@/utils/intuitAPI' +import { AccountsListResponse } from '@/type/common' + +export class AccountService extends BaseService { + async listAccountsForProductMapping(): Promise { + const tokens = await getPortalTokens(this.user.workspaceId) + + 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..f4fead40 100644 --- a/src/app/api/quickbooks/token/token.service.ts +++ b/src/app/api/quickbooks/token/token.service.ts @@ -13,7 +13,12 @@ import { } 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 { + AccountRefsUpdateSchema, + AccountRefsUpdateType, + AccountType, + ChangeEnableStatusRequestType, +} from '@/type/common' import IntuitAPI from '@/utils/intuitAPI' import CustomLogger from '@/utils/logger' import dayjs from 'dayjs' @@ -90,6 +95,22 @@ export class TokenService extends BaseService { return token } + async updateAccountRefs(payload: AccountRefsUpdateType) { + const accountRefs = AccountRefsUpdateSchema.parse(payload) + + const updatedConnection = await this.updateQBPortalConnection( + accountRefs, + eq(QBPortalConnection.portalId, this.user.workspaceId), + ) + if (!updatedConnection) { + throw new APIError( + httpStatus.INTERNAL_SERVER_ERROR, + 'TokenService#updateAccountRefs | portal connection row not found during update', + ) + } + return updatedConnection + } + async turnOffSync(intuitRealmId: string) { const portalId = this.user.workspaceId // update db sync status for the defined portal diff --git a/src/components/dashboard/settings/SettingAccordion.tsx b/src/components/dashboard/settings/SettingAccordion.tsx index cd482bd3..33bb6a90 100644 --- a/src/components/dashboard/settings/SettingAccordion.tsx +++ b/src/components/dashboard/settings/SettingAccordion.tsx @@ -1,10 +1,12 @@ import { useApp } from '@/app/context/AppContext' import InvoiceDetail from '@/components/dashboard/settings/sections/invoice/InvoiceDetail' +import AccountMapping from '@/components/dashboard/settings/sections/account/AccountMapping' import ProductMapping from '@/components/dashboard/settings/sections/product/ProductMapping' import Accordion from '@/components/ui/Accordion' import Divider from '@/components/ui/Divider' import { useInvoiceDetailSettings, + useAccountMapping, useProductMappingSettings, useSettings, } from '@/hook/useSettings' @@ -44,6 +46,18 @@ export default function SettingAccordion({ showButton: showInvoiceButton, } = useInvoiceDetailSettings() + const { + options: accountOptions, + settingState: accountMappingState, + changeSettings: changeAccountMapping, + submitAccountMapping, + cancelAccountMapping, + isLoading: accountMappingIsLoading, + error: accountMappingError, + showButton: showAccountMappingButton, + isDisconnected: accountMappingIsDisconnected, + } = useAccountMapping() + const accordionItems = [ { id: 'product-mapping', @@ -75,6 +89,20 @@ export default function SettingAccordion({ /> ), }, + { + id: 'account-mapping', + header: 'Account Mapping', + content: ( + + ), + }, ] const { openItems, setOpenItems } = useSettings() @@ -142,6 +170,22 @@ export default function SettingAccordion({ /> )} + {index === 2 && syncFlag && showAccountMappingButton && ( + <> + + {isOpen && !disabled && ( +
+
+ {options?.map((o) => ( + + ))} +
+
+ )} + + + ) +} + +export default function AccountMapping({ + options, + settingState, + changeSettings, + isLoading, + error, + isDisconnected, +}: AccountMappingProps) { + if (isDisconnected) { + return ( +
+ Connect to QuickBooks to manage account settings. +
+ ) + } + + if (isLoading) return + + if (error) { + return ( +
+ Could not load accounts. Reload to retry. +
+ ) + } + + return ( +
+ changeSettings('incomeAccountRef', id)} + /> + changeSettings('expenseAccountRef', id)} + /> + changeSettings('assetAccountRef', id)} + /> +
+ ) +} diff --git a/src/db/service/token.service.ts b/src/db/service/token.service.ts index 256ca070..38d986f6 100644 --- a/src/db/service/token.service.ts +++ b/src/db/service/token.service.ts @@ -1,4 +1,5 @@ 'use server' +import APIError from '@/app/api/core/exceptions/api' import { db } from '@/db' import { PortalConnectionWithSettingType, @@ -10,6 +11,7 @@ import { WorkspaceResponse } from '@/type/common' import { CopilotAPI } from '@/utils/copilotAPI' import { IntuitAPITokensType } from '@/utils/intuitAPI' import { and, asc, eq, isNotNull, isNull, sql } from 'drizzle-orm' +import httpStatus from 'http-status' export const getPortalConnection = async ( portalId: string, @@ -108,7 +110,8 @@ export const getPortalTokens = async ( portalId: string, ): Promise => { const portalConnection = await getPortalConnection(portalId) - if (!portalConnection) throw new Error('Portal connection not found') + if (!portalConnection) + throw new APIError(httpStatus.NOT_FOUND, 'Portal connection not found') return { accessToken: portalConnection.accessToken, 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/helper/swr.helper.ts b/src/helper/swr.helper.ts index 4665ffae..da17a058 100644 --- a/src/helper/swr.helper.ts +++ b/src/helper/swr.helper.ts @@ -5,8 +5,13 @@ import useSWR, { SWRConfiguration } from 'swr' // SWR handles its own error-retry; aborts here would just produce false negatives. const fetcher = (url: string) => getFetcher(url, {}, { timeoutMs: null }) -export const useSwrHelper = (key: any, opts: SWRConfiguration = {}) => - useSWR(key, fetcher, { +export const useSwrHelper = ( + key: any, + opts: SWRConfiguration = {}, +) => + useSWR(key, fetcher, { revalidateOnFocus: false, + suspense: true, + revalidateOnMount: false, ...opts, }) diff --git a/src/hook/useSettings.ts b/src/hook/useSettings.ts index f8b4251a..68acd895 100644 --- a/src/hook/useSettings.ts +++ b/src/hook/useSettings.ts @@ -9,10 +9,11 @@ import { import { getTimeInterval } from '@/utils/common' import { QBO_ITEM_NAME_MAX_LENGTH } from '@/utils/string' import { ProductMappingItemType } from '@/db/schema/qbProductSync' -import { postFetcher } from '@/helper/fetch.helper' +import { patchFetcher, postFetcher } from '@/helper/fetch.helper' import { mutate } from 'swr' import equal from 'deep-equal' import { + AccountOption, InvoiceSettingType, ProductSettingType, SettingType, @@ -75,10 +76,6 @@ export const useProductMappingSettings = () => { const { data: setting } = useSwrHelper( `/api/quickbooks/setting?type=${SettingType.PRODUCT}&token=${token}`, - { - suspense: true, - revalidateOnMount: false, - }, ) const changeSettings = async ( @@ -338,26 +335,14 @@ export const useProductTableSetting = ( const { token, setAppParams, syncFlag } = useApp() const { data: products } = useSwrHelper( `/api/quickbooks/product/flatten?token=${token}`, - { - suspense: true, - revalidateOnMount: false, - }, ) const { data: quickbooksItems } = useSwrHelper( syncFlag ? `/api/quickbooks/product/qb/item?token=${token}` : null, - { - suspense: true, - revalidateOnMount: false, - }, ) const { data: mappedItems } = useSwrHelper( `/api/quickbooks/product/map?token=${token}`, - { - suspense: true, - revalidateOnMount: false, - }, ) useEffect(() => { @@ -515,10 +500,7 @@ export const useInvoiceDetailSettings = () => { data: setting, error, isLoading, - } = useSwrHelper(`/api/quickbooks/setting?type=invoice&token=${token}`, { - suspense: true, - revalidateOnMount: false, - }) + } = useSwrHelper(`/api/quickbooks/setting?type=invoice&token=${token}`) const changeSettings = async ( flag: keyof InvoiceSettingType, @@ -583,10 +565,111 @@ export const useInvoiceDetailSettings = () => { } } +export type AccountMappingState = { + incomeAccountRef: string + expenseAccountRef: string + assetAccountRef: string +} + +export type AccountsListResponseUi = { + options: { + income: AccountOption[] + expense: AccountOption[] + asset: AccountOption[] + } + selected: AccountMappingState +} + +export const useAccountMapping = () => { + const { token, syncFlag, portalConnectionStatus } = useApp() + // Skip the /accounts fetch when QB isn't connected or sync is off — the + // endpoint requires a live portal connection and would 404 otherwise. + const isDisconnected = !syncFlag || !portalConnectionStatus + const [settingState, setSettingState] = useState({ + incomeAccountRef: '', + expenseAccountRef: '', + assetAccountRef: '', + }) + const [showButton, setShowButton] = useState(false) + const [initialState, setInitialState] = useState< + AccountMappingState | undefined + >() + + const { data, error, isLoading } = useSwrHelper( + isDisconnected ? null : `/api/quickbooks/accounts?token=${token}`, + // Override the shared suspense default: keep loading/error state inline + // to this accordion section so the dashboard doesn't fall back to the + // page-level loading.tsx full-screen spinner on first open. + { suspense: false, revalidateOnMount: true }, + ) + + useEffect(() => { + if (data?.selected) { + setSettingState(data.selected) + setInitialState(structuredClone(data.selected)) + } + }, [data]) + + useEffect(() => { + if (!initialState) return + setShowButton(!equal(initialState, settingState)) + }, [settingState, initialState]) + + const changeSettings = (field: keyof AccountMappingState, value: string) => { + setSettingState((prev) => ({ ...prev, [field]: value })) + } + + const submitAccountMapping = async () => { + if (!initialState) return + setShowButton(false) + try { + // Send only changed fields to keep the PATCH minimal. + const payload: Partial = {} + if (settingState.incomeAccountRef !== initialState.incomeAccountRef) + payload.incomeAccountRef = settingState.incomeAccountRef + if (settingState.expenseAccountRef !== initialState.expenseAccountRef) + payload.expenseAccountRef = settingState.expenseAccountRef + if (settingState.assetAccountRef !== initialState.assetAccountRef) + payload.assetAccountRef = settingState.assetAccountRef + + await patchFetcher( + `/api/quickbooks/accounts?token=${token}`, + { 'content-type': 'application/json' }, + payload, + { timeoutMs: null }, + ) + mutate(`/api/quickbooks/accounts?token=${token}`) + setInitialState(structuredClone(settingState)) + } catch (err) { + setShowButton(true) + console.error('Error submitting Account Mapping settings', err) + } + } + + const cancelAccountMapping = () => { + setShowButton(false) + if (initialState) setSettingState(initialState) + } + + return { + options: data?.options, + settingState, + changeSettings, + submitAccountMapping, + cancelAccountMapping, + error, + isLoading, + showButton, + isDisconnected, + } +} + export const useSettings = () => { const { isEnabled } = useApp() const [openItems, setOpenItems] = useState( - isEnabled ? ['product-mapping'] : ['product-mapping', 'invoice-detail'], + isEnabled + ? ['product-mapping'] + : ['product-mapping', 'invoice-detail', 'account-mapping'], ) return { openItems, setOpenItems } 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..a48b84e3 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,45 @@ 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 — one flat query per AccountType is the only shape + // that parses. + const baseCols = QB_ACCOUNT_COLUMNS.join(', ') + const incomeQuery = `SELECT ${baseCols}, AccountSubType FROM Account WHERE AccountType = 'Income' AND AccountSubType = 'SalesOfProductIncome' AND Active = true` + const expenseQuery = `SELECT ${baseCols} FROM Account WHERE AccountType = 'Expense' AND Active = true` + const assetQuery = `SELECT ${baseCols} FROM Account WHERE AccountType = 'Bank' AND Active = true` + + const [incomeRaw, expenseRaw, assetRaw] = await Promise.all([ + this.customQuery(incomeQuery), + this.customQuery(expenseQuery), + this.customQuery(assetQuery), + ]) + + // customQuery returns undefined when QBO responds with no QueryResponse + // key (degenerate but observed in some empty-realm states). Treat as an + // empty bucket rather than a hard error — the UI shows "No matching + // accounts in QuickBooks" instead of a misleading "Could not load." + const parse = (raw: unknown): QBAccountRowType[] => { + if (raw === undefined || raw === null) return [] + const parsed = QBAccountQueryResponseSchema.parse(raw) + return parsed.Account ?? [] + } + + return { + income: parse(incomeRaw), + expense: parse(expenseRaw), + asset: parse(assetRaw), + } + } + /** * Either displayName or id must be provided */ @@ -993,6 +1033,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 diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 1ed048ba..0d74d8d3 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -120,6 +120,11 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) { // base Assembly invoice number is used; override per-test to exercise the // suffix-walk path. findInvoicesByDocNumberPrefix: vi.fn().mockResolvedValue([]), + getAccountsForProductMapping: vi.fn().mockResolvedValue({ + income: [], + expense: [], + asset: [], + }), ...overrides, } } diff --git a/test/integration/quickbooks/accounts/listAccounts.test.ts b/test/integration/quickbooks/accounts/listAccounts.test.ts new file mode 100644 index 00000000..0024bf6d --- /dev/null +++ b/test/integration/quickbooks/accounts/listAccounts.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { testApiHandler } from 'next-test-api-route-handler' + +import * as appHandler from '@/app/api/quickbooks/accounts/route' +import { truncateAllTestTables } from '@test/helpers/testDb' +import { createMockIntuitAPI, installMockApis } from '@test/helpers/mocks' +import { + seedHealthyPortal, + TEST_ASSET_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, + TEST_INCOME_ACCOUNT_REF, + TEST_WEBHOOK_TOKEN, +} from '@test/helpers/seed' + +describe('GET /api/quickbooks/accounts', () => { + beforeEach(async () => { + await truncateAllTestTables() + installMockApis({ + intuit: createMockIntuitAPI({ + getAccountsForProductMapping: vi.fn().mockResolvedValue({ + income: [ + { + Id: '100', + Name: 'Sales', + SyncToken: '0', + Active: true, + AccountType: 'Income', + }, + { + Id: '200', + Name: 'Service Income', + SyncToken: '0', + Active: true, + AccountType: 'Income', + }, + ], + expense: [ + { + Id: '102', + Name: 'Cost of Goods', + SyncToken: '0', + Active: true, + AccountType: 'Expense', + }, + ], + asset: [ + { + Id: '101', + Name: 'Inventory Asset', + SyncToken: '0', + Active: true, + AccountType: 'OtherCurrentAsset', + }, + ], + }), + }), + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("returns options grouped by bucket and the portal's currently-selected refs", async () => { + await seedHealthyPortal() + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/accounts?token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ method: 'GET' }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.options.income).toEqual([ + { id: '100', name: 'Sales' }, + { id: '200', name: 'Service Income' }, + ]) + expect(body.options.expense).toEqual([ + { id: '102', name: 'Cost of Goods' }, + ]) + expect(body.options.asset).toEqual([ + { id: '101', name: 'Inventory Asset' }, + ]) + expect(body.selected).toEqual({ + incomeAccountRef: TEST_INCOME_ACCOUNT_REF, + expenseAccountRef: TEST_EXPENSE_ACCOUNT_REF, + assetAccountRef: TEST_ASSET_ACCOUNT_REF, + }) + }, + }) + }) + + it('returns 401 without a token', async () => { + await testApiHandler({ + appHandler, + url: `/api/quickbooks/accounts`, + test: async ({ fetch }) => { + const res = await fetch({ method: 'GET' }) + expect(res.status).toBe(401) + }, + }) + }) +}) diff --git a/test/integration/quickbooks/accounts/updateAccountRefs.test.ts b/test/integration/quickbooks/accounts/updateAccountRefs.test.ts new file mode 100644 index 00000000..3d571cc1 --- /dev/null +++ b/test/integration/quickbooks/accounts/updateAccountRefs.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { testApiHandler } from 'next-test-api-route-handler' +import { eq } from 'drizzle-orm' + +import * as appHandler from '@/app/api/quickbooks/accounts/route' +import { db } from '@/db' +import { QBPortalConnection } from '@/db/schema/qbPortalConnections' +import { truncateAllTestTables } from '@test/helpers/testDb' +import { installMockApis } from '@test/helpers/mocks' +import { + seedHealthyPortal, + TEST_PORTAL_ID, + TEST_INCOME_ACCOUNT_REF, + TEST_EXPENSE_ACCOUNT_REF, + TEST_ASSET_ACCOUNT_REF, + TEST_WEBHOOK_TOKEN, +} from '@test/helpers/seed' + +describe('PATCH /api/quickbooks/accounts', () => { + beforeEach(async () => { + await truncateAllTestTables() + installMockApis() + }) + afterEach(() => vi.clearAllMocks()) + + it('updates only the income ref when only income is provided', async () => { + await seedHealthyPortal() + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/accounts?token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ + method: 'PATCH', + body: JSON.stringify({ incomeAccountRef: '500' }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.portalConnection.incomeAccountRef).toBe('500') + expect(body.portalConnection.accessToken).toBeUndefined() + expect(body.portalConnection.refreshToken).toBeUndefined() + }, + }) + + const rows = await db + .select() + .from(QBPortalConnection) + .where(eq(QBPortalConnection.portalId, TEST_PORTAL_ID)) + expect(rows[0].incomeAccountRef).toBe('500') + expect(rows[0].expenseAccountRef).toBe(TEST_EXPENSE_ACCOUNT_REF) + expect(rows[0].assetAccountRef).toBe(TEST_ASSET_ACCOUNT_REF) + }) + + it('rejects empty body with 422', async () => { + await seedHealthyPortal() + await testApiHandler({ + appHandler, + url: `/api/quickbooks/accounts?token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ + method: 'PATCH', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(422) + }, + }) + }) + + it('updates all three refs when all are provided', async () => { + await seedHealthyPortal() + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/accounts?token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ + method: 'PATCH', + body: JSON.stringify({ + incomeAccountRef: '700', + expenseAccountRef: '701', + assetAccountRef: '702', + }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.portalConnection.incomeAccountRef).toBe('700') + expect(body.portalConnection.expenseAccountRef).toBe('701') + expect(body.portalConnection.assetAccountRef).toBe('702') + expect(body.portalConnection.accessToken).toBeUndefined() + expect(body.portalConnection.refreshToken).toBeUndefined() + expect(body.portalConnection.tokenType).toBeUndefined() + }, + }) + + const rows = await db + .select() + .from(QBPortalConnection) + .where(eq(QBPortalConnection.portalId, TEST_PORTAL_ID)) + expect(rows[0].incomeAccountRef).toBe('700') + expect(rows[0].expenseAccountRef).toBe('701') + expect(rows[0].assetAccountRef).toBe('702') + }) + + it("cannot modify another portal's row (tenant isolation)", async () => { + // CopilotAPI mock always decrypts to TEST_PORTAL_ID, so this verifies the + // WHERE-clause scope works — not that a foreign token would be rejected. + await seedHealthyPortal() + const OTHER = 'other-portal-99999999' + await seedHealthyPortal({ + portal: { portalId: OTHER, intuitRealmId: 'other-realm' }, + setting: { portalId: OTHER }, + }) + + await testApiHandler({ + appHandler, + url: `/api/quickbooks/accounts?token=${TEST_WEBHOOK_TOKEN}`, + test: async ({ fetch }) => { + const res = await fetch({ + method: 'PATCH', + body: JSON.stringify({ incomeAccountRef: '700' }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(200) + }, + }) + + const a = await db + .select() + .from(QBPortalConnection) + .where(eq(QBPortalConnection.portalId, TEST_PORTAL_ID)) + expect(a[0].incomeAccountRef).toBe('700') + + const b = await db + .select() + .from(QBPortalConnection) + .where(eq(QBPortalConnection.portalId, OTHER)) + expect(b[0].incomeAccountRef).toBe(TEST_INCOME_ACCOUNT_REF) + }) + + it('returns 401 without a token', async () => { + await testApiHandler({ + appHandler, + url: `/api/quickbooks/accounts`, + test: async ({ fetch }) => { + const res = await fetch({ + method: 'PATCH', + body: JSON.stringify({ incomeAccountRef: '1' }), + headers: { 'content-type': 'application/json' }, + }) + expect(res.status).toBe(401) + }, + }) + }) +}) diff --git a/test/integration/setup.ts b/test/integration/setup.ts index caff9e7e..7e26c988 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -12,14 +12,28 @@ import { vi } from 'vitest' * modules (copilot-node-sdk has an ESM directory-import that breaks). * - Sentry has to be stubbed because withRetry.ts calls * `scope.addEventProcessor(...)` inside Sentry.withScope. + * + * Pin the vi.fn() factories on globalThis so multiple setupFile evaluations + * under isolate:false reuse the same mock identity. See docs/vitest-gotchas.md. */ +type MockSingletons = { + CopilotAPI?: ReturnType + IntuitAPI?: ReturnType +} +const g = globalThis as typeof globalThis & { + __qbsync_test_mocks?: MockSingletons +} +g.__qbsync_test_mocks ??= {} +g.__qbsync_test_mocks.CopilotAPI ??= vi.fn() +g.__qbsync_test_mocks.IntuitAPI ??= vi.fn() + vi.mock('@/utils/copilotAPI', () => ({ - CopilotAPI: vi.fn(), + CopilotAPI: g.__qbsync_test_mocks!.CopilotAPI!, })) vi.mock('@/utils/intuitAPI', () => ({ - default: vi.fn(), + default: g.__qbsync_test_mocks!.IntuitAPI!, // Named export used by src/utils/error.ts to detect Intuit-sourced APIErrors // when unwrapping error messages in the webhook catch block. IntuitAPIErrorMessage: '#IntuitAPIErrorMessage#', diff --git a/test/unit/api/quickbooks/accounts/accounts.service.test.ts b/test/unit/api/quickbooks/accounts/accounts.service.test.ts new file mode 100644 index 00000000..3972fc0c --- /dev/null +++ b/test/unit/api/quickbooks/accounts/accounts.service.test.ts @@ -0,0 +1,110 @@ +/** + * AccountService.listAccountsForProductMapping loads the portal connection + * (for selected refs + IntuitAPI tokens), delegates to IntuitAPI for the + * three pick-lists, and strips SyncToken/Active from the response. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn(), + captureMessage: vi.fn(), + captureException: vi.fn(), +})) +vi.mock('@/utils/logger', () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})) + +const intuit = { + getAccountsForProductMapping: vi.fn(), +} +// Mock impl uses `function` (not arrow) so `new IntuitAPI(...)` is callable. +vi.mock('@/utils/intuitAPI', () => ({ + default: vi.fn().mockImplementation(function (this: unknown) { + return intuit + }), + IntuitAPIErrorMessage: '#IntuitAPIErrorMessage#', +})) + +const tokens = { + accessToken: 'access', + refreshToken: 'refresh', + intuitRealmId: 'realm-1', + incomeAccountRef: '100', + expenseAccountRef: '102', + assetAccountRef: '101', + serviceItemRef: null, + clientFeeRef: null, +} + +const getPortalTokens = vi.fn(async (_portalId: string) => tokens) +vi.mock('@/db/service/token.service', () => ({ + getPortalTokens: (portalId: string) => getPortalTokens(portalId), +})) + +import { AccountService } from '@/app/api/quickbooks/accounts/accounts.service' +import User from '@/app/api/core/models/User.model' + +const fakeUser = new User('test-token', { + workspaceId: 'portal-1', + internalUserId: 'user-1', +}) + +describe('AccountService.listAccountsForProductMapping', () => { + beforeEach(() => { + vi.clearAllMocks() + getPortalTokens.mockResolvedValue(tokens) + intuit.getAccountsForProductMapping.mockResolvedValue({ + income: [ + { + Id: '100', + Name: 'Sales', + SyncToken: '0', + Active: true, + AccountType: 'Income', + }, + { + Id: '200', + Name: 'Service Income', + SyncToken: '0', + Active: true, + AccountType: 'Income', + }, + ], + expense: [ + { + Id: '102', + Name: 'Cost of Goods', + SyncToken: '0', + Active: true, + AccountType: 'Expense', + }, + ], + asset: [ + { + Id: '101', + Name: 'Inventory Asset', + SyncToken: '0', + Active: true, + AccountType: 'OtherCurrentAsset', + }, + ], + }) + }) + + it('returns options grouped by bucket with only id+name, plus selected refs', async () => { + const svc = new AccountService(fakeUser) + const out = await svc.listAccountsForProductMapping() + + expect(out.options.income).toEqual([ + { id: '100', name: 'Sales' }, + { id: '200', name: 'Service Income' }, + ]) + expect(out.options.expense).toEqual([{ id: '102', name: 'Cost of Goods' }]) + expect(out.options.asset).toEqual([{ id: '101', name: 'Inventory Asset' }]) + expect(out.selected).toEqual({ + incomeAccountRef: '100', + expenseAccountRef: '102', + assetAccountRef: '101', + }) + }) +}) diff --git a/test/unit/api/quickbooks/token/updateAccountRefs.test.ts b/test/unit/api/quickbooks/token/updateAccountRefs.test.ts new file mode 100644 index 00000000..3a61e94e --- /dev/null +++ b/test/unit/api/quickbooks/token/updateAccountRefs.test.ts @@ -0,0 +1,98 @@ +/** + * TokenService.updateAccountRefs writes incoming refs to qb_portal_connections + * scoped by portalId. The dashboard is the only caller and every ref came from + * GET /api/quickbooks/accounts, so no per-ref QBO validation runs here. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn(), + captureMessage: vi.fn(), + captureException: vi.fn(), +})) +vi.mock('@/utils/logger', () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})) + +// Stub IntuitAPI + CopilotAPI so importing TokenService doesn't transitively +// load copilot-node-sdk (which has an ESM directory-import that breaks under +// vitest). See docs/vitest-gotchas.md. +vi.mock('@/utils/intuitAPI', () => ({ + default: vi.fn(), + IntuitAPIErrorMessage: '#IntuitAPIErrorMessage#', +})) +vi.mock('@/utils/copilotAPI', () => ({ + CopilotAPI: vi.fn(), +})) + +import { TokenService } from '@/app/api/quickbooks/token/token.service' +import APIError from '@/app/api/core/exceptions/api' +import User from '@/app/api/core/models/User.model' + +const fakeUser = new User('test-token', { + workspaceId: 'portal-1', + internalUserId: 'user-1', +}) + +function makeService(updateReturn: unknown = { id: 'pc-1' }) { + const service = new TokenService(fakeUser) + // Spy on the inherited update so we can assert payload + WHERE clause without + // touching Postgres. + ;(service as any).updateQBPortalConnection = vi + .fn() + .mockResolvedValue(updateReturn) + return service +} + +describe('TokenService.updateAccountRefs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes only the provided refs, scoped by portal_id', async () => { + const service = makeService() + await service.updateAccountRefs({ incomeAccountRef: '300' }) + + expect((service as any).updateQBPortalConnection).toHaveBeenCalledTimes(1) + const [accountRefs, where] = (service as any).updateQBPortalConnection.mock + .calls[0] + expect(accountRefs).toEqual({ incomeAccountRef: '300' }) + const chunks = (where as { queryChunks?: unknown[] }).queryChunks ?? [] + const referencesPortalId = chunks.some( + (c) => (c as { name?: string })?.name === 'portal_id', + ) + expect(referencesPortalId).toBe(true) + }) + + it('writes all three refs when all are provided', async () => { + const service = makeService() + await service.updateAccountRefs({ + incomeAccountRef: '300', + expenseAccountRef: '301', + assetAccountRef: '302', + }) + const [accountRefs] = (service as any).updateQBPortalConnection.mock + .calls[0] + expect(accountRefs).toEqual({ + incomeAccountRef: '300', + expenseAccountRef: '301', + assetAccountRef: '302', + }) + }) + + it('throws 500 when the update matches no row (portal connection missing)', async () => { + // updateQBPortalConnection yields a falsy value when WHERE matches zero + // rows (Drizzle destructures `.returning()`'s first element). Pass null + // — undefined would collide with makeService's default parameter. + const service = makeService(null) + await expect( + service.updateAccountRefs({ incomeAccountRef: '300' }), + ).rejects.toBeInstanceOf(APIError) + }) + + it('rejects an empty payload via the schema refine', async () => { + const service = makeService() + await expect(service.updateAccountRefs({})).rejects.toThrow() + expect((service as any).updateQBPortalConnection).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/utils/intuitAPI.accounts.test.ts b/test/unit/utils/intuitAPI.accounts.test.ts new file mode 100644 index 00000000..de3ee2c9 --- /dev/null +++ b/test/unit/utils/intuitAPI.accounts.test.ts @@ -0,0 +1,118 @@ +/** + * Unit tests for IntuitAPI.getAccountsForProductMapping — verifies the three + * SQL queries are built with the agreed AccountType / AccountSubType filters + * and that the result is grouped into { income, expense, asset } with + * Active=true filtering applied by QBO's WHERE clause (not in JS). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@sentry/nextjs', () => ({ + withScope: vi.fn(), + captureMessage: vi.fn(), + captureException: vi.fn(), +})) + +vi.mock('@/utils/logger', () => ({ + default: { info: vi.fn(), error: vi.fn() }, +})) + +vi.mock('@/helper/fetch.helper', () => ({ + getFetcher: vi.fn(), + postFetcher: vi.fn(), +})) + +import IntuitAPI, { IntuitAPITokensType } from '@/utils/intuitAPI' + +const baseTokens: IntuitAPITokensType = { + accessToken: 'access', + refreshToken: 'refresh', + intuitRealmId: 'realm-1', + incomeAccountRef: 'income', + expenseAccountRef: 'expense', + assetAccountRef: 'asset', + serviceItemRef: 'service', + clientFeeRef: 'client-fee', +} + +type Row = { + Id: string + Name: string + SyncToken: string + Active: boolean + AccountType: string + AccountSubType?: string +} +const row = ( + Id: string, + Name: string, + AccountType: string, + AccountSubType?: string, +): Row => ({ + Id, + Name, + SyncToken: '0', + Active: true, + AccountType, + ...(AccountSubType ? { AccountSubType } : {}), +}) + +function makeApi(perQueryResponse: (q: string) => unknown) { + const api = new IntuitAPI(baseTokens) + const customQuery = vi.fn(async (q: string) => perQueryResponse(q)) + ;(api as unknown as { customQuery: unknown }).customQuery = customQuery + return { api, customQuery } +} + +describe('IntuitAPI#getAccountsForProductMapping', () => { + beforeEach(() => vi.clearAllMocks()) + + it('issues three queries (income/expense/asset) with no OR / parens / IN', async () => { + const { api, customQuery } = makeApi((q) => { + if (q.includes("AccountType = 'Income'")) + return { + Account: [row('1', 'Sales', 'Income', 'SalesOfProductIncome')], + } + if (q.includes("AccountType = 'Expense'")) + return { Account: [row('2', 'COGS', 'Expense')] } + // Bank (asset bucket) + return { Account: [row('3', 'Checking', 'Bank')] } + }) + + const out = await (api as any).getAccountsForProductMapping() + + expect(customQuery).toHaveBeenCalledTimes(3) + const queries = customQuery.mock.calls.map((c) => c[0] as string) + + // QBO's parser rejects OR / parens / IN on AccountType, so every query is + // a flat AND chain. Income narrows by AccountSubType in SQL. + expect(queries[0]).toContain("AccountType = 'Income'") + expect(queries[0]).toContain("AccountSubType = 'SalesOfProductIncome'") + expect(queries[0]).not.toContain('OR') + expect(queries[0]).not.toContain('(') + + expect(queries[1]).toContain("AccountType = 'Expense'") + expect(queries[2]).toContain("AccountType = 'Bank'") + + expect(out).toEqual({ + income: [row('1', 'Sales', 'Income', 'SalesOfProductIncome')], + expense: [row('2', 'COGS', 'Expense')], + asset: [row('3', 'Checking', 'Bank')], + }) + }) + + it('returns empty arrays when QBO returns no Account key for a bucket', async () => { + const { api } = makeApi(() => ({})) // QueryResponse with no Account + const out = await (api as any).getAccountsForProductMapping() + expect(out).toEqual({ income: [], expense: [], asset: [] }) + }) + + it('treats a missing QueryResponse (undefined customQuery result) as an empty bucket', async () => { + // Regression guard: an earlier version threw APIError(400) when any of + // the three customQuery calls returned undefined (e.g., realm with no + // QueryResponse key). That would surface as "Could not load accounts" + // in the UI even though the correct semantic is an empty dropdown. + const { api } = makeApi(() => undefined) + const out = await (api as any).getAccountsForProductMapping() + expect(out).toEqual({ income: [], expense: [], asset: [] }) + }) +}) diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts index 4b898a4a..a07a90d3 100644 --- a/test/unit/utils/intuitAPI.responses.test.ts +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -82,6 +82,7 @@ describe('IntuitAPI customQuery-based reads', () => { Name: 'Sales of Product Income', SyncToken: '0', Active: true, + AccountType: 'Income', }) }) @@ -163,7 +164,15 @@ describe('IntuitAPI customQuery-based reads', () => { it('getAnAccount returns the account when QBO responds with a single match', async () => { vi.mocked(getFetcher).mockResolvedValue( queryResponse({ - Account: [{ Id: '7', Name: 'Assets', SyncToken: '0', Active: true }], + Account: [ + { + Id: '7', + Name: 'Assets', + SyncToken: '0', + Active: true, + AccountType: 'OtherCurrentAsset', + }, + ], }), ) @@ -363,7 +372,13 @@ describe('IntuitAPI POST-based writes', () => { it('createAccount returns the created account when QBO accepts the request', async () => { vi.mocked(postFetcher).mockResolvedValue({ - Account: { Id: '300', Name: 'New Asset', SyncToken: '0', Active: true }, + Account: { + Id: '300', + Name: 'New Asset', + SyncToken: '0', + Active: true, + AccountType: 'Asset', + }, }) const api = makeApi()