From 32e84a7ffd70a79597f80ddaf02a1ce40c1c5d22 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 13:17:28 +0545 Subject: [PATCH 01/18] refactor(OUT-3543): decompose Item/Account response schemas into row + envelope --- src/type/dto/intuitAPI.dto.ts | 36 +++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 2082df2d..2caff614 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -96,18 +96,30 @@ export type QBItemFullUpdatePayloadType = z.infer< typeof QBItemFullUpdatePayloadSchema > -export const QBItemResponseSchema = z.object({ - Item: z.object({ +export const QBItemRowSchema = z + .object({ Id: z.string(), SyncToken: z.string(), Name: z.string(), ClassRef: QBNameValueSchema.optional(), Active: z.boolean(), UnitPrice: z.number(), - }), + }) + .passthrough() +export type QBItemRowType = z.infer + +export const QBItemResponseSchema = z.object({ + Item: QBItemRowSchema, }) export type QBItemResponseType = z.infer +// Envelope returned by `customQuery` for `SELECT ... FROM Item`. Item is +// optional because QBO omits the key when there are zero results. +export const QBItemQueryResponseSchema = z.object({ + Item: z.array(QBItemRowSchema).optional(), +}) +export type QBItemQueryResponseType = z.infer + export const QBPaymentCreatePayloadSchema = z.object({ TotalAmt: z.number(), CustomerRef: z.object({ @@ -164,16 +176,28 @@ export type QBAccountUpdatePayloadType = z.infer< typeof QBAccountUpdatePayloadSchema > -export const QBAccountResponseSchema = z.object({ - Account: z.object({ +export const QBAccountRowSchema = z + .object({ Id: z.string(), Name: z.string(), SyncToken: z.string(), Active: z.boolean(), - }), + }) + .passthrough() +export type QBAccountRowType = z.infer + +export const QBAccountResponseSchema = z.object({ + Account: QBAccountRowSchema, }) export type QBAccountResponseType = z.infer +export const QBAccountQueryResponseSchema = z.object({ + Account: z.array(QBAccountRowSchema).optional(), +}) +export type QBAccountQueryResponseType = z.infer< + typeof QBAccountQueryResponseSchema +> + export const QBPurchaseCreatePayloadSchema = z.object({ PaymentType: z.literal('Cash'), AccountRef: QBNameValueSchema, From 785c6b024a1ed95b940a46abd90083f2bc01dea3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 13:20:33 +0545 Subject: [PATCH 02/18] refactor(OUT-3543): rename QBInvoiceResponseSchema to row, add envelope + delete envelope --- src/type/dto/intuitAPI.dto.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 2caff614..1b308df5 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -250,14 +250,41 @@ export type CustomerQueryResponseType = z.infer< typeof CustomerQueryResponseSchema > +export const QBInvoiceRowSchema = z + .object({ + Id: z.string(), + SyncToken: z.string(), + DocNumber: z.string().optional(), + Balance: z.number().optional(), + TotalAmt: z.number().optional(), + TxnDate: z.string().optional(), + DueDate: z.string().optional(), + PrivateNote: z.string().optional(), + CustomerRef: QBNameValueSchema.optional(), + }) + .passthrough() +export type QBInvoiceRowType = z.infer + +// Envelope returned by createInvoice / invoiceSparseUpdate / voidInvoice. export const QBInvoiceResponseSchema = z.object({ - Id: z.string(), - Balance: z.number(), - PrivateNote: z.string().optional(), - SyncToken: z.string(), + Invoice: QBInvoiceRowSchema, + time: z.string().optional(), }) export type QBInvoiceResponseType = z.infer +// Envelope returned by deleteInvoice (no full row, just deletion confirmation). +export const QBInvoiceDeleteResponseSchema = z.object({ + Invoice: z.object({ + Id: z.string(), + status: z.string().optional(), + domain: z.string().optional(), + }), + time: z.string().optional(), +}) +export type QBInvoiceDeleteResponseType = z.infer< + typeof QBInvoiceDeleteResponseSchema +> + export const QBPurchaseResponseSchema = z.object({ Id: z.string(), TotalAmt: z.number(), From e04676d8200f598892e884a4dd5060eccf096ca2 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 13:23:42 +0545 Subject: [PATCH 03/18] feat(OUT-3543): add Payment/Purchase response envelope + row + delete schemas --- src/type/dto/intuitAPI.dto.ts | 72 ++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 1b308df5..e764f130 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -285,12 +285,80 @@ export type QBInvoiceDeleteResponseType = z.infer< typeof QBInvoiceDeleteResponseSchema > +export const QBPurchaseRowSchema = z + .object({ + Id: z.string(), + SyncToken: z.string().optional(), + TotalAmt: z.number(), + TxnDate: z.string().optional(), + AccountRef: QBNameValueSchema.optional(), + PaymentType: z.string().optional(), + }) + .passthrough() +export type QBPurchaseRowType = z.infer + export const QBPurchaseResponseSchema = z.object({ - Id: z.string(), - TotalAmt: z.number(), + Purchase: QBPurchaseRowSchema, + time: z.string().optional(), }) export type QBPurchaseResponseType = z.infer +export const QBPurchaseDeleteResponseSchema = z.object({ + Purchase: z.object({ + Id: z.string(), + status: z.string().optional(), + domain: z.string().optional(), + }), + time: z.string().optional(), +}) +export type QBPurchaseDeleteResponseType = z.infer< + typeof QBPurchaseDeleteResponseSchema +> + +export const QBPaymentRowSchema = z + .object({ + Id: z.string(), + SyncToken: z.string().optional(), + TotalAmt: z.number(), + TxnDate: z.string().optional(), + CustomerRef: QBNameValueSchema.optional(), + Line: z + .array( + z.object({ + Amount: z.number().optional(), + LinkedTxn: z + .array( + z.object({ + TxnId: z.string(), + TxnType: z.string(), + }), + ) + .optional(), + }), + ) + .optional(), + }) + .passthrough() +export type QBPaymentRowType = z.infer + +export const QBPaymentResponseSchema = z.object({ + Payment: QBPaymentRowSchema, + time: z.string().optional(), +}) +export type QBPaymentResponseType = z.infer + +export const QBPaymentDeleteResponseSchema = z.object({ + Payment: z.object({ + Id: z.string(), + status: z.string().optional(), + domain: z.string().optional(), + }), + time: z.string().optional(), +}) +export type QBPaymentDeleteResponseType = z.infer< + typeof QBPaymentDeleteResponseSchema +> + export const QBItemsResponseSchema = z.array( z.object({ Id: z.string(), From 46fc2e0df0d22025ea6363710123c738270dd6dc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 13:27:39 +0545 Subject: [PATCH 04/18] refactor(OUT-3543): replace inline IntuitAPI interfaces with dto z.infer types --- src/utils/intuitAPI.ts | 57 +++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index e00271d0..0091efea 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -15,10 +15,13 @@ import { QBPurchaseCreatePayloadType, QBDeletePayloadType, QBDestructiveInvoicePayloadSchema, - QBNameValueSchemaType, + QBItemRowType, + QBItemQueryResponseSchema, QBItemResponseType, QBItemResponseSchema, QBAccountUpdatePayloadType, + QBAccountRowType, + QBAccountQueryResponseSchema, QBAccountResponseType, QBAccountResponseSchema, CompanyInfoType, @@ -26,6 +29,18 @@ import { CustomerQueryResponseType, CustomerQueryResponseSchema, QBItemsResponseSchema, + QBInvoiceResponseType, + QBInvoiceResponseSchema, + QBInvoiceDeleteResponseType, + QBInvoiceDeleteResponseSchema, + QBPaymentResponseType, + QBPaymentResponseSchema, + QBPaymentDeleteResponseType, + QBPaymentDeleteResponseSchema, + QBPurchaseResponseType, + QBPurchaseResponseSchema, + QBPurchaseDeleteResponseType, + QBPurchaseDeleteResponseSchema, SingleIdAndTokenResponseSchema, } from '@/type/dto/intuitAPI.dto' import { escapeForQBQuery, getNameAsCustomer } from '@/utils/string' @@ -44,22 +59,6 @@ export type IntuitAPITokensType = Pick< | 'clientFeeRef' > & { isSuspended?: boolean } -export type BaseResponseType = { - Id: string - SyncToken: string - Active: boolean -} - -export type AccountResponseType = BaseResponseType & { - Name: string -} - -export type ItemResponseType = BaseResponseType & { - Name: string - ClassRef?: QBNameValueSchemaType - UnitPrice: number -} - export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#' // Upper bound on the " (Customer) N" suffix counter when resolving a unique @@ -386,17 +385,17 @@ export default class IntuitAPI { name: string, id?: undefined, includeInactive?: boolean, - ): Promise + ): Promise async _getAnItem( name: undefined, id: string, includeInactive?: boolean, - ): Promise + ): Promise async _getAnItem( name: string, id: string, includeInactive?: boolean, - ): Promise + ): Promise async _getAnItem(name?: string, id?: string, includeInactive?: boolean) { if (!name && !id) { throw new APIError( @@ -665,17 +664,17 @@ export default class IntuitAPI { accountName: string, id?: undefined, includeInactive?: boolean, - ): Promise + ): Promise async _getAnAccount( accountName: undefined, id: string, includeInactive?: boolean, - ): Promise + ): Promise async _getAnAccount( accountName: string, id: string, includeInactive?: boolean, - ): Promise + ): Promise async _getAnAccount( accountName?: string, id?: string, @@ -830,17 +829,17 @@ export default class IntuitAPI { name: string, id?: undefined, includeInactive?: boolean, - ): Promise + ): Promise ( name: undefined, id: string, includeInactive?: boolean, - ): Promise + ): Promise ( name: string, id: string, includeInactive?: boolean, - ): Promise + ): Promise } = this._getAnItem.bind(this) as any getAllItems = this._getAllItems.bind(this) invoiceSparseUpdate = this.wrapWithRetry(this._invoiceSparseUpdate) @@ -855,17 +854,17 @@ export default class IntuitAPI { accountName: string, id?: undefined, includeInactive?: boolean, - ): Promise + ): Promise ( accountName: undefined, id: string, includeInactive?: boolean, - ): Promise + ): Promise ( accountName: string, id: string, includeInactive?: boolean, - ): Promise + ): Promise } = this._getAnAccount.bind(this) as any createAccount = this.wrapWithRetry(this._createAccount) updateAccount = this.wrapWithRetry(this._updateAccount) From eeb45ffb9eac3a27e521f443f4ec03b0c26db05d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 13:35:07 +0545 Subject: [PATCH 05/18] feat(OUT-3543): zod-parse Item method responses --- src/utils/intuitAPI.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 0091efea..81c83231 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -29,6 +29,7 @@ import { CustomerQueryResponseType, CustomerQueryResponseSchema, QBItemsResponseSchema, + QBItemsResponseType, QBInvoiceResponseType, QBInvoiceResponseSchema, QBInvoiceDeleteResponseType, @@ -180,7 +181,7 @@ export default class IntuitAPI { return customer.Customer } - async _createItem(payload: QBItemCreatePayloadType) { + async _createItem(payload: QBItemCreatePayloadType): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#createItem | Item creation start for realmId: ${this.tokens.intuitRealmId}. Payload: `, @@ -197,11 +198,12 @@ export default class IntuitAPI { ) } + const parsed = QBItemResponseSchema.parse(item) CustomLogger.info({ - obj: { response: item.Item }, - message: `IntuitAPI#createItem | item created with Id = ${item?.Item?.Id}. Response: `, + obj: { response: parsed.Item }, + message: `IntuitAPI#createItem | item created with Id = ${parsed.Item?.Id}. Response: `, }) - return item.Item + return parsed.Item } async _getSingleIncomeAccount() { @@ -418,10 +420,14 @@ export default class IntuitAPI { if (!qbItem) return null - return qbItem.Item?.[0] + const parsed = QBItemQueryResponseSchema.parse(qbItem) + return parsed.Item?.[0] ?? null } - async _getAllItems(limit: number, columns: string[] = ['Id']) { + async _getAllItems( + limit: number, + columns: string[] = ['Id'], + ): Promise { CustomLogger.info({ message: `IntuitAPI#getAllItems | Item query start for realmId: ${this.tokens.intuitRealmId}`, }) From 405a12517792996937258e571f194f8f8406e476 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 13:43:55 +0545 Subject: [PATCH 06/18] feat(OUT-3543): zod-parse Account method responses --- src/utils/intuitAPI.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 81c83231..7995eb91 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -206,7 +206,7 @@ export default class IntuitAPI { return parsed.Item } - async _getSingleIncomeAccount() { + async _getSingleIncomeAccount(): Promise { CustomLogger.info({ message: `IntuitAPI#getSingleIncomeAccount | Income account query start for realmId: ${this.tokens.intuitRealmId}`, }) @@ -219,7 +219,8 @@ export default class IntuitAPI { 'IntuitAPI#getSingleIncomeAccount | Income account not found', ) - return qbIncomeAccountRefInfo.Account?.[0] + const parsed = QBAccountQueryResponseSchema.parse(qbIncomeAccountRefInfo) + return parsed.Account?.[0] } /** @@ -699,14 +700,17 @@ export default class IntuitAPI { queryCondition = `${queryCondition} AND Active IN (true${includeInactive ? ', false' : ''})` // By default, QB returns only active items. const query = `SELECT Id, SyncToken, Active, Name FROM Account where ${queryCondition}` - const customQuery = await this.customQuery(query) + const customQueryRes = await this.customQuery(query) - if (!customQuery) return null + if (!customQueryRes) return null - return customQuery.Account?.[0] + const parsed = QBAccountQueryResponseSchema.parse(customQueryRes) + return parsed.Account?.[0] ?? null } - async _createAccount(payload: QBAccountCreatePayloadType) { + async _createAccount( + payload: QBAccountCreatePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#createAccount | Account create start for realmId: ${this.tokens.intuitRealmId}. `, @@ -723,11 +727,12 @@ export default class IntuitAPI { ) } + const parsed = QBAccountResponseSchema.parse(account) CustomLogger.info({ - obj: { response: account.Account }, - message: `IntuitAPI#createAccount | Account created with Id = ${account.Account?.Id}. `, + obj: { response: parsed.Account }, + message: `IntuitAPI#createAccount | Account created with Id = ${parsed.Account?.Id}. `, }) - return account.Account + return parsed.Account } async _createPurchase(payload: QBPurchaseCreatePayloadType) { From c2865380b95f3695f492d7e13ea8c556daf2a677 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 14:13:07 +0545 Subject: [PATCH 07/18] feat(OUT-3543): zod-parse Customer create/update responses --- src/type/dto/intuitAPI.dto.ts | 25 ++++++++++++++----------- src/utils/intuitAPI.ts | 14 ++++++++------ 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index e764f130..ada9000b 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -234,17 +234,20 @@ export const CompanyInfoSchema = z.object({ }) export type CompanyInfoType = z.infer -export const CustomerQueryResponseSchema = z.object({ - Id: z.string(), - SyncToken: z.string(), - Active: z.boolean(), - CompanyName: z.string().optional(), - PrimaryEmailAddr: z - .object({ - Address: z.string(), - }) - .optional(), -}) +export const CustomerQueryResponseSchema = z + .object({ + Id: z.string(), + SyncToken: z.string(), + Active: z.boolean(), + CompanyName: z.string().optional(), + FullyQualifiedName: z.string().optional(), + PrimaryEmailAddr: z + .object({ + Address: z.string(), + }) + .optional(), + }) + .passthrough() export type CustomerQueryResponseType = z.infer< typeof CustomerQueryResponseSchema diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 7995eb91..e97130b1 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -174,11 +174,12 @@ export default class IntuitAPI { ) } + const parsedCustomer = CustomerQueryResponseSchema.parse(customer.Customer) CustomLogger.info({ - obj: { response: customer.Customer }, - message: `IntuitAPI#createCustomer | customer created with name = ${customer.Customer?.FullyQualifiedName}.`, + obj: { response: parsedCustomer }, + message: `IntuitAPI#createCustomer | customer created with name = ${parsedCustomer.FullyQualifiedName ?? ''}.`, }) - return customer.Customer + return parsedCustomer } async _createItem(payload: QBItemCreatePayloadType): Promise { @@ -488,11 +489,12 @@ export default class IntuitAPI { ) } + const parsedCustomer = CustomerQueryResponseSchema.parse(customer.Customer) CustomLogger.info({ - obj: { response: customer.Customer }, - message: `IntuitAPI#customerSparseUpdate | customer sparse updated with name = ${customer.Customer?.FullyQualifiedName}. `, + obj: { response: parsedCustomer }, + message: `IntuitAPI#customerSparseUpdate | customer sparse updated with name = ${parsedCustomer.FullyQualifiedName ?? ''}. `, }) - return customer.Customer + return parsedCustomer } async _itemFullUpdate( From ee80e0e3b686e38d69b8191534f2d3529fae657d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 15:17:56 +0545 Subject: [PATCH 08/18] feat(OUT-3543): zod-parse Invoice create/update/void/delete responses --- src/type/dto/intuitAPI.dto.ts | 3 +++ src/utils/intuitAPI.ts | 49 +++++++++++++++++++++++------------ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index ada9000b..3543fb01 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -377,3 +377,6 @@ export const SingleIdAndTokenResponseSchema = z.object({ Id: z.string(), SyncToken: z.string(), }) +export type SingleIdAndTokenResponseType = z.infer< + typeof SingleIdAndTokenResponseSchema +> diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index e97130b1..be157440 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -43,6 +43,7 @@ import { QBPurchaseDeleteResponseType, QBPurchaseDeleteResponseSchema, SingleIdAndTokenResponseSchema, + SingleIdAndTokenResponseType, } from '@/type/dto/intuitAPI.dto' import { escapeForQBQuery, getNameAsCustomer } from '@/utils/string' import CustomLogger from '@/utils/logger' @@ -131,7 +132,9 @@ export default class IntuitAPI { return res.QueryResponse } - async _createInvoice(payload: QBInvoiceCreatePayloadType) { + async _createInvoice( + payload: QBInvoiceCreatePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#createInvoice | invoice creation start for realmId: ${this.tokens.intuitRealmId}.`, @@ -148,11 +151,12 @@ export default class IntuitAPI { ) } + const parsed = QBInvoiceResponseSchema.parse(invoice) CustomLogger.info({ - obj: { response: invoice.Invoice }, - message: `IntuitAPI#createInvoice | invoice created with doc number = ${invoice.Invoice?.DocNumber}.`, + obj: { response: parsed.Invoice }, + message: `IntuitAPI#createInvoice | invoice created with doc number = ${parsed.Invoice?.DocNumber ?? ''}.`, }) - return invoice + return parsed } async _createCustomer( @@ -446,7 +450,9 @@ export default class IntuitAPI { return QBItemsResponseSchema.parse(qbItems.Item || []) } - async _invoiceSparseUpdate(payload: QBInvoiceSparseUpdatePayloadType) { + async _invoiceSparseUpdate( + payload: QBInvoiceSparseUpdatePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#InvoiceSparseUpdate | invoice sparse update start for realmId: ${this.tokens.intuitRealmId}. `, @@ -463,11 +469,12 @@ export default class IntuitAPI { ) } + const parsed = QBInvoiceResponseSchema.parse(invoice) CustomLogger.info({ - obj: { response: invoice.Invoice }, - message: `IntuitAPI#InvoiceSparseUpdate | invoice sparse updated for doc number = ${invoice.Invoice?.DocNumber}.`, + obj: { response: parsed.Invoice }, + message: `IntuitAPI#InvoiceSparseUpdate | invoice sparse updated for doc number = ${parsed.Invoice?.DocNumber ?? ''}.`, }) - return invoice + return parsed } async _customerSparseUpdate( @@ -577,7 +584,9 @@ export default class IntuitAPI { return payment } - async _getInvoice(invoiceNumber: string) { + async _getInvoice( + invoiceNumber: string, + ): Promise { CustomLogger.info({ obj: { invoiceNumber }, message: `IntuitAPI#getInvoice | invoice query start for realmId: ${this.tokens.intuitRealmId}. `, @@ -594,7 +603,9 @@ export default class IntuitAPI { return SingleIdAndTokenResponseSchema.parse(invoice.Invoice[0]) } - async _voidInvoice(payload: QBDestructiveInvoicePayloadSchema) { + async _voidInvoice( + payload: QBDestructiveInvoicePayloadSchema, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#voidInvoice | invoice void start for realmId: ${this.tokens.intuitRealmId}. `, @@ -611,14 +622,17 @@ export default class IntuitAPI { ) } + const parsed = QBInvoiceResponseSchema.parse(invoice) CustomLogger.info({ - obj: { response: invoice.Invoice }, - message: `IntuitAPI#voidInvoice | Voided invoice with Id = ${invoice.Invoice?.Id}.`, + obj: { response: parsed.Invoice }, + message: `IntuitAPI#voidInvoice | Voided invoice with Id = ${parsed.Invoice.Id}.`, }) - return invoice + return parsed } - async _deleteInvoice(payload: QBDestructiveInvoicePayloadSchema) { + async _deleteInvoice( + payload: QBDestructiveInvoicePayloadSchema, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#deleteInvoice | invoice deletion start for realmId: ${this.tokens.intuitRealmId}. `, @@ -635,11 +649,12 @@ export default class IntuitAPI { ) } + const parsed = QBInvoiceDeleteResponseSchema.parse(invoice) CustomLogger.info({ - obj: { response: invoice.Invoice }, - message: `IntuitAPI#deleteInvoice | Deleted invoice with id = ${invoice.Invoice?.Id}. `, + obj: { response: parsed.Invoice }, + message: `IntuitAPI#deleteInvoice | Deleted invoice with id = ${parsed.Invoice.Id}. `, }) - return invoice + return parsed } async _deletePayment(payload: QBDeletePayloadType) { From 201f65e7f4bea6a654739bf1b0daae047a92334c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 15:21:55 +0545 Subject: [PATCH 09/18] feat(OUT-3543): zod-parse Payment/Purchase create+delete responses --- src/utils/intuitAPI.ts | 44 +++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index be157440..6a399735 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -560,7 +560,9 @@ export default class IntuitAPI { return parsedAccount } - async _createPayment(payload: QBPaymentCreatePayloadType) { + async _createPayment( + payload: QBPaymentCreatePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#createPayment | payment creation start for realmId: ${this.tokens.intuitRealmId}. `, @@ -577,11 +579,12 @@ export default class IntuitAPI { ) } + const parsed = QBPaymentResponseSchema.parse(payment) CustomLogger.info({ - obj: { response: payment.Payment }, - message: `IntuitAPI#createPayment | payment created with Id = ${payment.Payment?.Id}.`, + obj: { response: parsed.Payment }, + message: `IntuitAPI#createPayment | payment created with Id = ${parsed.Payment.Id}.`, }) - return payment + return parsed } async _getInvoice( @@ -657,7 +660,9 @@ export default class IntuitAPI { return parsed } - async _deletePayment(payload: QBDeletePayloadType) { + async _deletePayment( + payload: QBDeletePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#deletePayment | payment delete start for realmId: ${this.tokens.intuitRealmId}. `, @@ -674,11 +679,12 @@ export default class IntuitAPI { ) } + const parsed = QBPaymentDeleteResponseSchema.parse(payment) CustomLogger.info({ - obj: { response: payment.Payment }, - message: `IntuitAPI#deletePayment | payment deleted with Id = ${payment.Payment?.Id}. `, + obj: { response: parsed.Payment }, + message: `IntuitAPI#deletePayment | payment deleted with Id = ${parsed.Payment.Id}. `, }) - return payment + return parsed } /** @@ -752,7 +758,9 @@ export default class IntuitAPI { return parsed.Account } - async _createPurchase(payload: QBPurchaseCreatePayloadType) { + async _createPurchase( + payload: QBPurchaseCreatePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#createPurchase | Purchase create start for realmId: ${this.tokens.intuitRealmId}.`, @@ -769,14 +777,17 @@ export default class IntuitAPI { ) } + const parsed = QBPurchaseResponseSchema.parse(purchase) CustomLogger.info({ - obj: { response: purchase.Purchase }, - message: `IntuitAPI#createPurchase | Purchase created with Id = ${purchase.Purchase?.Id}.`, + obj: { response: parsed.Purchase }, + message: `IntuitAPI#createPurchase | Purchase created with Id = ${parsed.Purchase.Id}.`, }) - return purchase + return parsed } - async _deletePurchase(payload: QBDeletePayloadType) { + async _deletePurchase( + payload: QBDeletePayloadType, + ): Promise { CustomLogger.info({ obj: { payload }, message: `IntuitAPI#deletePurchase | purchase delete start for realmId: ${this.tokens.intuitRealmId}.`, @@ -793,11 +804,12 @@ export default class IntuitAPI { ) } + const parsed = QBPurchaseDeleteResponseSchema.parse(purchase) CustomLogger.info({ - obj: { response: purchase.Purchase }, - message: `IntuitAPI#deletePurchase | purchase deleted with Id = ${purchase.Purchase?.Id}. `, + obj: { response: parsed.Purchase }, + message: `IntuitAPI#deletePurchase | purchase deleted with Id = ${parsed.Purchase.Id}. `, }) - return purchase + return parsed } async _getCompanyInfo(): Promise { From b8c5c2f1a1c5a18a0d60b448e17e259b43ffd9b6 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 15:49:45 +0545 Subject: [PATCH 10/18] refactor(OUT-3543): tighten _customQuery return, narrow body type, remove as-any casts --- src/helper/fetch.helper.ts | 2 +- src/type/dto/intuitAPI.dto.ts | 37 ++++++++ src/utils/intuitAPI.ts | 172 +++++++++++++++++++++------------- 3 files changed, 143 insertions(+), 68 deletions(-) diff --git a/src/helper/fetch.helper.ts b/src/helper/fetch.helper.ts index 851c5d40..e8f89d2b 100644 --- a/src/helper/fetch.helper.ts +++ b/src/helper/fetch.helper.ts @@ -77,7 +77,7 @@ const resolveSignal = (opts: FetcherOptions): AbortSignal | undefined => { export const postFetcher = async ( url: string, headers: Record, - body: Record, + body: Record, opts: FetcherOptions = {}, ) => { const response = await fetch(url, { diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 3543fb01..07158b5d 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -253,6 +253,34 @@ export type CustomerQueryResponseType = z.infer< typeof CustomerQueryResponseSchema > +// Envelope row used for the paginated email walk. PrimaryEmailAddr.Address is +// `unknown` (not `z.string()`) because mid-walk we tolerate QBO returning +// malformed rows (null/number/missing Address) without failing the whole page; +// the find() predicate narrows with `typeof addr === 'string'`. +export const CustomerListRowSchema = z + .object({ + Id: z.string(), + SyncToken: z.string(), + Active: z.boolean(), + CompanyName: z.string().optional(), + FullyQualifiedName: z.string().optional(), + PrimaryEmailAddr: z + .object({ + Address: z.unknown(), + }) + .passthrough() + .optional(), + }) + .passthrough() +export type CustomerListRowType = z.infer + +export const CustomerListEnvelopeSchema = z.object({ + Customer: z.array(CustomerListRowSchema).optional(), +}) +export type CustomerListEnvelopeType = z.infer< + typeof CustomerListEnvelopeSchema +> + export const QBInvoiceRowSchema = z .object({ Id: z.string(), @@ -275,6 +303,15 @@ export const QBInvoiceResponseSchema = z.object({ }) export type QBInvoiceResponseType = z.infer +// Envelope returned by customQuery for SELECT ... FROM Invoice. Invoice is +// optional because QBO omits the key when there are zero results. +export const QBInvoiceQueryResponseSchema = z.object({ + Invoice: z.array(QBInvoiceRowSchema).optional(), +}) +export type QBInvoiceQueryResponseType = z.infer< + typeof QBInvoiceQueryResponseSchema +> + // Envelope returned by deleteInvoice (no full row, just deletion confirmation). export const QBInvoiceDeleteResponseSchema = z.object({ Invoice: z.object({ diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 6a399735..8a50bf60 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -44,10 +44,13 @@ import { QBPurchaseDeleteResponseSchema, SingleIdAndTokenResponseSchema, SingleIdAndTokenResponseType, + QBInvoiceQueryResponseSchema, + CustomerListEnvelopeSchema, } from '@/type/dto/intuitAPI.dto' import { escapeForQBQuery, getNameAsCustomer } from '@/utils/string' import CustomLogger from '@/utils/logger' import httpStatus from 'http-status' +import { z } from 'zod' export type IntuitAPITokensType = Pick< QBPortalConnectionSelectSchemaType, @@ -63,6 +66,56 @@ export type IntuitAPITokensType = Pick< export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#' +type GetACustomerOverloads = { + ( + displayName: string, + id?: undefined, + includeInactive?: boolean, + ): Promise + ( + displayName: undefined, + id: string, + includeInactive?: boolean, + ): Promise + ( + displayName: string, + id: string, + includeInactive?: boolean, + ): Promise +} + +type GetAnItemOverloads = { + ( + name: string, + id?: undefined, + includeInactive?: boolean, + ): Promise + ( + name: undefined, + id: string, + includeInactive?: boolean, + ): Promise + (name: string, id: string, includeInactive?: boolean): Promise +} + +type GetAnAccountOverloads = { + ( + accountName: string, + id?: undefined, + includeInactive?: boolean, + ): Promise + ( + accountName: undefined, + id: string, + includeInactive?: boolean, + ): Promise + ( + accountName: string, + id: string, + includeInactive?: boolean, + ): Promise +} + // Upper bound on the " (Customer) N" suffix counter when resolving a unique // DisplayName. If exceeded, creation is aborted and a human is paged — having // 20+ Copilot clients sharing a single display name is pathological. @@ -90,7 +143,7 @@ export default class IntuitAPI { */ private async postFetchWithHeaders( url: string, - body: Record, + body: unknown, customHeaders?: Record, ) { const headers = { @@ -116,7 +169,7 @@ export default class IntuitAPI { return response } - async _customQuery(query: string) { + async _customQuery(query: string): Promise { CustomLogger.info({ message: 'IntuitAPI#customQuery', obj: { query } }) const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/query?query=${encodeURIComponent(query)}&minorversion=${intuitApiMinorVersion}` const res = await this.getFetchWithHeader(url) @@ -274,8 +327,9 @@ export default class IntuitAPI { if (!qbCustomers) return null - if (!qbCustomers.Customer) return - return CustomerQueryResponseSchema.parse(qbCustomers.Customer[0]) + const envelope = CustomerListEnvelopeSchema.parse(qbCustomers) + if (!envelope.Customer) return + return CustomerQueryResponseSchema.parse(envelope.Customer[0]) } // QBO's parser mishandles special chars on PrimaryEmailAddr filters, so we @@ -301,10 +355,11 @@ export default class IntuitAPI { while (true) { const customerQuery = `SELECT Id, SyncToken, Active, CompanyName, PrimaryEmailAddr FROM Customer WHERE Active IN (true, false) ORDERBY Id ASC STARTPOSITION ${startPosition} MAXRESULTS ${pageSize}` const qbCustomers = await this.customQuery(customerQuery) - const customers = qbCustomers?.Customer ?? [] + const envelope = CustomerListEnvelopeSchema.parse(qbCustomers ?? {}) + const customers = envelope.Customer ?? [] if (customers.length === 0) return - const match = customers.find((c: CustomerQueryResponseType) => { + const match = customers.find((c) => { const addr = c.PrimaryEmailAddr?.Address if (typeof addr !== 'string') return false if (addr.trim().toLowerCase() !== needle) return false @@ -365,13 +420,30 @@ export default class IntuitAPI { // Case-insensitive comparison: QBO's DisplayName equality is case- // insensitive, so a returned record may differ in case from our candidate. const usedNames = new Set() - for (const c of customerRes?.Customer ?? []) { + const customerNameEnv = z + .object({ + Customer: z.array(z.object({ DisplayName: z.string() })).optional(), + }) + .parse(customerRes ?? {}) + for (const c of customerNameEnv.Customer ?? []) { usedNames.add(c.DisplayName.toLowerCase()) } - for (const v of vendorRes?.Vendor ?? []) { + + const vendorNameEnv = z + .object({ + Vendor: z.array(z.object({ DisplayName: z.string() })).optional(), + }) + .parse(vendorRes ?? {}) + for (const v of vendorNameEnv.Vendor ?? []) { usedNames.add(v.DisplayName.toLowerCase()) } - for (const e of employeeRes?.Employee ?? []) { + + const employeeNameEnv = z + .object({ + Employee: z.array(z.object({ DisplayName: z.string() })).optional(), + }) + .parse(employeeRes ?? {}) + for (const e of employeeNameEnv.Employee ?? []) { usedNames.add(e.DisplayName.toLowerCase()) } @@ -447,7 +519,8 @@ export default class IntuitAPI { if (!qbItems) return null - return QBItemsResponseSchema.parse(qbItems.Item || []) + const envelope = QBItemQueryResponseSchema.parse(qbItems) + return QBItemsResponseSchema.parse(envelope.Item || []) } async _invoiceSparseUpdate( @@ -597,13 +670,20 @@ export default class IntuitAPI { const query = `select Id, SyncToken, DocNumber from Invoice where DocNumber = '${escapeForQBQuery(invoiceNumber)}' maxresults 1` const invoice = await this.customQuery(query) - if (!invoice.Invoice) return null + if (!invoice) + throw new APIError( + httpStatus.BAD_REQUEST, + 'IntuitAPI#getInvoice | message = no response', + ) + + const envelope = QBInvoiceQueryResponseSchema.parse(invoice) + if (!envelope.Invoice || envelope.Invoice.length === 0) return null CustomLogger.info({ - obj: { response: invoice.Invoice }, + obj: { response: envelope.Invoice }, message: `IntuitAPI#getInvoice | invoice fetched with doc number = ${invoiceNumber}.`, }) - return SingleIdAndTokenResponseSchema.parse(invoice.Invoice[0]) + return SingleIdAndTokenResponseSchema.parse(envelope.Invoice[0]) } async _voidInvoice( @@ -843,44 +923,16 @@ export default class IntuitAPI { createCustomer = this.wrapWithRetry(this._createCustomer) createItem = this.wrapWithRetry(this._createItem) getSingleIncomeAccount = this._getSingleIncomeAccount.bind(this) - getACustomer: { - ( - displayName: string, - id?: undefined, - includeInactive?: boolean, - ): Promise - ( - displayName: undefined, - id: string, - includeInactive?: boolean, - ): Promise - ( - displayName: string, - id: string, - includeInactive?: boolean, - ): Promise - } = this._getACustomer.bind(this) as any - // Additional rationale beyond the wrap convention: a transient 429 mid-walk - // would replay from page 1 and amplify rate-limit pressure (same reasoning - // as resolveUniqueCustomerName). + getACustomer: GetACustomerOverloads = this._getACustomer.bind( + this, + ) as unknown as GetACustomerOverloads + // Intentionally NOT wrapped in wrapWithRetry — a transient 429 mid-walk would + // replay from page 1 and amplify rate-limit pressure. The inner customQuery + // calls already retry on 429 (same reasoning as resolveUniqueCustomerName). getCustomerByEmail = this._getCustomerByEmail.bind(this) - getAnItem: { - ( - name: string, - id?: undefined, - includeInactive?: boolean, - ): Promise - ( - name: undefined, - id: string, - includeInactive?: boolean, - ): Promise - ( - name: string, - id: string, - includeInactive?: boolean, - ): Promise - } = this._getAnItem.bind(this) as any + getAnItem: GetAnItemOverloads = this._getAnItem.bind( + this, + ) as unknown as GetAnItemOverloads getAllItems = this._getAllItems.bind(this) invoiceSparseUpdate = this.wrapWithRetry(this._invoiceSparseUpdate) customerSparseUpdate = this.wrapWithRetry(this._customerSparseUpdate) @@ -889,23 +941,9 @@ export default class IntuitAPI { getInvoice = this._getInvoice.bind(this) voidInvoice = this.wrapWithRetry(this._voidInvoice) deleteInvoice = this.wrapWithRetry(this._deleteInvoice) - getAnAccount: { - ( - accountName: string, - id?: undefined, - includeInactive?: boolean, - ): Promise - ( - accountName: undefined, - id: string, - includeInactive?: boolean, - ): Promise - ( - accountName: string, - id: string, - includeInactive?: boolean, - ): Promise - } = this._getAnAccount.bind(this) as any + getAnAccount: GetAnAccountOverloads = this._getAnAccount.bind( + this, + ) as unknown as GetAnAccountOverloads createAccount = this.wrapWithRetry(this._createAccount) updateAccount = this.wrapWithRetry(this._updateAccount) createPurchase = this.wrapWithRetry(this._createPurchase) From 8ffe6a3b5cfe4332e651df8fc0c815ad0c57549e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 15:55:28 +0545 Subject: [PATCH 11/18] fix(OUT-3543): align consumers with tightened IntuitAPI return types Drop .optional() from QBPurchaseRowSchema.SyncToken since QBO always returns SyncToken on a successful create and PaymentService's deletePurchase rollback path requires a non-undefined value. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/type/dto/intuitAPI.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 07158b5d..dc76787e 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -328,7 +328,7 @@ export type QBInvoiceDeleteResponseType = z.infer< export const QBPurchaseRowSchema = z .object({ Id: z.string(), - SyncToken: z.string().optional(), + SyncToken: z.string(), TotalAmt: z.number(), TxnDate: z.string().optional(), AccountRef: QBNameValueSchema.optional(), From e707a924e46d7f6309cb02bc4cb2b608386d11ed Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 20:07:51 +0545 Subject: [PATCH 12/18] fix(OUT-3543): align schemas with SQL projections and consumer reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions surfaced post-merge of the IntuitAPI Zod-parse work: 1. _getSingleIncomeAccount SQL projected only Id, but the strict row schema required Name/SyncToken/Active — every token-bootstrap call threw ZodError. Extended the SELECT to project the fields the row schema and consumers expect. 2. _getAllItems double-parses through QBItemRowSchema then QBItemsResponseSchema. Real callers (backfillProductInfo) project columns without Active, so the first parse failed. Made Active optional on the row and added Description (consumed by QBItemsResponseSchema). While in the schemas, also: - Dropped 9 .passthrough() calls. Default .strip() matches consumer intent (every reader only touches modeled fields). - Dropped _getAllItems' columns = ['Id'] default — would break the first parse on any caller that omits columns. - Tightened CustomerListRowSchema.Address from z.unknown() to z.string() to match QBO's contract. Removed the corresponding unit-test case that injected non-string Addresses (premise wasn't real). - Made QBPaymentRowSchema.SyncToken required to match Purchase, so a future symmetric rollback path doesn't trip the same cascade Task 11 fixed for deletePurchase. - Made CompanyInfoSchema.Country optional. The single consumer (quickbooks.action.ts) handles undefined safely (!== 'US'). --- src/type/dto/intuitAPI.dto.ts | 176 ++++++++++++++---------------- src/utils/intuitAPI.ts | 4 +- test/unit/utils/intuitAPI.test.ts | 37 +------ 3 files changed, 89 insertions(+), 128 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index dc76787e..d74a9d68 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -96,16 +96,15 @@ export type QBItemFullUpdatePayloadType = z.infer< typeof QBItemFullUpdatePayloadSchema > -export const QBItemRowSchema = z - .object({ - Id: z.string(), - SyncToken: z.string(), - Name: z.string(), - ClassRef: QBNameValueSchema.optional(), - Active: z.boolean(), - UnitPrice: z.number(), - }) - .passthrough() +export const QBItemRowSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), + Name: z.string(), + ClassRef: QBNameValueSchema.optional(), + Active: z.boolean().optional(), + UnitPrice: z.number(), + Description: z.string().optional(), +}) export type QBItemRowType = z.infer export const QBItemResponseSchema = z.object({ @@ -176,14 +175,12 @@ export type QBAccountUpdatePayloadType = z.infer< typeof QBAccountUpdatePayloadSchema > -export const QBAccountRowSchema = z - .object({ - Id: z.string(), - Name: z.string(), - SyncToken: z.string(), - Active: z.boolean(), - }) - .passthrough() +export const QBAccountRowSchema = z.object({ + Id: z.string(), + Name: z.string(), + SyncToken: z.string(), + Active: z.boolean(), +}) export type QBAccountRowType = z.infer export const QBAccountResponseSchema = z.object({ @@ -228,26 +225,24 @@ export type QBDeletePayloadType = z.infer export const CompanyInfoSchema = z.object({ CompanyInfo: z.array( z.object({ - Country: z.string(), + Country: z.string().optional(), }), ), }) export type CompanyInfoType = z.infer -export const CustomerQueryResponseSchema = z - .object({ - Id: z.string(), - SyncToken: z.string(), - Active: z.boolean(), - CompanyName: z.string().optional(), - FullyQualifiedName: z.string().optional(), - PrimaryEmailAddr: z - .object({ - Address: z.string(), - }) - .optional(), - }) - .passthrough() +export const CustomerQueryResponseSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), + Active: z.boolean(), + CompanyName: z.string().optional(), + FullyQualifiedName: z.string().optional(), + PrimaryEmailAddr: z + .object({ + Address: z.string(), + }) + .optional(), +}) export type CustomerQueryResponseType = z.infer< typeof CustomerQueryResponseSchema @@ -257,21 +252,18 @@ export type CustomerQueryResponseType = z.infer< // `unknown` (not `z.string()`) because mid-walk we tolerate QBO returning // malformed rows (null/number/missing Address) without failing the whole page; // the find() predicate narrows with `typeof addr === 'string'`. -export const CustomerListRowSchema = z - .object({ - Id: z.string(), - SyncToken: z.string(), - Active: z.boolean(), - CompanyName: z.string().optional(), - FullyQualifiedName: z.string().optional(), - PrimaryEmailAddr: z - .object({ - Address: z.unknown(), - }) - .passthrough() - .optional(), - }) - .passthrough() +export const CustomerListRowSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), + Active: z.boolean(), + CompanyName: z.string().optional(), + FullyQualifiedName: z.string().optional(), + PrimaryEmailAddr: z + .object({ + Address: z.string(), + }) + .optional(), +}) export type CustomerListRowType = z.infer export const CustomerListEnvelopeSchema = z.object({ @@ -281,19 +273,17 @@ export type CustomerListEnvelopeType = z.infer< typeof CustomerListEnvelopeSchema > -export const QBInvoiceRowSchema = z - .object({ - Id: z.string(), - SyncToken: z.string(), - DocNumber: z.string().optional(), - Balance: z.number().optional(), - TotalAmt: z.number().optional(), - TxnDate: z.string().optional(), - DueDate: z.string().optional(), - PrivateNote: z.string().optional(), - CustomerRef: QBNameValueSchema.optional(), - }) - .passthrough() +export const QBInvoiceRowSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), + DocNumber: z.string().optional(), + Balance: z.number().optional(), + TotalAmt: z.number().optional(), + TxnDate: z.string().optional(), + DueDate: z.string().optional(), + PrivateNote: z.string().optional(), + CustomerRef: QBNameValueSchema.optional(), +}) export type QBInvoiceRowType = z.infer // Envelope returned by createInvoice / invoiceSparseUpdate / voidInvoice. @@ -325,16 +315,14 @@ export type QBInvoiceDeleteResponseType = z.infer< typeof QBInvoiceDeleteResponseSchema > -export const QBPurchaseRowSchema = z - .object({ - Id: z.string(), - SyncToken: z.string(), - TotalAmt: z.number(), - TxnDate: z.string().optional(), - AccountRef: QBNameValueSchema.optional(), - PaymentType: z.string().optional(), - }) - .passthrough() +export const QBPurchaseRowSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), + TotalAmt: z.number(), + TxnDate: z.string().optional(), + AccountRef: QBNameValueSchema.optional(), + PaymentType: z.string().optional(), +}) export type QBPurchaseRowType = z.infer export const QBPurchaseResponseSchema = z.object({ @@ -355,30 +343,28 @@ export type QBPurchaseDeleteResponseType = z.infer< typeof QBPurchaseDeleteResponseSchema > -export const QBPaymentRowSchema = z - .object({ - Id: z.string(), - SyncToken: z.string().optional(), - TotalAmt: z.number(), - TxnDate: z.string().optional(), - CustomerRef: QBNameValueSchema.optional(), - Line: z - .array( - z.object({ - Amount: z.number().optional(), - LinkedTxn: z - .array( - z.object({ - TxnId: z.string(), - TxnType: z.string(), - }), - ) - .optional(), - }), - ) - .optional(), - }) - .passthrough() +export const QBPaymentRowSchema = z.object({ + Id: z.string(), + SyncToken: z.string(), + TotalAmt: z.number(), + TxnDate: z.string().optional(), + CustomerRef: QBNameValueSchema.optional(), + Line: z + .array( + z.object({ + Amount: z.number().optional(), + LinkedTxn: z + .array( + z.object({ + TxnId: z.string(), + TxnType: z.string(), + }), + ) + .optional(), + }), + ) + .optional(), +}) export type QBPaymentRowType = z.infer export const QBPaymentResponseSchema = z.object({ diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 8a50bf60..09f7ddbd 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -268,7 +268,7 @@ export default class IntuitAPI { CustomLogger.info({ message: `IntuitAPI#getSingleIncomeAccount | Income account query start for realmId: ${this.tokens.intuitRealmId}`, }) - const sqlQuery = `SELECT Id FROM Account WHERE AccountType = 'Income' AND AccountSubType = 'SalesOfProductIncome' AND Active = true maxresults 1` + const sqlQuery = `SELECT Id, Name, SyncToken, Active FROM Account WHERE AccountType = 'Income' AND AccountSubType = 'SalesOfProductIncome' AND Active = true maxresults 1` const qbIncomeAccountRefInfo = await this.customQuery(sqlQuery) if (!qbIncomeAccountRefInfo) @@ -504,7 +504,7 @@ export default class IntuitAPI { async _getAllItems( limit: number, - columns: string[] = ['Id'], + columns: string[], ): Promise { CustomLogger.info({ message: `IntuitAPI#getAllItems | Item query start for realmId: ${this.tokens.intuitRealmId}`, diff --git a/test/unit/utils/intuitAPI.test.ts b/test/unit/utils/intuitAPI.test.ts index 38d39f5f..19350fe6 100644 --- a/test/unit/utils/intuitAPI.test.ts +++ b/test/unit/utils/intuitAPI.test.ts @@ -13,9 +13,8 @@ * pages followed by empty (off-by-one guard), and on first match. * - Match is case-insensitive and whitespace-tolerant on both sides * (search input AND stored value). - * - Malformed `PrimaryEmailAddr` rows do not throw — guards the - * defensive `typeof addr === 'string'` predicate that fixed the - * type-laundering bug found in review. + * - Rows missing `PrimaryEmailAddr` do not throw — the predicate + * short-circuits on the optional key. * - Empty/whitespace email short-circuits without calling QBO. */ @@ -56,12 +55,11 @@ const baseTokens: IntuitAPITokensType = { } // Builds a customer row in the shape QBO returns inside `QueryResponse.Customer`. -// `email: null` produces a row with no `PrimaryEmailAddr` at all (covers the -// "Address absent" branch). Any other value goes verbatim to test malformed -// shapes (string instead of object, etc.) without TS friction. +// `email: null` produces a row with no `PrimaryEmailAddr` at all — covers the +// "Address absent" branch. function row( id: string, - email: string | null | { Address?: unknown }, + email: string | null, overrides: Record = {}, ) { const base = { @@ -72,10 +70,7 @@ function row( ...overrides, } if (email === null) return base - if (typeof email === 'string') { - return { ...base, PrimaryEmailAddr: { Address: email } } - } - return { ...base, PrimaryEmailAddr: email } + return { ...base, PrimaryEmailAddr: { Address: email } } } // `customQuery` is a public field on IntuitAPI (`this.wrapWithRetry(this._customQuery)`). @@ -279,26 +274,6 @@ describe('IntuitAPI#getCustomerByEmail', () => { expect(result?.Id).toBe('3') }) - it('skips rows where PrimaryEmailAddr.Address is non-string without throwing', async () => { - // Same guard from a different angle: `Address` exists but is not a - // string (number, null, nested object). The `typeof addr === 'string'` - // check must short-circuit before `.trim()`. - const { api } = makeApi([ - { - Customer: [ - row('1', { Address: null }), - row('2', { Address: 12345 }), - row('3', { Address: undefined }), - row('4', 'alice@example.com'), - ], - }, - ]) - - const result = await api.getCustomerByEmail('alice@example.com', undefined) - - expect(result?.Id).toBe('4') - }) - it('returns the first match when multiple customers share the same email and pass the company predicate', async () => { // QBO does not enforce email uniqueness across customers. With // `sanitizedCompanyName=undefined` and rows that both have no From dd8005a59d0b8f371fc34023495417badf3fb3cb Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 12 May 2026 20:09:53 +0545 Subject: [PATCH 13/18] chore(OUT-3543): drop stale CustomerListRowSchema comment Comment described the prior z.unknown() Address schema; tightened to z.string() in the prior commit but the comment was missed. --- src/type/dto/intuitAPI.dto.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index d74a9d68..64341711 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -248,10 +248,6 @@ export type CustomerQueryResponseType = z.infer< typeof CustomerQueryResponseSchema > -// Envelope row used for the paginated email walk. PrimaryEmailAddr.Address is -// `unknown` (not `z.string()`) because mid-walk we tolerate QBO returning -// malformed rows (null/number/missing Address) without failing the whole page; -// the find() predicate narrows with `typeof addr === 'string'`. export const CustomerListRowSchema = z.object({ Id: z.string(), SyncToken: z.string(), From 1505c6993ba66663fc3c2f94b2a3d6bc78c8239e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 14:09:42 +0545 Subject: [PATCH 14/18] refactor(OUT-3543): centralize Fault handling and tighten envelope schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add assertNotQBFault helper + QBFaultSchema; replaces 15 imperative `if (raw?.Fault) throw ...` blocks with a one-liner per method. - Narrow postFetchWithHeaders body to Record and tighten the IntuitAPI-internal fetch wrappers to Promise (postFetcher / getFetcher in fetch.helper.ts unchanged so frontend SWR consumers don't break). - Add QBCustomerResponseSchema envelope so _createCustomer / _customerSparseUpdate follow the same envelope-parse pattern as every other create/update method (removes two `as { Customer? }` casts). - Drop unused `time` field from envelope schemas — no consumer reads it; default .strip() handles QBO's extra fields. - Drop CustomerListRowSchema — exact duplicate of CustomerQueryResponseSchema after Address tightening. Envelope now wraps CustomerQueryResponseSchema directly. - _itemFullUpdate: log now references parsedItem.Item (was leftover item.Item from before .parse was added). - _getAllItems: JSDoc on required columns to surface the double-parse contract. - QBItemRowSchema.Description: tighten to z.string().nullish() to match QBItemsResponseSchema — QBO returns null for items without descriptions; the prior .optional() would have rejected null at the double-parse boundary. --- src/type/dto/intuitAPI.dto.ts | 36 +++--- src/utils/intuitAPI.ts | 223 ++++++++++++++++++---------------- 2 files changed, 135 insertions(+), 124 deletions(-) diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 64341711..4b26121f 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -8,6 +8,16 @@ export const QBNameValueSchema = z.object({ }) export type QBNameValueSchemaType = z.infer +// QBO returns Fault on any failed response. Error is loose (object or array) +// across endpoints; we forward whatever shape arrived so callers/log +// consumers can inspect it. +export const QBFaultSchema = z.object({ + Fault: z.object({ + Error: z.unknown().optional(), + }), +}) +export type QBFaultType = z.infer + export const QBInvoiceLineItemSchema = z.object({ DetailType: z.string(), Amount: z.number(), @@ -103,7 +113,7 @@ export const QBItemRowSchema = z.object({ ClassRef: QBNameValueSchema.optional(), Active: z.boolean().optional(), UnitPrice: z.number(), - Description: z.string().optional(), + Description: z.string().nullish(), }) export type QBItemRowType = z.infer @@ -248,22 +258,14 @@ export type CustomerQueryResponseType = z.infer< typeof CustomerQueryResponseSchema > -export const CustomerListRowSchema = z.object({ - Id: z.string(), - SyncToken: z.string(), - Active: z.boolean(), - CompanyName: z.string().optional(), - FullyQualifiedName: z.string().optional(), - PrimaryEmailAddr: z - .object({ - Address: z.string(), - }) - .optional(), +// Envelope returned by createCustomer / customerSparseUpdate. +export const QBCustomerResponseSchema = z.object({ + Customer: CustomerQueryResponseSchema, }) -export type CustomerListRowType = z.infer +export type QBCustomerResponseType = z.infer export const CustomerListEnvelopeSchema = z.object({ - Customer: z.array(CustomerListRowSchema).optional(), + Customer: z.array(CustomerQueryResponseSchema).optional(), }) export type CustomerListEnvelopeType = z.infer< typeof CustomerListEnvelopeSchema @@ -285,7 +287,6 @@ export type QBInvoiceRowType = z.infer // Envelope returned by createInvoice / invoiceSparseUpdate / voidInvoice. export const QBInvoiceResponseSchema = z.object({ Invoice: QBInvoiceRowSchema, - time: z.string().optional(), }) export type QBInvoiceResponseType = z.infer @@ -305,7 +306,6 @@ export const QBInvoiceDeleteResponseSchema = z.object({ status: z.string().optional(), domain: z.string().optional(), }), - time: z.string().optional(), }) export type QBInvoiceDeleteResponseType = z.infer< typeof QBInvoiceDeleteResponseSchema @@ -323,7 +323,6 @@ export type QBPurchaseRowType = z.infer export const QBPurchaseResponseSchema = z.object({ Purchase: QBPurchaseRowSchema, - time: z.string().optional(), }) export type QBPurchaseResponseType = z.infer @@ -333,7 +332,6 @@ export const QBPurchaseDeleteResponseSchema = z.object({ status: z.string().optional(), domain: z.string().optional(), }), - time: z.string().optional(), }) export type QBPurchaseDeleteResponseType = z.infer< typeof QBPurchaseDeleteResponseSchema @@ -365,7 +363,6 @@ export type QBPaymentRowType = z.infer export const QBPaymentResponseSchema = z.object({ Payment: QBPaymentRowSchema, - time: z.string().optional(), }) export type QBPaymentResponseType = z.infer @@ -375,7 +372,6 @@ export const QBPaymentDeleteResponseSchema = z.object({ status: z.string().optional(), domain: z.string().optional(), }), - time: z.string().optional(), }) export type QBPaymentDeleteResponseType = z.infer< typeof QBPaymentDeleteResponseSchema diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 09f7ddbd..474d5fc9 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -28,6 +28,7 @@ import { CompanyInfoSchema, CustomerQueryResponseType, CustomerQueryResponseSchema, + QBCustomerResponseSchema, QBItemsResponseSchema, QBItemsResponseType, QBInvoiceResponseType, @@ -46,6 +47,7 @@ import { SingleIdAndTokenResponseType, QBInvoiceQueryResponseSchema, CustomerListEnvelopeSchema, + QBFaultSchema, } from '@/type/dto/intuitAPI.dto' import { escapeForQBQuery, getNameAsCustomer } from '@/utils/string' import CustomLogger from '@/utils/logger' @@ -66,6 +68,30 @@ export type IntuitAPITokensType = Pick< export const IntuitAPIErrorMessage = '#IntuitAPIErrorMessage#' +// Throws an APIError if `raw` is a QBO Fault response; no-op otherwise. +// Replaces the duplicated `if (raw?.Fault) throw ...` block in every method. +// Fault.Error.code is preserved only when numeric (HTTP-safe); QBO's +// string codes fall back to BAD_REQUEST as before. +export function assertNotQBFault(raw: unknown, opName: string): void { + const result = QBFaultSchema.safeParse(raw) + if (!result.success) return + const error = result.data.Fault.Error + CustomLogger.error({ obj: error, message: 'Error: ' }) + const code = + error && + typeof error === 'object' && + !Array.isArray(error) && + 'code' in error && + typeof (error as { code: unknown }).code === 'number' + ? (error as { code: number }).code + : httpStatus.BAD_REQUEST + throw new APIError( + code, + `${IntuitAPIErrorMessage}${opName}`, + error as unknown[] | undefined, + ) +} + type GetACustomerOverloads = { ( displayName: string, @@ -143,9 +169,9 @@ export default class IntuitAPI { */ private async postFetchWithHeaders( url: string, - body: unknown, + body: Record, customHeaders?: Record, - ) { + ): Promise { const headers = { ...this.headers, ...customHeaders, @@ -160,7 +186,7 @@ export default class IntuitAPI { private async getFetchWithHeader( url: string, customHeaders?: Record, - ) { + ): Promise { const headers = { ...this.headers, ...customHeaders, @@ -174,15 +200,14 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/query?query=${encodeURIComponent(query)}&minorversion=${intuitApiMinorVersion}` const res = await this.getFetchWithHeader(url) - if (res?.Fault) { - CustomLogger.error({ obj: res.Fault?.Error, message: 'Error: ' }) + if (!res) throw new APIError( - res.Fault.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}customQuery`, - res.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#customQuery | message = no response', ) - } - return res.QueryResponse + + assertNotQBFault(res, 'customQuery') + return (res as { QueryResponse?: unknown }).QueryResponse } async _createInvoice( @@ -195,14 +220,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/invoice?minorversion=${intuitApiMinorVersion}` const invoice = await this.postFetchWithHeaders(url, payload) - if (invoice?.Fault) { - CustomLogger.error({ obj: invoice.Fault?.Error, message: 'Error: ' }) + if (!invoice) throw new APIError( - invoice.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}createInvoice`, - invoice.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#createInvoice | message = no response', ) - } + + assertNotQBFault(invoice, 'createInvoice') const parsed = QBInvoiceResponseSchema.parse(invoice) CustomLogger.info({ @@ -222,21 +246,20 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/customer?minorversion=${intuitApiMinorVersion}` const customer = await this.postFetchWithHeaders(url, payload) - if (customer?.Fault) { - CustomLogger.error({ obj: customer.Fault?.Error, message: 'Error: ' }) + if (!customer) throw new APIError( - customer.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}createCustomer`, - customer.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#createCustomer | message = no response', ) - } - const parsedCustomer = CustomerQueryResponseSchema.parse(customer.Customer) + assertNotQBFault(customer, 'createCustomer') + + const parsed = QBCustomerResponseSchema.parse(customer) CustomLogger.info({ - obj: { response: parsedCustomer }, - message: `IntuitAPI#createCustomer | customer created with name = ${parsedCustomer.FullyQualifiedName ?? ''}.`, + obj: { response: parsed.Customer }, + message: `IntuitAPI#createCustomer | customer created with name = ${parsed.Customer.FullyQualifiedName ?? ''}.`, }) - return parsedCustomer + return parsed.Customer } async _createItem(payload: QBItemCreatePayloadType): Promise { @@ -247,14 +270,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/item?minorversion=${intuitApiMinorVersion}` const item = await this.postFetchWithHeaders(url, payload) - if (item?.Fault) { - CustomLogger.error({ obj: item.Fault?.Error, message: 'Error: ' }) + if (!item) throw new APIError( - item.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}createItem`, - item.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#createItem | message = no response', ) - } + + assertNotQBFault(item, 'createItem') const parsed = QBItemResponseSchema.parse(item) CustomLogger.info({ @@ -329,7 +351,7 @@ export default class IntuitAPI { const envelope = CustomerListEnvelopeSchema.parse(qbCustomers) if (!envelope.Customer) return - return CustomerQueryResponseSchema.parse(envelope.Customer[0]) + return envelope.Customer[0] } // QBO's parser mishandles special chars on PrimaryEmailAddr filters, so we @@ -366,7 +388,7 @@ export default class IntuitAPI { if ((c.CompanyName || undefined) !== sanitizedCompanyName) return false return true }) - if (match) return CustomerQueryResponseSchema.parse(match) + if (match) return match if (customers.length < pageSize) return startPosition += pageSize @@ -502,6 +524,9 @@ export default class IntuitAPI { return parsed.Item?.[0] ?? null } + // `columns` MUST include at minimum Id, Name, UnitPrice, SyncToken — the + // double-parse path (QBItemQueryResponseSchema then QBItemsResponseSchema) + // requires them. Description is optional but typically included by callers. async _getAllItems( limit: number, columns: string[], @@ -533,14 +558,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/invoice?minorversion=${intuitApiMinorVersion}` const invoice = await this.postFetchWithHeaders(url, payload) - if (invoice?.Fault) { - CustomLogger.error({ obj: invoice.Fault?.Error, message: 'Error: ' }) + if (!invoice) throw new APIError( - invoice.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}invoiceSparseUpdate`, - invoice.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#InvoiceSparseUpdate | message = no response', ) - } + + assertNotQBFault(invoice, 'invoiceSparseUpdate') const parsed = QBInvoiceResponseSchema.parse(invoice) CustomLogger.info({ @@ -560,21 +584,20 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/customer?minorversion=${intuitApiMinorVersion}` const customer = await this.postFetchWithHeaders(url, payload) - if (customer?.Fault) { - CustomLogger.error({ obj: customer.Fault?.Error, message: 'Error: ' }) + if (!customer) throw new APIError( - customer.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}customerSparseUpdate`, - customer.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#customerSparseUpdate | message = no response', ) - } - const parsedCustomer = CustomerQueryResponseSchema.parse(customer.Customer) + assertNotQBFault(customer, 'customerSparseUpdate') + + const parsed = QBCustomerResponseSchema.parse(customer) CustomLogger.info({ - obj: { response: parsedCustomer }, - message: `IntuitAPI#customerSparseUpdate | customer sparse updated with name = ${parsedCustomer.FullyQualifiedName ?? ''}. `, + obj: { response: parsed.Customer }, + message: `IntuitAPI#customerSparseUpdate | customer sparse updated with name = ${parsed.Customer.FullyQualifiedName ?? ''}. `, }) - return parsedCustomer + return parsed.Customer } async _itemFullUpdate( @@ -587,20 +610,19 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/item?minorversion=${intuitApiMinorVersion}` const item = await this.postFetchWithHeaders(url, payload) - if (item?.Fault) { - CustomLogger.error({ obj: item.Fault?.Error, message: 'Error: ' }) + if (!item) throw new APIError( - item.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}itemFullUpdate`, - item.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#itemFullUpdate | message = no response', ) - } + + assertNotQBFault(item, 'itemFullUpdate') const parsedItem = QBItemResponseSchema.parse(item) CustomLogger.info({ - obj: { response: item.Item }, - message: `IntuitAPI#itemFullUpdate | item full updated with Id = ${item.Item?.Id}.`, + obj: { response: parsedItem.Item }, + message: `IntuitAPI#itemFullUpdate | item full updated with Id = ${parsedItem.Item.Id}.`, }) return parsedItem } @@ -615,14 +637,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/account?minorversion=${intuitApiMinorVersion}` const account = await this.postFetchWithHeaders(url, payload) - if (account?.Fault) { - CustomLogger.error({ obj: account.Fault?.Error, message: 'Error: ' }) + if (!account) throw new APIError( httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}updateAccount`, - account.Fault?.Error, + 'IntuitAPI#updateAccount | message = no response', ) - } + + assertNotQBFault(account, 'updateAccount') const parsedAccount = QBAccountResponseSchema.parse(account) @@ -643,14 +664,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/payment?minorversion=${intuitApiMinorVersion}` const payment = await this.postFetchWithHeaders(url, payload) - if (payment?.Fault) { - CustomLogger.error({ obj: payment.Fault?.Error, message: 'Error: ' }) + if (!payment) throw new APIError( - payment.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}createPayment`, - payment.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#createPayment | message = no response', ) - } + + assertNotQBFault(payment, 'createPayment') const parsed = QBPaymentResponseSchema.parse(payment) CustomLogger.info({ @@ -696,14 +716,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/invoice?operation=void&minorversion=${intuitApiMinorVersion}` const invoice = await this.postFetchWithHeaders(url, payload) - if (invoice?.Fault) { - CustomLogger.error({ obj: invoice.Fault?.Error, message: 'Error: ' }) + if (!invoice) throw new APIError( - invoice.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}voidInvoice`, - invoice.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#voidInvoice | message = no response', ) - } + + assertNotQBFault(invoice, 'voidInvoice') const parsed = QBInvoiceResponseSchema.parse(invoice) CustomLogger.info({ @@ -723,15 +742,15 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/invoice?operation=delete&minorversion=${intuitApiMinorVersion}` const invoice = await this.postFetchWithHeaders(url, payload) - if (invoice?.Fault) { - CustomLogger.error({ obj: invoice.Fault?.Error, message: 'Error: ' }) + if (!invoice) { throw new APIError( - invoice.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}deleteInvoice`, - invoice.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#deleteInvoice | No invoice deletion confirmation was received from Quickbooks API', ) } + assertNotQBFault(invoice, 'deleteInvoice') + const parsed = QBInvoiceDeleteResponseSchema.parse(invoice) CustomLogger.info({ obj: { response: parsed.Invoice }, @@ -750,14 +769,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/payment?operation=delete&minorversion=${intuitApiMinorVersion}` const payment = await this.postFetchWithHeaders(url, payload) - if (payment?.Fault) { - CustomLogger.error({ obj: payment.Fault?.Error, message: 'Error: ' }) + if (!payment) throw new APIError( - payment.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}deletePayment`, - payment.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#deletePayment | message = no response', ) - } + + assertNotQBFault(payment, 'deletePayment') const parsed = QBPaymentDeleteResponseSchema.parse(payment) CustomLogger.info({ @@ -821,14 +839,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/account?minorversion=${intuitApiMinorVersion}` const account = await this.postFetchWithHeaders(url, payload) - if (account?.Fault) { - CustomLogger.error({ obj: account.Fault?.Error, message: 'Error: ' }) + if (!account) throw new APIError( - account.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}createAccount`, - account.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#createAccount | message = no response', ) - } + + assertNotQBFault(account, 'createAccount') const parsed = QBAccountResponseSchema.parse(account) CustomLogger.info({ @@ -848,14 +865,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/purchase?minorversion=${intuitApiMinorVersion}` const purchase = await this.postFetchWithHeaders(url, payload) - if (purchase?.Fault) { - CustomLogger.error({ obj: purchase.Fault?.Error, message: 'Error: ' }) + if (!purchase) throw new APIError( - purchase.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}createPurchase`, - purchase.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#createPurchase | message = no response', ) - } + + assertNotQBFault(purchase, 'createPurchase') const parsed = QBPurchaseResponseSchema.parse(purchase) CustomLogger.info({ @@ -875,14 +891,13 @@ export default class IntuitAPI { const url = `${intuitBaseUrl}/v3/company/${this.tokens.intuitRealmId}/purchase?operation=delete&minorversion=${intuitApiMinorVersion}` const purchase = await this.postFetchWithHeaders(url, payload) - if (purchase?.Fault) { - CustomLogger.error({ obj: purchase.Fault?.Error, message: 'Error: ' }) + if (!purchase) throw new APIError( - purchase.Fault?.Error?.code || httpStatus.BAD_REQUEST, - `${IntuitAPIErrorMessage}deletePurchase`, - purchase.Fault?.Error, + httpStatus.BAD_REQUEST, + 'IntuitAPI#deletePurchase | message = no response', ) - } + + assertNotQBFault(purchase, 'deletePurchase') const parsed = QBPurchaseDeleteResponseSchema.parse(purchase) CustomLogger.info({ From 1103ff55cc6868e84f520ab487349a4c0dd59f53 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 14:09:53 +0545 Subject: [PATCH 15/18] test(OUT-3543): add unit tests for IntuitAPI response-parse paths 20 tests across customQuery-based reads and POST-based writes. Mocks @/helper/fetch.helper to feed canonical QBO response shapes through the real production methods. Why this exists: the integration suite mocks @/utils/intuitAPI wholesale (test/integration/setup.ts), so production .parse calls never run against realistic input in CI. These tests would have surfaced both SQL-projection regressions caught earlier in this branch (the _getSingleIncomeAccount Id-only SQL bug and the _getAllItems Active-required bug). --- test/unit/utils/intuitAPI.responses.test.ts | 391 ++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 test/unit/utils/intuitAPI.responses.test.ts diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts new file mode 100644 index 00000000..7e3f2dc5 --- /dev/null +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -0,0 +1,391 @@ +// Exercises the production .parse paths against canonical QBO response +// shapes. Integration tests mock @/utils/intuitAPI wholesale, so these +// schemas otherwise never run against realistic input in CI. + +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' +import { getFetcher, postFetcher } from '@/helper/fetch.helper' +import APIError from '@/app/api/core/exceptions/api' + +const baseTokens: IntuitAPITokensType = { + accessToken: 'access', + refreshToken: 'refresh', + intuitRealmId: 'realm-1', + incomeAccountRef: 'income', + expenseAccountRef: 'expense', + assetAccountRef: 'asset', + serviceItemRef: 'service', + clientFeeRef: 'client-fee', +} + +function makeApi() { + return new IntuitAPI(baseTokens) +} + +function queryResponse(body: Record) { + return { QueryResponse: body } +} + +function faultResponse() { + return { + Fault: { + Error: [{ Message: 'Bad request', Detail: 'detail', code: '6000' }], + }, + } +} + +describe('IntuitAPI customQuery-based reads', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('getSingleIncomeAccount parses the SQL it actually issues (Id + Name + SyncToken + Active)', async () => { + // Regression guard: SQL projection must satisfy QBAccountRowSchema. + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ + Account: [ + { Id: '42', Name: 'Sales of Product Income', SyncToken: '0', Active: true }, + ], + }), + ) + + const api = makeApi() + const result = await api.getSingleIncomeAccount() + + expect(result).toEqual({ + Id: '42', + Name: 'Sales of Product Income', + SyncToken: '0', + Active: true, + }) + }) + + it('getSingleIncomeAccount throws APIError on Fault response', async () => { + vi.mocked(getFetcher).mockResolvedValue(faultResponse()) + + const api = makeApi() + await expect(api.getSingleIncomeAccount()).rejects.toBeInstanceOf(APIError) + }) + + it('getAllItems parses rows from a caller-supplied column projection that omits Active', async () => { + // Regression guard: mirrors backfillProductInfo's column list (no Active). + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ + Item: [ + { + Id: '1', + Name: 'Service A', + UnitPrice: 100, + Description: 'desc', + SyncToken: '0', + }, + { + Id: '2', + Name: 'Service B', + UnitPrice: 200, + Description: null, + SyncToken: '0', + }, + ], + }), + ) + + const api = makeApi() + const result = await api.getAllItems(100, [ + 'Id', + 'Name', + 'UnitPrice', + 'Description', + 'SyncToken', + ]) + + expect(result).toHaveLength(2) + expect(result?.[0]).toEqual({ + Id: '1', + Name: 'Service A', + UnitPrice: 100, + Description: 'desc', + SyncToken: '0', + }) + }) + + it('getAllItems returns parsed empty array when QBO omits the Item key', async () => { + vi.mocked(getFetcher).mockResolvedValue(queryResponse({})) + + const api = makeApi() + const result = await api.getAllItems(100, [ + 'Id', + 'Name', + 'UnitPrice', + 'SyncToken', + ]) + + expect(result).toEqual([]) + }) + + it('getAnAccount parses a single-row account match', async () => { + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ + Account: [{ Id: '7', Name: 'Assets', SyncToken: '0', Active: true }], + }), + ) + + const api = makeApi() + const result = await api.getAnAccount('Assets') + + expect(result?.Id).toBe('7') + expect(result?.SyncToken).toBe('0') + }) + + it('getAnAccount returns null when no Account key is present', async () => { + vi.mocked(getFetcher).mockResolvedValue(queryResponse({})) + + const api = makeApi() + const result = await api.getAnAccount('NoSuchAccount') + + expect(result).toBeNull() + }) + + it('getAnItem parses a single-row item match with the SQL columns it projects', async () => { + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ + Item: [ + { + Id: '9', + SyncToken: '0', + ClassRef: { name: 'cls', value: 'c1' }, + Active: true, + Name: 'Widget', + UnitPrice: 50, + }, + ], + }), + ) + + const api = makeApi() + const result = await api.getAnItem('Widget') + + expect(result?.Id).toBe('9') + expect(result?.Name).toBe('Widget') + expect(result?.UnitPrice).toBe(50) + }) + + it('getInvoice parses an invoice match and reduces to Id+SyncToken', async () => { + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ + Invoice: [{ Id: '100', SyncToken: '0', DocNumber: 'INV-1' }], + }), + ) + + const api = makeApi() + const result = await api.getInvoice('INV-1') + + expect(result).toEqual({ Id: '100', SyncToken: '0' }) + }) + + it('getInvoice returns null when QBO returns an empty Invoice array', async () => { + vi.mocked(getFetcher).mockResolvedValue(queryResponse({ Invoice: [] })) + + const api = makeApi() + const result = await api.getInvoice('INV-MISSING') + + expect(result).toBeNull() + }) + + it('getCompanyInfo tolerates a CompanyInfo row without Country', async () => { + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ CompanyInfo: [{}] }), + ) + + const api = makeApi() + const result = await api.getCompanyInfo() + + expect(result.Country).toBeUndefined() + }) + + it('getCompanyInfo passes Country through when present', async () => { + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ CompanyInfo: [{ Country: 'US' }] }), + ) + + const api = makeApi() + const result = await api.getCompanyInfo() + + expect(result.Country).toBe('US') + }) + + it('getACustomer parses a single-row customer match', async () => { + vi.mocked(getFetcher).mockResolvedValue( + queryResponse({ + Customer: [ + { + Id: '5', + SyncToken: '0', + Active: true, + CompanyName: 'Acme', + PrimaryEmailAddr: { Address: 'a@b.com' }, + }, + ], + }), + ) + + const api = makeApi() + const result = await api.getACustomer('Acme') + + expect(result?.Id).toBe('5') + expect(result?.PrimaryEmailAddr?.Address).toBe('a@b.com') + }) +}) + +describe('IntuitAPI POST-based writes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createInvoice parses the envelope and returns the full response', async () => { + vi.mocked(postFetcher).mockResolvedValue({ + Invoice: { + Id: '500', + SyncToken: '0', + DocNumber: 'INV-500', + TotalAmt: 100, + }, + }) + + const api = makeApi() + const result = await api.createInvoice({ + Line: [], + CustomerRef: { value: 'c1' }, + }) + + expect(result.Invoice.Id).toBe('500') + expect(result.Invoice.SyncToken).toBe('0') + }) + + it('createInvoice throws APIError on Fault', async () => { + vi.mocked(postFetcher).mockResolvedValue(faultResponse()) + + const api = makeApi() + await expect( + api.createInvoice({ Line: [], CustomerRef: { value: 'c1' } }), + ).rejects.toBeInstanceOf(APIError) + }) + + it('createCustomer parses the envelope and returns the inner Customer', async () => { + vi.mocked(postFetcher).mockResolvedValue({ + Customer: { + Id: '50', + SyncToken: '0', + Active: true, + FullyQualifiedName: 'Acme', + }, + }) + + const api = makeApi() + const result = await api.createCustomer({ + PrimaryEmailAddr: { Address: 'a@b.com' }, + }) + + expect(result.Id).toBe('50') + expect(result.FullyQualifiedName).toBe('Acme') + }) + + it('createItem parses the envelope and returns the inner Item', async () => { + vi.mocked(postFetcher).mockResolvedValue({ + Item: { + Id: '200', + SyncToken: '0', + Name: 'Widget', + Active: true, + UnitPrice: 25, + }, + }) + + const api = makeApi() + const result = await api.createItem({ + Name: 'Widget', + UnitPrice: 25, + Type: 'Service' as never, + Taxable: false, + }) + + expect(result.Id).toBe('200') + expect(result.UnitPrice).toBe(25) + }) + + it('createAccount parses the envelope and returns the inner Account', async () => { + vi.mocked(postFetcher).mockResolvedValue({ + Account: { Id: '300', Name: 'New Asset', SyncToken: '0', Active: true }, + }) + + const api = makeApi() + const result = await api.createAccount({ + Name: 'New Asset', + AccountType: 'Asset', + Active: true, + Classification: 'Asset', + }) + + expect(result.Id).toBe('300') + expect(result.Name).toBe('New Asset') + }) + + it('createPayment parses the envelope and returns the full response', async () => { + vi.mocked(postFetcher).mockResolvedValue({ + Payment: { Id: '400', SyncToken: '0', TotalAmt: 100 }, + }) + + const api = makeApi() + const result = await api.createPayment({ + TotalAmt: 100, + CustomerRef: { value: 'c1' }, + Line: [], + }) + + expect(result.Payment.Id).toBe('400') + expect(result.Payment.SyncToken).toBe('0') + }) + + it('voidInvoice parses the envelope returned by void operation', async () => { + vi.mocked(postFetcher).mockResolvedValue({ + Invoice: { Id: '500', SyncToken: '1', DocNumber: 'INV-500' }, + }) + + const api = makeApi() + const result = await api.voidInvoice({ Id: '500', SyncToken: '0' }) + + expect(result.Invoice.Id).toBe('500') + expect(result.Invoice.SyncToken).toBe('1') + }) + + it('deleteInvoice parses the deletion-confirmation envelope (no full row)', async () => { + vi.mocked(postFetcher).mockResolvedValue({ + Invoice: { Id: '500', status: 'Deleted', domain: 'QBO' }, + }) + + const api = makeApi() + const result = await api.deleteInvoice({ Id: '500', SyncToken: '0' }) + + expect(result.Invoice.Id).toBe('500') + expect(result.Invoice.status).toBe('Deleted') + }) +}) From 3726c162182233ca3075c49bf328c937c3b3ee75 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 14:29:06 +0545 Subject: [PATCH 16/18] chore(OUT-3543): lint fix --- test/unit/utils/intuitAPI.responses.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts index 7e3f2dc5..2c711a4b 100644 --- a/test/unit/utils/intuitAPI.responses.test.ts +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -63,7 +63,12 @@ describe('IntuitAPI customQuery-based reads', () => { vi.mocked(getFetcher).mockResolvedValue( queryResponse({ Account: [ - { Id: '42', Name: 'Sales of Product Income', SyncToken: '0', Active: true }, + { + Id: '42', + Name: 'Sales of Product Income', + SyncToken: '0', + Active: true, + }, ], }), ) From fa19ce276dcd6ff470a0b00545ea70df995862fc Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 14:50:44 +0545 Subject: [PATCH 17/18] fix(OUT-3543): drop misleading cast on APIError.errors in assertNotQBFault Greptile P2: `error as unknown[] | undefined` lies about the runtime value when QBO returns Fault.Error as a singular object (a documented QBO shape, not just the array form). The cast satisfies TypeScript but APIError.errors is typed unknown[], so consumers reading it as an array would silently misbehave on the object branch. Pass arrays verbatim; drop non-array shapes to undefined. The thrown APIError still carries the human-readable message, and the preceding CustomLogger.error captures the full Error payload either way, so no diagnostic information is lost. --- src/utils/intuitAPI.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 474d5fc9..8e3420ea 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -85,10 +85,13 @@ export function assertNotQBFault(raw: unknown, opName: string): void { typeof (error as { code: unknown }).code === 'number' ? (error as { code: number }).code : httpStatus.BAD_REQUEST + // APIError.errors is typed unknown[] — pass array Errors verbatim, drop + // non-array shapes to undefined rather than casting (the message + the + // logged error above retain the diagnostic detail). throw new APIError( code, `${IntuitAPIErrorMessage}${opName}`, - error as unknown[] | undefined, + Array.isArray(error) ? error : undefined, ) } From f7fd89f4b82723aebcee06a9c82e90057fdaadff Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Wed, 13 May 2026 15:02:41 +0545 Subject: [PATCH 18/18] =?UTF-8?q?fix(OUT-3543):=20address=20Greptile=20rev?= =?UTF-8?q?iew=20=E2=80=94=20defensive=20parsing=20and=20fixed-column=20ge?= =?UTF-8?q?tAllItems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assertNotQBFault: pass array Fault.Error verbatim, drop non-array shapes to undefined instead of casting (APIError.errors is typed unknown[]; the cast was misleading). Diagnostic detail still flows through the preceding CustomLogger.error and the opName in the thrown message. - CustomerQueryResponseSchema.PrimaryEmailAddr: outer and inner are now .nullish() so a single malformed customer row (null PrimaryEmailAddr or null Address) cannot ZodError the entire paginated walk in _getCustomerByEmail. The find() predicate's `typeof addr === 'string'` guard handles the malformed shapes. Re-adds a defensive test for the null-PrimaryEmailAddr and null-Address branches. - _getAllItems: drop the dynamic columns parameter. SQL projects a fixed Id/Name/UnitPrice/Description/SyncToken to match QBItemsResponseSchema. Removes the runtime-only column contract the JSDoc had to document. Updates queryItemsFromQB, the product controller, the backfillProductInfo cmd, and the test fixtures. --- .../quickbooks/product/product.controller.ts | 1 - .../api/quickbooks/product/product.service.ts | 8 ++----- .../backfillProductInfo.service.ts | 8 +------ src/type/dto/intuitAPI.dto.ts | 8 +++++-- src/utils/intuitAPI.ts | 13 ++++------- test/unit/utils/intuitAPI.responses.test.ts | 18 +++------------ test/unit/utils/intuitAPI.test.ts | 23 ++++++++++++++++--- 7 files changed, 36 insertions(+), 43 deletions(-) diff --git a/src/app/api/quickbooks/product/product.controller.ts b/src/app/api/quickbooks/product/product.controller.ts index a62254d9..41b42a4b 100644 --- a/src/app/api/quickbooks/product/product.controller.ts +++ b/src/app/api/quickbooks/product/product.controller.ts @@ -47,7 +47,6 @@ export async function getItemsFromQB(req: NextRequest) { const items = await productService.queryItemsFromQB( qbTokenInfo, MAX_PRODUCT_LIST_LIMIT, - ['Id', 'Name', 'UnitPrice', 'SyncToken', 'Description'], ) return NextResponse.json(items) } diff --git a/src/app/api/quickbooks/product/product.service.ts b/src/app/api/quickbooks/product/product.service.ts index ab3e29ca..ecc7eb12 100644 --- a/src/app/api/quickbooks/product/product.service.ts +++ b/src/app/api/quickbooks/product/product.service.ts @@ -646,13 +646,9 @@ export class ProductService extends BaseService { }) } - async queryItemsFromQB( - qbTokenInfo: IntuitAPITokensType, - limit: number, - columns: string[], - ) { + async queryItemsFromQB(qbTokenInfo: IntuitAPITokensType, limit: number) { const intuitApi = new IntuitAPI(qbTokenInfo) - return await intuitApi.getAllItems(limit, columns) + return await intuitApi.getAllItems(limit) } async formatAndSyncProductLogs(payload: ProductChangedItemReferenceType[]) { diff --git a/src/cmd/backfillProductInfo/backfillProductInfo.service.ts b/src/cmd/backfillProductInfo/backfillProductInfo.service.ts index 351cec74..e8d1a29f 100644 --- a/src/cmd/backfillProductInfo/backfillProductInfo.service.ts +++ b/src/cmd/backfillProductInfo/backfillProductInfo.service.ts @@ -69,13 +69,7 @@ export class BackfillProductInfoService extends BaseService { } const intuitApi = new IntuitAPI(qbTokenInfo) - const allQbItems = await intuitApi.getAllItems(MAX_PRODUCT_LIST_LIMIT, [ - 'Id', - 'Name', - 'UnitPrice', - 'Description', - 'SyncToken', - ]) + const allQbItems = await intuitApi.getAllItems(MAX_PRODUCT_LIST_LIMIT) // 3. update the product info in our mapping table for (const mappedProduct of mappedProducts) { diff --git a/src/type/dto/intuitAPI.dto.ts b/src/type/dto/intuitAPI.dto.ts index 4b26121f..0c67256c 100644 --- a/src/type/dto/intuitAPI.dto.ts +++ b/src/type/dto/intuitAPI.dto.ts @@ -247,11 +247,15 @@ export const CustomerQueryResponseSchema = z.object({ Active: z.boolean(), CompanyName: z.string().optional(), FullyQualifiedName: z.string().optional(), + // PrimaryEmailAddr and its Address are .nullish() because a single + // malformed row (null email object, or present-but-null Address) must not + // ZodError the entire paginated walk in _getCustomerByEmail. The find() + // predicate narrows with `typeof addr === 'string'`. PrimaryEmailAddr: z .object({ - Address: z.string(), + Address: z.string().nullish(), }) - .optional(), + .nullish(), }) export type CustomerQueryResponseType = z.infer< diff --git a/src/utils/intuitAPI.ts b/src/utils/intuitAPI.ts index 8e3420ea..c637f316 100644 --- a/src/utils/intuitAPI.ts +++ b/src/utils/intuitAPI.ts @@ -527,18 +527,13 @@ export default class IntuitAPI { return parsed.Item?.[0] ?? null } - // `columns` MUST include at minimum Id, Name, UnitPrice, SyncToken — the - // double-parse path (QBItemQueryResponseSchema then QBItemsResponseSchema) - // requires them. Description is optional but typically included by callers. - async _getAllItems( - limit: number, - columns: string[], - ): Promise { + async _getAllItems(limit: number): Promise { CustomLogger.info({ message: `IntuitAPI#getAllItems | Item query start for realmId: ${this.tokens.intuitRealmId}`, }) - const stringColumns = columns.map((column) => `${column}`).join(',') - const customerQuery = `select ${stringColumns} from Item where Type = 'Service' maxresults ${limit}` // Only get service items + // Columns are fixed to match QBItemsResponseSchema; callers don't need + // to know the projection. Service items only. + const customerQuery = `select Id, Name, UnitPrice, Description, SyncToken from Item where Type = 'Service' maxresults ${limit}` CustomLogger.info({ obj: { customerQuery }, message: 'IntuitAPI#getAllItems', diff --git a/test/unit/utils/intuitAPI.responses.test.ts b/test/unit/utils/intuitAPI.responses.test.ts index 2c711a4b..c83cca44 100644 --- a/test/unit/utils/intuitAPI.responses.test.ts +++ b/test/unit/utils/intuitAPI.responses.test.ts @@ -91,8 +91,7 @@ describe('IntuitAPI customQuery-based reads', () => { await expect(api.getSingleIncomeAccount()).rejects.toBeInstanceOf(APIError) }) - it('getAllItems parses rows from a caller-supplied column projection that omits Active', async () => { - // Regression guard: mirrors backfillProductInfo's column list (no Active). + it('getAllItems parses rows including null Description (QBO returns null for empty)', async () => { vi.mocked(getFetcher).mockResolvedValue( queryResponse({ Item: [ @@ -115,13 +114,7 @@ describe('IntuitAPI customQuery-based reads', () => { ) const api = makeApi() - const result = await api.getAllItems(100, [ - 'Id', - 'Name', - 'UnitPrice', - 'Description', - 'SyncToken', - ]) + const result = await api.getAllItems(100) expect(result).toHaveLength(2) expect(result?.[0]).toEqual({ @@ -137,12 +130,7 @@ describe('IntuitAPI customQuery-based reads', () => { vi.mocked(getFetcher).mockResolvedValue(queryResponse({})) const api = makeApi() - const result = await api.getAllItems(100, [ - 'Id', - 'Name', - 'UnitPrice', - 'SyncToken', - ]) + const result = await api.getAllItems(100) expect(result).toEqual([]) }) diff --git a/test/unit/utils/intuitAPI.test.ts b/test/unit/utils/intuitAPI.test.ts index 19350fe6..956bd58a 100644 --- a/test/unit/utils/intuitAPI.test.ts +++ b/test/unit/utils/intuitAPI.test.ts @@ -256,9 +256,7 @@ describe('IntuitAPI#getCustomerByEmail', () => { }) it('skips rows with missing PrimaryEmailAddr without throwing', async () => { - // Defensive behaviour added after review: a row with no email field - // must not crash the predicate. Before the fix, accessing `.Address` - // on an unexpected shape would throw mid-find(). + // A row with no email field must not crash the predicate. const { api } = makeApi([ { Customer: [ @@ -274,6 +272,25 @@ describe('IntuitAPI#getCustomerByEmail', () => { expect(result?.Id).toBe('3') }) + it('skips rows with null PrimaryEmailAddr or null Address without ZodError-ing the page', async () => { + // Regression guard: a single malformed row must not taint the whole- + // page parse. Schema permits PrimaryEmailAddr and Address to be null; + // the typeof addr === 'string' predicate skips them. + const { api } = makeApi([ + { + Customer: [ + row('1', null, { PrimaryEmailAddr: null }), + row('2', null, { PrimaryEmailAddr: { Address: null } }), + row('3', 'alice@example.com'), + ], + }, + ]) + + const result = await api.getCustomerByEmail('alice@example.com', undefined) + + expect(result?.Id).toBe('3') + }) + it('returns the first match when multiple customers share the same email and pass the company predicate', async () => { // QBO does not enforce email uniqueness across customers. With // `sanitizedCompanyName=undefined` and rows that both have no