diff --git a/workers/main/src/common/utils.test.ts b/workers/main/src/common/utils.test.ts index 6146e449..5988e6a9 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; @@ -79,3 +79,21 @@ describe('formatDateToISOString', () => { expect(result).toBe('2024-12-31'); }); }); + +describe('generateJitter', () => { + it('should generate jitter between 0 and 10% of baseDelay', () => { + const baseDelay = 1000; + const jitter = generateJitter(baseDelay); + + expect(jitter).toBeGreaterThanOrEqual(0); + expect(jitter).toBeLessThan(0.1 * baseDelay); + }); + + it('should handle different baseDelay values', () => { + const baseDelay = 500; + const jitter = generateJitter(baseDelay); + + expect(jitter).toBeGreaterThanOrEqual(0); + expect(jitter).toBeLessThan(0.1 * baseDelay); + }); +}); diff --git a/workers/main/src/common/utils.ts b/workers/main/src/common/utils.ts index 35387f56..725c0e1a 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) / 0x100000000; // Convert to [0,1) range + + return randomValue * 0.1 * baseDelay; +} 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..56a7015b --- /dev/null +++ b/workers/main/src/services/QBO/QBORepository.ts @@ -0,0 +1,172 @@ +import axios from 'axios'; +import axiosRetry from 'axios-retry'; + +import { QuickBooksRepositoryError } from '../../common/errors'; +import { formatDateToISOString, generateJitter } from '../../common/utils'; +import { axiosConfig } from '../../configs/axios'; +import { qboConfig } from '../../configs/qbo'; +import { OAuth2Manager } from '../OAuth2'; +import { + CustomerRevenueByRef, + DateWindow, + HTTP_STATUS, + Invoice, + IQBORepository, + NetworkErrorCode, + QBOQueryResponse, + QBORetryError, +} from './types'; + +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 { + return ( + this.isHttpRetryableError(error) || this.isNetworkRetryableError(error) + ); + } + + private isHttpRetryableError(error: QBORetryError): boolean { + if (!error.response) return false; + + 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 { + const rateLimitDelay = this.getRateLimitDelay(error); + + if (rateLimitDelay > 0) return rateLimitDelay; + + const baseDelay = Math.pow(2, attempt) * 1000; + const jitter = generateJitter(baseDelay); + 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); + } + + 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..d47e690b --- /dev/null +++ b/workers/main/src/services/QBO/types.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; + +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'); + }); + + 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..db1155b0 --- /dev/null +++ b/workers/main/src/services/QBO/types.ts @@ -0,0 +1,53 @@ +export interface CustomerRevenueByRef { + [customerRefId: string]: { + customerName: string; + totalAmount: number; + invoiceCount: number; + }; +} + +export interface Invoice { + TotalAmt: number; + Balance: number; + CustomerRef?: { + value: string; + 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; +}