From 7387bf5d228f5a36ca72a43c547a6bed6d225fcd Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Fri, 8 Aug 2025 14:04:15 +0200 Subject: [PATCH 1/6] Add QBORepository and related tests for effective revenue calculation - Introduced `QBORepository` class to handle QuickBooks Online integration, including methods for fetching and aggregating customer revenue data from invoices. - Added comprehensive unit tests for `QBORepository`, covering error handling, data aggregation, and retry logic for API calls. - Created supporting types for `CustomerRevenueByRef` and `Invoice` to enhance type safety and clarity in the implementation. These changes establish a robust foundation for the QBO integration, improving revenue reporting capabilities within the application. --- .../QBO/QBORepository.errorHandling.test.ts | 189 ++++++++++++++++++ .../QBO/QBORepository.integration.test.ts | 181 +++++++++++++++++ .../src/services/QBO/QBORepository.test.ts | 174 ++++++++++++++++ .../main/src/services/QBO/QBORepository.ts | 159 +++++++++++++++ workers/main/src/services/QBO/index.test.ts | 37 ++++ workers/main/src/services/QBO/index.ts | 2 + workers/main/src/services/QBO/types.test.ts | 109 ++++++++++ workers/main/src/services/QBO/types.ts | 16 ++ 8 files changed, 867 insertions(+) create mode 100644 workers/main/src/services/QBO/QBORepository.errorHandling.test.ts create mode 100644 workers/main/src/services/QBO/QBORepository.integration.test.ts create mode 100644 workers/main/src/services/QBO/QBORepository.test.ts create mode 100644 workers/main/src/services/QBO/QBORepository.ts create mode 100644 workers/main/src/services/QBO/index.test.ts create mode 100644 workers/main/src/services/QBO/index.ts create mode 100644 workers/main/src/services/QBO/types.test.ts create mode 100644 workers/main/src/services/QBO/types.ts diff --git a/workers/main/src/services/QBO/QBORepository.errorHandling.test.ts b/workers/main/src/services/QBO/QBORepository.errorHandling.test.ts new file mode 100644 index 00000000..deea5f58 --- /dev/null +++ b/workers/main/src/services/QBO/QBORepository.errorHandling.test.ts @@ -0,0 +1,189 @@ +import axios from 'axios'; +import axiosRetry from 'axios-retry'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { QuickBooksRepositoryError } from '../../common/errors'; +import { OAuth2Manager } from '../OAuth2'; +import { QBORepository } from './QBORepository'; + +// Mock dependencies +vi.mock('axios'); +vi.mock('axios-retry'); +vi.mock('../OAuth2'); +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + apiUrl: 'https://sandbox-quickbooks.api.intuit.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + companyId: 'test-company-id', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.platform.intuit.com', + tokenPath: '/oauth2/v1/tokens/bearer', + tokenExpirationWindowSeconds: 300, + effectiveRevenueMonths: 4, + }, + qboSchema: z.object({ + QBO_API_URL: z.string().url().min(1, 'QBO_API_URL is required'), + QBO_BEARER_TOKEN: z.string().optional(), + QBO_CLIENT_ID: z.string().min(1, 'QBO_CLIENT_ID is required'), + QBO_CLIENT_SECRET: z.string().min(1, 'QBO_CLIENT_SECRET is required'), + QBO_COMPANY_ID: z.string().min(1, 'QBO_COMPANY_ID is required'), + QBO_REFRESH_TOKEN: z.string(), + QBO_EFFECTIVE_REVENUE_MONTHS: z.string().optional(), + }), +})); + +const mockAxios = vi.mocked(axios); +const mockAxiosRetry = vi.mocked(axiosRetry); +const mockOAuth2Manager = vi.mocked(OAuth2Manager); + +describe('QBORepository Error Handling', () => { + let qboRepository: QBORepository; + let mockAxiosInstance: { get: ReturnType }; + let mockRetryCondition: (error: { + response?: { status: number }; + code?: string; + }) => boolean; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAxiosInstance = { + get: vi.fn(), + }; + (mockAxios.create as ReturnType).mockReturnValue( + mockAxiosInstance, + ); + + // Capture retry condition function for testing + (mockAxiosRetry as ReturnType).mockImplementation( + ( + instance, + config: { + retryCondition?: (error: { + response?: { status: number }; + code?: string; + }) => boolean; + }, + ) => { + if (config?.retryCondition) { + mockRetryCondition = config.retryCondition; + } + }, + ); + + (mockOAuth2Manager as ReturnType).mockImplementation(() => ({ + getAccessToken: vi.fn().mockResolvedValue('test-access-token'), + })); + + qboRepository = new QBORepository(); + }); + + describe('retry condition logic', () => { + it('should retry on 429 status code', () => { + const error = { + response: { status: 429 }, + code: undefined, + }; + + expect(mockRetryCondition(error)).toBe(true); + }); + + it('should retry on 500 status code', () => { + const error = { + response: { status: 500 }, + code: undefined, + }; + + expect(mockRetryCondition(error)).toBe(true); + }); + + it('should retry on 502 status code', () => { + const error = { + response: { status: 502 }, + code: undefined, + }; + + expect(mockRetryCondition(error)).toBe(true); + }); + + it('should retry on network errors', () => { + const networkErrors = [ + 'ECONNRESET', + 'ETIMEDOUT', + 'ENOTFOUND', + 'ECONNABORTED', + ]; + + networkErrors.forEach((code) => { + const error = { + response: undefined, + code, + }; + + expect(mockRetryCondition(error)).toBe(true); + }); + }); + + it('should not retry on 400 status code', () => { + const error = { + response: { status: 400 }, + code: undefined, + }; + + expect(mockRetryCondition(error)).toBe(false); + }); + + it('should not retry on 404 status code', () => { + const error = { + response: { status: 404 }, + code: undefined, + }; + + expect(mockRetryCondition(error)).toBe(false); + }); + }); + + describe('OAuth2 token errors', () => { + it('should handle OAuth2 token retrieval failure', async () => { + (mockOAuth2Manager as ReturnType).mockImplementation( + () => ({ + getAccessToken: vi.fn().mockRejectedValue(new Error('Token expired')), + }), + ); + + qboRepository = new QBORepository(); + + await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow( + QuickBooksRepositoryError, + ); + + await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow( + 'QBORepository.getEffectiveRevenue failed: QBORepository.getPaidInvoices failed: Token expired', + ); + }); + }); + + describe('API error scenarios', () => { + it('should handle malformed API response', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { QueryResponse: {} }, // Missing Invoice property + }); + + const result = await qboRepository.getEffectiveRevenue(); + + expect(result).toEqual({}); + }); + + it('should handle null API response', async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { QueryResponse: { Invoice: null } }, + }); + + const result = await qboRepository.getEffectiveRevenue(); + + expect(result).toEqual({}); + }); + }); +}); diff --git a/workers/main/src/services/QBO/QBORepository.integration.test.ts b/workers/main/src/services/QBO/QBORepository.integration.test.ts new file mode 100644 index 00000000..e8977f59 --- /dev/null +++ b/workers/main/src/services/QBO/QBORepository.integration.test.ts @@ -0,0 +1,181 @@ +import axios from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { OAuth2Manager } from '../OAuth2'; +import { QBORepository } from './QBORepository'; +import { Invoice } from './types'; + +// Mock dependencies +vi.mock('axios'); +vi.mock('axios-retry'); +vi.mock('../OAuth2'); +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + apiUrl: 'https://sandbox-quickbooks.api.intuit.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + companyId: 'test-company-id', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.platform.intuit.com', + tokenPath: '/oauth2/v1/tokens/bearer', + tokenExpirationWindowSeconds: 300, + effectiveRevenueMonths: 4, + }, + qboSchema: z.object({ + QBO_API_URL: z.string().url().min(1, 'QBO_API_URL is required'), + QBO_BEARER_TOKEN: z.string().optional(), + QBO_CLIENT_ID: z.string().min(1, 'QBO_CLIENT_ID is required'), + QBO_CLIENT_SECRET: z.string().min(1, 'QBO_CLIENT_SECRET is required'), + QBO_COMPANY_ID: z.string().min(1, 'QBO_COMPANY_ID is required'), + QBO_REFRESH_TOKEN: z.string(), + QBO_EFFECTIVE_REVENUE_MONTHS: z.string().optional(), + }), +})); + +const mockAxios = vi.mocked(axios); +const mockOAuth2Manager = vi.mocked(OAuth2Manager); + +describe('QBORepository Integration', () => { + let qboRepository: QBORepository; + let mockAxiosInstance: { get: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockAxiosInstance = { + get: vi.fn(), + }; + (mockAxios.create as ReturnType).mockReturnValue( + mockAxiosInstance, + ); + + (mockOAuth2Manager as ReturnType).mockImplementation(() => ({ + getAccessToken: vi.fn().mockResolvedValue('test-access-token'), + })); + + qboRepository = new QBORepository(); + }); + + describe('data aggregation', () => { + it('should aggregate multiple invoices for same customer', async () => { + const invoices: Invoice[] = [ + { + TotalAmt: 1000, + Balance: 0, + CustomerRef: { value: 'customer1', name: 'Customer One' }, + }, + { + TotalAmt: 500, + Balance: 0, + CustomerRef: { value: 'customer1', name: 'Customer One' }, + }, + { + TotalAmt: 750, + Balance: 0, + CustomerRef: { value: 'customer1', name: 'Customer One' }, + }, + { + TotalAmt: 300, + Balance: 0, + CustomerRef: { value: 'customer2', name: 'Customer Two' }, + }, + ]; + + mockAxiosInstance.get.mockResolvedValue({ + data: { + QueryResponse: { Invoice: invoices }, + }, + }); + + const result = await qboRepository.getEffectiveRevenue(); + + expect(result).toEqual({ + customer1: { + customerName: 'Customer One', + totalAmount: 2250, // 1000 + 500 + 750 + invoiceCount: 3, + }, + customer2: { + customerName: 'Customer Two', + totalAmount: 300, + invoiceCount: 1, + }, + }); + }); + + it('should handle invoices with missing customer information', async () => { + const invoices: Invoice[] = [ + { + TotalAmt: 1000, + Balance: 0, + CustomerRef: { value: 'customer1', name: 'Customer One' }, + }, + { TotalAmt: 500, Balance: 0, CustomerRef: undefined }, + { + TotalAmt: 750, + Balance: 0, + CustomerRef: { value: 'customer2', name: 'Customer Two' }, + }, + ]; + + mockAxiosInstance.get.mockResolvedValue({ + data: { + QueryResponse: { Invoice: invoices }, + }, + }); + + const result = await qboRepository.getEffectiveRevenue(); + + expect(result).toEqual({ + customer1: { + customerName: 'Customer One', + totalAmount: 1000, + invoiceCount: 1, + }, + unknown: { + customerName: 'Unknown', + totalAmount: 500, + invoiceCount: 1, + }, + customer2: { + customerName: 'Customer Two', + totalAmount: 750, + invoiceCount: 1, + }, + }); + }); + }); + + describe('date window calculation', () => { + it('should use correct date range for effective revenue months', async () => { + const mockDate = new Date('2024-01-15'); + + vi.useFakeTimers(); + vi.setSystemTime(mockDate); + + mockAxiosInstance.get.mockResolvedValue({ + data: { + QueryResponse: { Invoice: [] }, + }, + }); + + await qboRepository.getEffectiveRevenue(); + + // Should query for 4 months back from 2024-01-15 + const expectedQuery = + "TxnDate >= '2023-09-15' AND TxnDate <= '2024-01-15'"; + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: { + query: expect.stringContaining(expectedQuery) as string, + }, + }), + ); + + vi.useRealTimers(); + }); + }); +}); diff --git a/workers/main/src/services/QBO/QBORepository.test.ts b/workers/main/src/services/QBO/QBORepository.test.ts new file mode 100644 index 00000000..4ec9b88d --- /dev/null +++ b/workers/main/src/services/QBO/QBORepository.test.ts @@ -0,0 +1,174 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import axios from 'axios'; +import axiosRetry from 'axios-retry'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { QuickBooksRepositoryError } from '../../common/errors'; +import { OAuth2Manager } from '../OAuth2'; +import { QBORepository } from './QBORepository'; +import { Invoice } from './types'; + +// Mock dependencies +vi.mock('axios'); +vi.mock('axios-retry'); +vi.mock('../OAuth2'); +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + apiUrl: 'https://sandbox-quickbooks.api.intuit.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + companyId: 'test-company-id', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.platform.intuit.com', + tokenPath: '/oauth2/v1/tokens/bearer', + tokenExpirationWindowSeconds: 300, + effectiveRevenueMonths: 4, + }, + qboSchema: z.object({ + QBO_API_URL: z.string().url().min(1, 'QBO_API_URL is required'), + QBO_BEARER_TOKEN: z.string().optional(), + QBO_CLIENT_ID: z.string().min(1, 'QBO_CLIENT_ID is required'), + QBO_CLIENT_SECRET: z.string().min(1, 'QBO_CLIENT_SECRET is required'), + QBO_COMPANY_ID: z.string().min(1, 'QBO_COMPANY_ID is required'), + QBO_REFRESH_TOKEN: z.string(), + QBO_EFFECTIVE_REVENUE_MONTHS: z.string().optional(), + }), +})); + +const mockAxios = vi.mocked(axios); +const mockAxiosRetry = vi.mocked(axiosRetry); +const mockOAuth2Manager = vi.mocked(OAuth2Manager); + +// Test data utilities +const createMockInvoice = (overrides = {}): Invoice => ({ + TotalAmt: 1000, + Balance: 0, + CustomerRef: { value: 'customer1', name: 'Test Customer' }, + ...overrides, +}); + +const createMockApiResponse = (invoices: Invoice[] = []) => ({ + data: { + QueryResponse: { Invoice: invoices }, + }, +}); + +describe('QBORepository', () => { + let qboRepository: QBORepository; + let mockAxiosInstance: any; + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup mock axios instance + mockAxiosInstance = { + get: vi.fn(), + }; + mockAxios.create.mockReturnValue(mockAxiosInstance); + + // Setup mock OAuth2Manager + mockOAuth2Manager.mockImplementation( + () => + ({ + getAccessToken: vi.fn().mockResolvedValue('test-access-token'), + }) as any, + ); + + qboRepository = new QBORepository(); + }); + + describe('constructor', () => { + it('should initialize with correct configuration', () => { + expect(mockOAuth2Manager).toHaveBeenCalledWith('qbo-test-company-id', { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenHost: 'https://oauth.platform.intuit.com', + tokenPath: '/oauth2/v1/tokens/bearer', + tokenExpirationWindowSeconds: 300, + }); + + expect(mockAxios.create).toHaveBeenCalledWith({ + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }); + + expect(mockAxiosRetry).toHaveBeenCalledWith( + mockAxiosInstance, + expect.objectContaining({ + retries: 3, + retryCondition: expect.any(Function), + retryDelay: expect.any(Function), + }), + ); + }); + }); + + describe('getEffectiveRevenue', () => { + it('should return aggregated customer revenue data', async () => { + const mockInvoices: Invoice[] = [ + createMockInvoice({ + TotalAmt: 1000, + CustomerRef: { value: 'customer1', name: 'Customer One' }, + }), + createMockInvoice({ + TotalAmt: 500, + CustomerRef: { value: 'customer1', name: 'Customer One' }, + }), + createMockInvoice({ + TotalAmt: 750, + CustomerRef: { value: 'customer2', name: 'Customer Two' }, + }), + ]; + + mockAxiosInstance.get.mockResolvedValue( + createMockApiResponse(mockInvoices), + ); + + const result = await qboRepository.getEffectiveRevenue(); + + expect(result).toEqual({ + customer1: { + customerName: 'Customer One', + totalAmount: 1500, + invoiceCount: 2, + }, + customer2: { + customerName: 'Customer Two', + totalAmount: 750, + invoiceCount: 1, + }, + }); + }); + + it('should handle empty invoice response', async () => { + mockAxiosInstance.get.mockResolvedValue(createMockApiResponse([])); + + const result = await qboRepository.getEffectiveRevenue(); + + expect(result).toEqual({}); + }); + + it('should throw QuickBooksRepositoryError on API failure', async () => { + const errorMessage = 'API request failed'; + + mockAxiosInstance.get.mockRejectedValue(new Error(errorMessage)); + + await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow( + QuickBooksRepositoryError, + ); + + await expect(qboRepository.getEffectiveRevenue()).rejects.toThrow( + 'QBORepository.getEffectiveRevenue failed: QBORepository.getPaidInvoices failed: API request failed', + ); + }); + }); +}); diff --git a/workers/main/src/services/QBO/QBORepository.ts b/workers/main/src/services/QBO/QBORepository.ts new file mode 100644 index 00000000..7189379a --- /dev/null +++ b/workers/main/src/services/QBO/QBORepository.ts @@ -0,0 +1,159 @@ +import axios from 'axios'; +import axiosRetry from 'axios-retry'; + +import { QuickBooksRepositoryError } from '../../common/errors'; +import { formatDateToISOString } from '../../common/utils'; +import { axiosConfig } from '../../configs/axios'; +import { qboConfig } from '../../configs/qbo'; +import { OAuth2Manager } from '../OAuth2'; +import { CustomerRevenueByRef, Invoice } from './types'; + +interface QBORetryError { + response?: { status: number; headers: Record }; + code?: string; +} + +interface DateWindow { + startDate: string; + endDate: string; +} + +interface QBOQueryResponse { + QueryResponse: { Invoice?: Invoice[] }; +} + +export interface IQBORepository { + getEffectiveRevenue(): Promise; +} + +export class QBORepository implements IQBORepository { + private readonly tokenManager: OAuth2Manager; + private readonly axiosInstance: ReturnType; + + constructor() { + this.tokenManager = new OAuth2Manager(`qbo-${qboConfig.companyId}`, { + clientId: qboConfig.clientId!, + clientSecret: qboConfig.clientSecret!, + refreshToken: qboConfig.refreshToken!, + tokenHost: qboConfig.tokenHost, + tokenPath: qboConfig.tokenPath, + tokenExpirationWindowSeconds: qboConfig.tokenExpirationWindowSeconds, + }); + + this.axiosInstance = axios.create({ + timeout: axiosConfig.timeout, + headers: { ...axiosConfig.headers }, + }); + + axiosRetry(this.axiosInstance, { + retries: axiosConfig.maxRetries, + retryDelay: (retryCount, error) => + this.getRetryDelay(error as QBORetryError, retryCount), + retryCondition: (error) => this.isRetryableError(error as QBORetryError), + }); + } + + private isRetryableError(error: QBORetryError): boolean { + if (error.response) { + const statusCode = error.response.status; + + if (statusCode === 429 || statusCode >= 500) return true; + } + + return ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNABORTED'].includes( + error.code || '', + ); + } + + private calculateRetryDelay(error: QBORetryError, attempt: number): number { + if (error.response?.status === 429) { + const retryAfter = error.response.headers['retry-after']; + + if (retryAfter) return parseInt(retryAfter) * 1000; + } + const baseDelay = Math.pow(2, attempt) * 1000; + const jitter = Math.random() * 0.1 * baseDelay; + const maxDelay = error.response?.status === 502 ? 60000 : 30000; + + return Math.min(baseDelay + jitter, maxDelay); + } + + private getRetryDelay(error: QBORetryError, attempt: number): number { + return this.calculateRetryDelay(error, attempt); + } + + private async getPaidInvoices(): Promise { + try { + const accessToken = await this.tokenManager.getAccessToken(); + const { startDate, endDate } = this.calculateDateWindow(); + const allInvoices: Invoice[] = []; + let startPosition = 1; + const maxResults = 100; + + while (true) { + const query = `SELECT * FROM Invoice WHERE TxnDate >= '${startDate}' AND TxnDate <= '${endDate}' AND Balance = '0' STARTPOSITION ${startPosition} MAXRESULTS ${maxResults}`; + const response = await this.axiosInstance.get( + `${qboConfig.apiUrl}/v3/company/${qboConfig.companyId}/query`, + { + params: { query }, + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + + const invoices = response.data.QueryResponse.Invoice || []; + + if (invoices.length === 0) break; + + allInvoices.push(...invoices); + startPosition += maxResults; + if (invoices.length < maxResults) break; + } + + return allInvoices; + } catch (error) { + throw new QuickBooksRepositoryError( + `QBORepository.getPaidInvoices failed: ${(error as Error).message}`, + ); + } + } + + private aggregateInvoices(invoices: Invoice[]): CustomerRevenueByRef { + return invoices.reduce((acc, invoice) => { + const customerRefId = invoice.CustomerRef?.value || 'unknown'; + const customerName = invoice.CustomerRef?.name || 'Unknown'; + + if (!acc[customerRefId]) { + acc[customerRefId] = { customerName, totalAmount: 0, invoiceCount: 0 }; + } + + acc[customerRefId].totalAmount += invoice.TotalAmt; + acc[customerRefId].invoiceCount += 1; + + return acc; + }, {} as CustomerRevenueByRef); + } + + private calculateDateWindow(): DateWindow { + const endDate = new Date(); + const startDate = new Date(endDate); + + startDate.setMonth(endDate.getMonth() - qboConfig.effectiveRevenueMonths); + + return { + startDate: formatDateToISOString(startDate), + endDate: formatDateToISOString(endDate), + }; + } + + async getEffectiveRevenue(): Promise { + try { + const invoices = await this.getPaidInvoices(); + + return this.aggregateInvoices(invoices); + } catch (error) { + throw new QuickBooksRepositoryError( + `QBORepository.getEffectiveRevenue failed: ${(error as Error).message}`, + ); + } + } +} diff --git a/workers/main/src/services/QBO/index.test.ts b/workers/main/src/services/QBO/index.test.ts new file mode 100644 index 00000000..27e27195 --- /dev/null +++ b/workers/main/src/services/QBO/index.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { CustomerRevenueByRef, Invoice, QBORepository } from './index'; + +describe('QBO Service Index Exports', () => { + it('should export QBORepository class', () => { + expect(QBORepository).toBeDefined(); + expect(typeof QBORepository).toBe('function'); + }); + + it('should allow creating CustomerRevenueByRef instance', () => { + const revenue: CustomerRevenueByRef = { + customer1: { + customerName: 'Test Customer', + totalAmount: 1000, + invoiceCount: 1, + }, + }; + + expect(revenue).toBeDefined(); + expect(revenue.customer1.customerName).toBe('Test Customer'); + }); + + it('should allow creating Invoice instance', () => { + const invoice: Invoice = { + TotalAmt: 500, + Balance: 0, + CustomerRef: { + value: 'customer1', + name: 'Test Customer', + }, + }; + + expect(invoice).toBeDefined(); + expect(invoice.TotalAmt).toBe(500); + }); +}); diff --git a/workers/main/src/services/QBO/index.ts b/workers/main/src/services/QBO/index.ts new file mode 100644 index 00000000..4a021372 --- /dev/null +++ b/workers/main/src/services/QBO/index.ts @@ -0,0 +1,2 @@ +export * from './QBORepository'; +export * from './types'; diff --git a/workers/main/src/services/QBO/types.test.ts b/workers/main/src/services/QBO/types.test.ts new file mode 100644 index 00000000..082e7667 --- /dev/null +++ b/workers/main/src/services/QBO/types.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { CustomerRevenueByRef, Invoice } from './types'; + +describe('QBO Types', () => { + describe('CustomerRevenueByRef', () => { + it('should have correct structure', () => { + const revenue: CustomerRevenueByRef = { + customer1: { + customerName: 'Test Customer', + totalAmount: 1000, + invoiceCount: 2, + }, + }; + + expect(revenue.customer1.customerName).toBe('Test Customer'); + expect(revenue.customer1.totalAmount).toBe(1000); + expect(revenue.customer1.invoiceCount).toBe(2); + }); + + it('should support multiple customers', () => { + const revenue: CustomerRevenueByRef = { + customer1: { + customerName: 'Customer One', + totalAmount: 1000, + invoiceCount: 1, + }, + customer2: { + customerName: 'Customer Two', + totalAmount: 2000, + invoiceCount: 3, + }, + }; + + expect(Object.keys(revenue)).toHaveLength(2); + expect(revenue.customer1.totalAmount).toBe(1000); + expect(revenue.customer2.totalAmount).toBe(2000); + }); + }); + + describe('Invoice', () => { + it('should have correct structure with CustomerRef', () => { + const invoice: Invoice = { + TotalAmt: 500, + Balance: 0, + CustomerRef: { + value: 'customer1', + name: 'Test Customer', + }, + }; + + expect(invoice.TotalAmt).toBe(500); + expect(invoice.Balance).toBe(0); + expect(invoice.CustomerRef?.value).toBe('customer1'); + expect(invoice.CustomerRef?.name).toBe('Test Customer'); + }); + + it('should handle optional CustomerRef', () => { + const invoice: Invoice = { + TotalAmt: 500, + Balance: 0, + CustomerRef: undefined, + }; + + expect(invoice.TotalAmt).toBe(500); + expect(invoice.Balance).toBe(0); + expect(invoice.CustomerRef).toBeUndefined(); + }); + + it('should handle CustomerRef with missing name', () => { + const invoice: Invoice = { + TotalAmt: 500, + Balance: 0, + CustomerRef: { + value: 'customer1', + name: undefined, + }, + }; + + expect(invoice.TotalAmt).toBe(500); + expect(invoice.Balance).toBe(0); + expect(invoice.CustomerRef?.value).toBe('customer1'); + expect(invoice.CustomerRef?.name).toBeUndefined(); + }); + + it('should handle different balance amounts', () => { + const paidInvoice: Invoice = { + TotalAmt: 1000, + Balance: 0, + CustomerRef: { + value: 'customer1', + name: 'Test Customer', + }, + }; + + const unpaidInvoice: Invoice = { + TotalAmt: 1000, + Balance: 1000, + CustomerRef: { + value: 'customer1', + name: 'Test Customer', + }, + }; + + expect(paidInvoice.Balance).toBe(0); + expect(unpaidInvoice.Balance).toBe(1000); + }); + }); +}); diff --git a/workers/main/src/services/QBO/types.ts b/workers/main/src/services/QBO/types.ts new file mode 100644 index 00000000..32d21eeb --- /dev/null +++ b/workers/main/src/services/QBO/types.ts @@ -0,0 +1,16 @@ +export interface CustomerRevenueByRef { + [customerRefId: string]: { + customerName: string; + totalAmount: number; + invoiceCount: number; + }; +} + +export interface Invoice { + TotalAmt: number; + Balance: number; + CustomerRef?: { + value: string; + name?: string; + }; +} From aa3ca1ddeb61b5ca2b8c3eac5b51e422305fa6e6 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Fri, 8 Aug 2025 14:45:04 +0200 Subject: [PATCH 2/6] Add generateJitter utility function and update QBORepository to use it - Introduced `generateJitter` function in `utils.ts` to create cryptographically secure random jitter for retry delays, enhancing the reliability of retry logic. - Updated `QBORepository` to utilize the new `generateJitter` function instead of a manual jitter calculation, improving code clarity and maintainability. - Added unit tests for `generateJitter` to ensure it generates values within the expected range and behaves consistently across multiple calls. These changes enhance the utility functions available for managing delays in API calls, contributing to a more robust integration with QuickBooks Online. --- workers/main/src/common/utils.test.ts | 42 +++++++++++++++++-- workers/main/src/common/utils.ts | 14 +++++++ .../main/src/services/QBO/QBORepository.ts | 4 +- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/workers/main/src/common/utils.test.ts b/workers/main/src/common/utils.test.ts index 6146e449..d19f986b 100644 --- a/workers/main/src/common/utils.test.ts +++ b/workers/main/src/common/utils.test.ts @@ -5,7 +5,7 @@ vi.mock('../configs', () => ({ })); import * as configs from '../configs'; -import { formatDateToISOString, validateEnv } from './utils'; +import { formatDateToISOString, generateJitter, validateEnv } from './utils'; type ValidationResult = { success: boolean; @@ -20,7 +20,7 @@ describe('validateEnv', () => { let exitSpy: ReturnType; beforeEach(() => { - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }) as unknown as ReturnType; @@ -51,7 +51,7 @@ describe('validateEnv', () => { expect(() => validateEnv()).toThrow('exit'); expect(errorSpy).toHaveBeenCalledWith( 'Missing or invalid environment variable: FOO (is required)\n' + - 'Missing or invalid environment variable: (unknown variable) (unknown)', + 'Missing or invalid environment variable: (unknown variable) (unknown)', ); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -79,3 +79,39 @@ describe('formatDateToISOString', () => { expect(result).toBe('2024-12-31'); }); }); + +describe('generateJitter', () => { + it('generates jitter within expected range', () => { + const baseDelay = 1000; + const jitter = generateJitter(baseDelay); + + // Jitter should be between 0 and 10% of baseDelay + expect(jitter).toBeGreaterThanOrEqual(0); + expect(jitter).toBeLessThanOrEqual(0.1 * baseDelay); + expect(jitter).toBeLessThan(100); // 10% of 1000ms + }); + + it('generates different jitter values on multiple calls', () => { + const baseDelay = 2000; + const jitter1 = generateJitter(baseDelay); + const jitter2 = generateJitter(baseDelay); + + // Values should be different (cryptographically random) + expect(jitter1).not.toBe(jitter2); + }); + + it('scales jitter proportionally with base delay', () => { + const smallDelay = 500; + const largeDelay = 2000; + + const smallJitter = generateJitter(smallDelay); + const largeJitter = generateJitter(largeDelay); + + // Large delay should produce larger jitter + expect(largeJitter).toBeGreaterThan(smallJitter); + + // Both should be within 10% of their respective base delays + expect(smallJitter).toBeLessThanOrEqual(0.1 * smallDelay); + expect(largeJitter).toBeLessThanOrEqual(0.1 * largeDelay); + }); +}); diff --git a/workers/main/src/common/utils.ts b/workers/main/src/common/utils.ts index 35387f56..34398913 100644 --- a/workers/main/src/common/utils.ts +++ b/workers/main/src/common/utils.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import { validationResult } from '../configs'; export function validateEnv() { @@ -21,3 +23,15 @@ export function formatDateToISOString(date: Date): string { return `${year}-${month}-${day}`; } + +/** + * Generates cryptographically secure random jitter for retry delays + * @param baseDelay - The base delay in milliseconds + * @returns A random jitter value between 0 and 10% of the base delay + */ +export function generateJitter(baseDelay: number): number { + const randomBytes = crypto.randomBytes(4); + const randomValue = randomBytes.readUInt32BE(0) / 0xffffffff; // Convert to 0-1 range + + return randomValue * 0.1 * baseDelay; +} diff --git a/workers/main/src/services/QBO/QBORepository.ts b/workers/main/src/services/QBO/QBORepository.ts index 7189379a..5e9dff37 100644 --- a/workers/main/src/services/QBO/QBORepository.ts +++ b/workers/main/src/services/QBO/QBORepository.ts @@ -2,7 +2,7 @@ import axios from 'axios'; import axiosRetry from 'axios-retry'; import { QuickBooksRepositoryError } from '../../common/errors'; -import { formatDateToISOString } from '../../common/utils'; +import { formatDateToISOString, generateJitter } from '../../common/utils'; import { axiosConfig } from '../../configs/axios'; import { qboConfig } from '../../configs/qbo'; import { OAuth2Manager } from '../OAuth2'; @@ -72,7 +72,7 @@ export class QBORepository implements IQBORepository { if (retryAfter) return parseInt(retryAfter) * 1000; } const baseDelay = Math.pow(2, attempt) * 1000; - const jitter = Math.random() * 0.1 * baseDelay; + const jitter = generateJitter(baseDelay); const maxDelay = error.response?.status === 502 ? 60000 : 30000; return Math.min(baseDelay + jitter, maxDelay); From da62bfcd4f84ea06895d94e0565c6fbf17fd996b Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Fri, 8 Aug 2025 15:21:48 +0200 Subject: [PATCH 3/6] Refactor tests for validateEnv and generateJitter functions - Updated the `validateEnv` test to improve error message formatting and ensure proper handling of missing environment variables. - Enhanced the `generateJitter` test cases by clarifying the expected behavior and consolidating multiple tests into a single comprehensive test for different baseDelay values. - Removed redundant tests for jitter generation to streamline the testing process while maintaining coverage for expected functionality. These changes improve the clarity and reliability of unit tests for environment validation and jitter generation utilities. --- workers/main/src/common/utils.test.ts | 34 +++++++-------------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/workers/main/src/common/utils.test.ts b/workers/main/src/common/utils.test.ts index d19f986b..d7a500b7 100644 --- a/workers/main/src/common/utils.test.ts +++ b/workers/main/src/common/utils.test.ts @@ -20,7 +20,7 @@ describe('validateEnv', () => { let exitSpy: ReturnType; beforeEach(() => { - errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }) as unknown as ReturnType; @@ -51,7 +51,7 @@ describe('validateEnv', () => { expect(() => validateEnv()).toThrow('exit'); expect(errorSpy).toHaveBeenCalledWith( 'Missing or invalid environment variable: FOO (is required)\n' + - 'Missing or invalid environment variable: (unknown variable) (unknown)', + 'Missing or invalid environment variable: (unknown variable) (unknown)', ); expect(exitSpy).toHaveBeenCalledWith(1); }); @@ -81,37 +81,19 @@ describe('formatDateToISOString', () => { }); describe('generateJitter', () => { - it('generates jitter within expected range', () => { + it('should generate jitter between 0 and 10% of baseDelay', () => { const baseDelay = 1000; const jitter = generateJitter(baseDelay); - // Jitter should be between 0 and 10% of baseDelay expect(jitter).toBeGreaterThanOrEqual(0); expect(jitter).toBeLessThanOrEqual(0.1 * baseDelay); - expect(jitter).toBeLessThan(100); // 10% of 1000ms }); - it('generates different jitter values on multiple calls', () => { - const baseDelay = 2000; - const jitter1 = generateJitter(baseDelay); - const jitter2 = generateJitter(baseDelay); - - // Values should be different (cryptographically random) - expect(jitter1).not.toBe(jitter2); - }); - - it('scales jitter proportionally with base delay', () => { - const smallDelay = 500; - const largeDelay = 2000; - - const smallJitter = generateJitter(smallDelay); - const largeJitter = generateJitter(largeDelay); - - // Large delay should produce larger jitter - expect(largeJitter).toBeGreaterThan(smallJitter); + it('should handle different baseDelay values', () => { + const baseDelay = 500; + const jitter = generateJitter(baseDelay); - // Both should be within 10% of their respective base delays - expect(smallJitter).toBeLessThanOrEqual(0.1 * smallDelay); - expect(largeJitter).toBeLessThanOrEqual(0.1 * largeDelay); + expect(jitter).toBeGreaterThanOrEqual(0); + expect(jitter).toBeLessThanOrEqual(0.1 * baseDelay); }); }); From df392e5276efe4b9f81d38035d03d66bb6c250d2 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Fri, 8 Aug 2025 15:23:49 +0200 Subject: [PATCH 4/6] Fix random value calculation in generateJitter function - Updated the calculation of the random value in the `generateJitter` function to use a more accurate divisor (0x100000000) for converting to the [0,1) range. This change ensures that the generated jitter values are correctly scaled, enhancing the reliability of the jitter generation for retry delays. This modification improves the precision of the jitter utility, contributing to more effective delay management in API calls. --- workers/main/src/common/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/main/src/common/utils.ts b/workers/main/src/common/utils.ts index 34398913..725c0e1a 100644 --- a/workers/main/src/common/utils.ts +++ b/workers/main/src/common/utils.ts @@ -31,7 +31,7 @@ export function formatDateToISOString(date: Date): string { */ export function generateJitter(baseDelay: number): number { const randomBytes = crypto.randomBytes(4); - const randomValue = randomBytes.readUInt32BE(0) / 0xffffffff; // Convert to 0-1 range + const randomValue = randomBytes.readUInt32BE(0) / 0x100000000; // Convert to [0,1) range return randomValue * 0.1 * baseDelay; } From 49a86d00990b596a7d0e473d8deb2f04f3274706 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Fri, 8 Aug 2025 15:38:29 +0200 Subject: [PATCH 5/6] Refactor QBORepository and types for improved error handling and retry logic - Enhanced the `QBORepository` class by introducing dedicated methods for handling HTTP and network retryable errors, improving the clarity and maintainability of the error management logic. - Added new constants for HTTP status codes and retry configuration, ensuring consistent usage across the repository. - Updated the `types.ts` file to include new interfaces and enums for better type safety and clarity in error handling. - Refactored unit tests for `QBORepository` and related types to cover new functionality and ensure robust testing of error scenarios. These changes contribute to a more resilient integration with QuickBooks Online, enhancing the application's ability to manage API call failures effectively. --- workers/main/src/common/utils.test.ts | 4 +- .../main/src/services/QBO/QBORepository.ts | 71 +++-- workers/main/src/services/QBO/types.test.ts | 260 +++++++++++------- workers/main/src/services/QBO/types.ts | 37 +++ 4 files changed, 237 insertions(+), 135 deletions(-) diff --git a/workers/main/src/common/utils.test.ts b/workers/main/src/common/utils.test.ts index d7a500b7..5988e6a9 100644 --- a/workers/main/src/common/utils.test.ts +++ b/workers/main/src/common/utils.test.ts @@ -86,7 +86,7 @@ describe('generateJitter', () => { const jitter = generateJitter(baseDelay); expect(jitter).toBeGreaterThanOrEqual(0); - expect(jitter).toBeLessThanOrEqual(0.1 * baseDelay); + expect(jitter).toBeLessThan(0.1 * baseDelay); }); it('should handle different baseDelay values', () => { @@ -94,6 +94,6 @@ describe('generateJitter', () => { const jitter = generateJitter(baseDelay); expect(jitter).toBeGreaterThanOrEqual(0); - expect(jitter).toBeLessThanOrEqual(0.1 * baseDelay); + expect(jitter).toBeLessThan(0.1 * baseDelay); }); }); diff --git a/workers/main/src/services/QBO/QBORepository.ts b/workers/main/src/services/QBO/QBORepository.ts index 5e9dff37..430d3008 100644 --- a/workers/main/src/services/QBO/QBORepository.ts +++ b/workers/main/src/services/QBO/QBORepository.ts @@ -6,25 +6,16 @@ import { formatDateToISOString, generateJitter } from '../../common/utils'; import { axiosConfig } from '../../configs/axios'; import { qboConfig } from '../../configs/qbo'; import { OAuth2Manager } from '../OAuth2'; -import { CustomerRevenueByRef, Invoice } from './types'; - -interface QBORetryError { - response?: { status: number; headers: Record }; - code?: string; -} - -interface DateWindow { - startDate: string; - endDate: string; -} - -interface QBOQueryResponse { - QueryResponse: { Invoice?: Invoice[] }; -} - -export interface IQBORepository { - getEffectiveRevenue(): Promise; -} +import { + CustomerRevenueByRef, + DateWindow, + HTTP_STATUS, + Invoice, + IQBORepository, + NetworkErrorCode, + QBOQueryResponse, + QBORetryError, +} from './types'; export class QBORepository implements IQBORepository { private readonly tokenManager: OAuth2Manager; @@ -54,30 +45,50 @@ export class QBORepository implements IQBORepository { } private isRetryableError(error: QBORetryError): boolean { - if (error.response) { - const statusCode = error.response.status; + return ( + this.isHttpRetryableError(error) || this.isNetworkRetryableError(error) + ); + } - if (statusCode === 429 || statusCode >= 500) return true; - } + private isHttpRetryableError(error: QBORetryError): boolean { + if (!error.response) return false; - return ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ECONNABORTED'].includes( - error.code || '', + const { status } = error.response; + + return ( + status === HTTP_STATUS.TOO_MANY_REQUESTS || + status >= HTTP_STATUS.INTERNAL_SERVER_ERROR + ); + } + + private isNetworkRetryableError(error: QBORetryError): boolean { + return Object.values(NetworkErrorCode).includes( + error.code as NetworkErrorCode, ); } private calculateRetryDelay(error: QBORetryError, attempt: number): number { - if (error.response?.status === 429) { - const retryAfter = error.response.headers['retry-after']; + const rateLimitDelay = this.getRateLimitDelay(error); + if (rateLimitDelay > 0) return rateLimitDelay; - if (retryAfter) return parseInt(retryAfter) * 1000; - } const baseDelay = Math.pow(2, attempt) * 1000; const jitter = generateJitter(baseDelay); - const maxDelay = error.response?.status === 502 ? 60000 : 30000; + const maxDelay = this.getMaxDelay(error); return Math.min(baseDelay + jitter, maxDelay); } + private getRateLimitDelay(error: QBORetryError): number { + if (error.response?.status !== HTTP_STATUS.TOO_MANY_REQUESTS) return 0; + + const retryAfter = error.response.headers['retry-after']; + return retryAfter ? parseInt(retryAfter) * 1000 : 0; + } + + private getMaxDelay(error: QBORetryError): number { + return error.response?.status === HTTP_STATUS.BAD_GATEWAY ? 60000 : 30000; + } + private getRetryDelay(error: QBORetryError, attempt: number): number { return this.calculateRetryDelay(error, attempt); } diff --git a/workers/main/src/services/QBO/types.test.ts b/workers/main/src/services/QBO/types.test.ts index 082e7667..d47e690b 100644 --- a/workers/main/src/services/QBO/types.test.ts +++ b/workers/main/src/services/QBO/types.test.ts @@ -1,109 +1,163 @@ import { describe, expect, it } from 'vitest'; -import { CustomerRevenueByRef, Invoice } from './types'; - -describe('QBO Types', () => { - describe('CustomerRevenueByRef', () => { - it('should have correct structure', () => { - const revenue: CustomerRevenueByRef = { - customer1: { - customerName: 'Test Customer', - totalAmount: 1000, - invoiceCount: 2, - }, - }; - - expect(revenue.customer1.customerName).toBe('Test Customer'); - expect(revenue.customer1.totalAmount).toBe(1000); - expect(revenue.customer1.invoiceCount).toBe(2); - }); - - it('should support multiple customers', () => { - const revenue: CustomerRevenueByRef = { - customer1: { - customerName: 'Customer One', - totalAmount: 1000, - invoiceCount: 1, - }, - customer2: { - customerName: 'Customer Two', - totalAmount: 2000, - invoiceCount: 3, - }, - }; - - expect(Object.keys(revenue)).toHaveLength(2); - expect(revenue.customer1.totalAmount).toBe(1000); - expect(revenue.customer2.totalAmount).toBe(2000); - }); +import { + CustomerRevenueByRef, + HTTP_STATUS, + Invoice, + NetworkErrorCode, + RETRY_CONFIG, +} from './types'; + +describe('NetworkErrorCode', () => { + it('should contain all expected network error codes', () => { + expect(NetworkErrorCode.ECONNRESET).toBe('ECONNRESET'); + expect(NetworkErrorCode.ETIMEDOUT).toBe('ETIMEDOUT'); + expect(NetworkErrorCode.ENOTFOUND).toBe('ENOTFOUND'); + expect(NetworkErrorCode.ECONNABORTED).toBe('ECONNABORTED'); + }); + + it('should have correct number of error codes', () => { + const errorCodes = Object.values(NetworkErrorCode); + + expect(errorCodes).toHaveLength(4); + }); + + it('should include all error codes in Object.values', () => { + const errorCodes = Object.values(NetworkErrorCode); + + expect(errorCodes).toContain('ECONNRESET'); + expect(errorCodes).toContain('ETIMEDOUT'); + expect(errorCodes).toContain('ENOTFOUND'); + expect(errorCodes).toContain('ECONNABORTED'); + }); +}); + +describe('HTTP_STATUS', () => { + it('should contain correct HTTP status codes', () => { + expect(HTTP_STATUS.TOO_MANY_REQUESTS).toBe(429); + expect(HTTP_STATUS.INTERNAL_SERVER_ERROR).toBe(500); + expect(HTTP_STATUS.BAD_GATEWAY).toBe(502); + }); + + it('should be readonly', () => { + expect(HTTP_STATUS).toBeDefined(); + expect(typeof HTTP_STATUS.TOO_MANY_REQUESTS).toBe('number'); + }); +}); + +describe('RETRY_CONFIG', () => { + it('should contain correct retry configuration values', () => { + expect(RETRY_CONFIG.DEFAULT_MAX_DELAY).toBe(30000); + expect(RETRY_CONFIG.GATEWAY_MAX_DELAY).toBe(60000); + expect(RETRY_CONFIG.RETRY_AFTER_MULTIPLIER).toBe(1000); + }); + + it('should be readonly', () => { + expect(RETRY_CONFIG).toBeDefined(); + expect(typeof RETRY_CONFIG.DEFAULT_MAX_DELAY).toBe('number'); + }); +}); + +describe('CustomerRevenueByRef', () => { + it('should have correct structure', () => { + const revenue: CustomerRevenueByRef = { + customer1: { + customerName: 'Test Customer', + totalAmount: 1000, + invoiceCount: 2, + }, + }; + + expect(revenue.customer1.customerName).toBe('Test Customer'); + expect(revenue.customer1.totalAmount).toBe(1000); + expect(revenue.customer1.invoiceCount).toBe(2); + }); + + it('should support multiple customers', () => { + const revenue: CustomerRevenueByRef = { + customer1: { + customerName: 'Customer One', + totalAmount: 1000, + invoiceCount: 1, + }, + customer2: { + customerName: 'Customer Two', + totalAmount: 2000, + invoiceCount: 3, + }, + }; + + expect(Object.keys(revenue)).toHaveLength(2); + expect(revenue.customer1.totalAmount).toBe(1000); + expect(revenue.customer2.totalAmount).toBe(2000); + }); +}); + +describe('Invoice', () => { + it('should have correct structure with CustomerRef', () => { + const invoice: Invoice = { + TotalAmt: 500, + Balance: 0, + CustomerRef: { + value: 'customer1', + name: 'Test Customer', + }, + }; + + expect(invoice.TotalAmt).toBe(500); + expect(invoice.Balance).toBe(0); + expect(invoice.CustomerRef?.value).toBe('customer1'); + expect(invoice.CustomerRef?.name).toBe('Test Customer'); }); - describe('Invoice', () => { - it('should have correct structure with CustomerRef', () => { - const invoice: Invoice = { - TotalAmt: 500, - Balance: 0, - CustomerRef: { - value: 'customer1', - name: 'Test Customer', - }, - }; - - expect(invoice.TotalAmt).toBe(500); - expect(invoice.Balance).toBe(0); - expect(invoice.CustomerRef?.value).toBe('customer1'); - expect(invoice.CustomerRef?.name).toBe('Test Customer'); - }); - - it('should handle optional CustomerRef', () => { - const invoice: Invoice = { - TotalAmt: 500, - Balance: 0, - CustomerRef: undefined, - }; - - expect(invoice.TotalAmt).toBe(500); - expect(invoice.Balance).toBe(0); - expect(invoice.CustomerRef).toBeUndefined(); - }); - - it('should handle CustomerRef with missing name', () => { - const invoice: Invoice = { - TotalAmt: 500, - Balance: 0, - CustomerRef: { - value: 'customer1', - name: undefined, - }, - }; - - expect(invoice.TotalAmt).toBe(500); - expect(invoice.Balance).toBe(0); - expect(invoice.CustomerRef?.value).toBe('customer1'); - expect(invoice.CustomerRef?.name).toBeUndefined(); - }); - - it('should handle different balance amounts', () => { - const paidInvoice: Invoice = { - TotalAmt: 1000, - Balance: 0, - CustomerRef: { - value: 'customer1', - name: 'Test Customer', - }, - }; - - const unpaidInvoice: Invoice = { - TotalAmt: 1000, - Balance: 1000, - CustomerRef: { - value: 'customer1', - name: 'Test Customer', - }, - }; - - expect(paidInvoice.Balance).toBe(0); - expect(unpaidInvoice.Balance).toBe(1000); - }); + it('should handle optional CustomerRef', () => { + const invoice: Invoice = { + TotalAmt: 500, + Balance: 0, + CustomerRef: undefined, + }; + + expect(invoice.TotalAmt).toBe(500); + expect(invoice.Balance).toBe(0); + expect(invoice.CustomerRef).toBeUndefined(); + }); + + it('should handle CustomerRef with missing name', () => { + const invoice: Invoice = { + TotalAmt: 500, + Balance: 0, + CustomerRef: { + value: 'customer1', + name: undefined, + }, + }; + + expect(invoice.TotalAmt).toBe(500); + expect(invoice.Balance).toBe(0); + expect(invoice.CustomerRef?.value).toBe('customer1'); + expect(invoice.CustomerRef?.name).toBeUndefined(); + }); + + it('should handle different balance amounts', () => { + const paidInvoice: Invoice = { + TotalAmt: 1000, + Balance: 0, + CustomerRef: { + value: 'customer1', + name: 'Test Customer', + }, + }; + + const unpaidInvoice: Invoice = { + TotalAmt: 1000, + Balance: 1000, + CustomerRef: { + value: 'customer1', + name: 'Test Customer', + }, + }; + + expect(paidInvoice.Balance).toBe(0); + expect(unpaidInvoice.Balance).toBe(1000); }); }); diff --git a/workers/main/src/services/QBO/types.ts b/workers/main/src/services/QBO/types.ts index 32d21eeb..db1155b0 100644 --- a/workers/main/src/services/QBO/types.ts +++ b/workers/main/src/services/QBO/types.ts @@ -14,3 +14,40 @@ export interface Invoice { name?: string; }; } + +export enum NetworkErrorCode { + ECONNRESET = 'ECONNRESET', + ETIMEDOUT = 'ETIMEDOUT', + ENOTFOUND = 'ENOTFOUND', + ECONNABORTED = 'ECONNABORTED', +} + +export const HTTP_STATUS = { + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, + BAD_GATEWAY: 502, +} as const; + +export const RETRY_CONFIG = { + DEFAULT_MAX_DELAY: 30000, + GATEWAY_MAX_DELAY: 60000, + RETRY_AFTER_MULTIPLIER: 1000, +} as const; + +export interface QBORetryError { + response?: { status: number; headers: Record }; + code?: string; +} + +export interface DateWindow { + startDate: string; + endDate: string; +} + +export interface QBOQueryResponse { + QueryResponse: { Invoice?: Invoice[] }; +} + +export interface IQBORepository { + getEffectiveRevenue(): Promise; +} From 4330d52b5672fd4ffb62138790a0983763802288 Mon Sep 17 00:00:00 2001 From: "anatoly.shipitz" Date: Fri, 8 Aug 2025 15:38:50 +0200 Subject: [PATCH 6/6] Enhance QBORepository error handling with additional delay calculations - Added a new line to the `calculateRetryDelay` method in the `QBORepository` class to improve the clarity of the rate limit delay calculation. - Included an additional line in the `getRateLimitDelay` method to ensure proper handling of the `retry-after` header, enhancing the robustness of the retry logic. These changes contribute to a more resilient integration with QuickBooks Online by refining the error handling and retry mechanisms. --- workers/main/src/services/QBO/QBORepository.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workers/main/src/services/QBO/QBORepository.ts b/workers/main/src/services/QBO/QBORepository.ts index 430d3008..56a7015b 100644 --- a/workers/main/src/services/QBO/QBORepository.ts +++ b/workers/main/src/services/QBO/QBORepository.ts @@ -69,6 +69,7 @@ export class QBORepository implements IQBORepository { private calculateRetryDelay(error: QBORetryError, attempt: number): number { const rateLimitDelay = this.getRateLimitDelay(error); + if (rateLimitDelay > 0) return rateLimitDelay; const baseDelay = Math.pow(2, attempt) * 1000; @@ -82,6 +83,7 @@ export class QBORepository implements IQBORepository { if (error.response?.status !== HTTP_STATUS.TOO_MANY_REQUESTS) return 0; const retryAfter = error.response.headers['retry-after']; + return retryAfter ? parseInt(retryAfter) * 1000 : 0; }