diff --git a/workers/main/src/common/errors/OAuth2Error.test.ts b/workers/main/src/common/errors/OAuth2Error.test.ts new file mode 100644 index 00000000..cb132101 --- /dev/null +++ b/workers/main/src/common/errors/OAuth2Error.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { OAuth2Error } from './OAuth2Error'; + +describe('OAuth2Error', () => { + it('should set the message, name, and default error code', () => { + const err = new OAuth2Error('test message'); + + expect(err.message).toBe('test message'); + expect(err.name).toBe('OAuth2Error'); + expect(err.code).toBe('UNKNOWN_OAUTH2_ERROR'); + }); + + it('should set the message, name, and custom error code', () => { + const err = new OAuth2Error('test message', 'CUSTOM_ERROR'); + + expect(err.message).toBe('test message'); + expect(err.name).toBe('OAuth2Error'); + expect(err.code).toBe('CUSTOM_ERROR'); + }); +}); diff --git a/workers/main/src/common/errors/OAuth2Error.ts b/workers/main/src/common/errors/OAuth2Error.ts new file mode 100644 index 00000000..bd9a6f42 --- /dev/null +++ b/workers/main/src/common/errors/OAuth2Error.ts @@ -0,0 +1,10 @@ +import { AppError } from './AppError'; + +export class OAuth2Error extends AppError { + public readonly code: string; + + constructor(message: string, code: string = 'UNKNOWN_OAUTH2_ERROR') { + super(message, 'OAuth2Error'); + this.code = code; + } +} diff --git a/workers/main/src/common/errors/index.ts b/workers/main/src/common/errors/index.ts index 8e8f9d8f..18419271 100644 --- a/workers/main/src/common/errors/index.ts +++ b/workers/main/src/common/errors/index.ts @@ -1,6 +1,7 @@ export * from './AppError'; export * from './FileUtilsError'; export * from './FinAppRepositoryError'; +export * from './OAuth2Error'; export * from './QuickBooksRepositoryError'; export * from './SlackRepositoryError'; export * from './TargetUnitRepositoryError'; diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.basic.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.basic.test.ts new file mode 100644 index 00000000..96de53dc --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.basic.test.ts @@ -0,0 +1,99 @@ +import { promises as fs } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; +import { TokenData } from './types'; + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +describe('FileTokenStorage - Basic', () => { + let fileTokenStorage: FileTokenStorage; + let mockMkdir: ReturnType; + let mockWriteFile: ReturnType; + + const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + beforeEach(() => { + mockMkdir = vi.mocked(fs.mkdir); + mockWriteFile = vi.mocked(fs.writeFile); + + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + fileTokenStorage = new FileTokenStorage('test-service'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create instance with default service name', () => { + const storage = new FileTokenStorage(); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('should create instance with custom service name', () => { + const storage = new FileTokenStorage('custom-service'); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + + it('should create instance with custom token file path', () => { + const customPath = '/custom/path/token.json'; + const storage = new FileTokenStorage('test-service', customPath); + + expect(storage).toBeInstanceOf(FileTokenStorage); + }); + }); + + describe('save', () => { + it('should save token data successfully', async () => { + await fileTokenStorage.save(mockTokenData); + + expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { + recursive: true, + }); + expect(mockWriteFile).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify(mockTokenData, null, 2), + ); + }); + + it('should throw OAuth2Error when save fails', async () => { + mockWriteFile.mockRejectedValue(new Error('Write failed')); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + OAuth2Error, + ); + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + 'Failed to save token data to file', + ); + }); + + it('should throw OAuth2Error when mkdir fails', async () => { + mockMkdir.mockRejectedValue(new Error('Mkdir failed')); + + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + OAuth2Error, + ); + await expect(fileTokenStorage.save(mockTokenData)).rejects.toThrow( + 'Failed to save token data to file', + ); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.clear.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.clear.test.ts new file mode 100644 index 00000000..39602302 --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.clear.test.ts @@ -0,0 +1,65 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2Error } from '../../common/errors'; +import { FileTokenStorage } from './FileTokenStorage'; + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock('path', () => ({ + join: vi.fn(), +})); + +describe('FileTokenStorage - Clear', () => { + let fileTokenStorage: FileTokenStorage; + let mockJoin: ReturnType; + let mockUnlink: ReturnType; + + beforeEach(() => { + mockJoin = vi.mocked(join); + mockUnlink = vi.mocked(fs.unlink); + + mockJoin.mockReturnValue('/test/path/token.json'); + mockUnlink.mockResolvedValue(undefined); + + fileTokenStorage = new FileTokenStorage('test-service'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('clear', () => { + it('should clear token data successfully', async () => { + await fileTokenStorage.clear(); + + expect(mockUnlink).toHaveBeenCalledWith('/test/path/token.json'); + }); + + it('should not throw error when file does not exist', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + + error.code = 'ENOENT'; + mockUnlink.mockRejectedValue(error); + + await expect(fileTokenStorage.clear()).resolves.toBeUndefined(); + }); + + it('should throw OAuth2Error when clear fails with other error', async () => { + mockUnlink.mockRejectedValue(new Error('Delete failed')); + + await expect(fileTokenStorage.clear()).rejects.toThrow(OAuth2Error); + await expect(fileTokenStorage.clear()).rejects.toThrow( + 'Failed to clear token data from file', + ); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.load.test.ts b/workers/main/src/services/OAuth2/FileTokenStorage.load.test.ts new file mode 100644 index 00000000..e9312b13 --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.load.test.ts @@ -0,0 +1,117 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FileTokenStorage } from './FileTokenStorage'; +import { TokenData } from './types'; + +vi.mock('fs', () => ({ + promises: { + mkdir: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + unlink: vi.fn(), + }, +})); + +vi.mock('path', () => ({ + join: vi.fn(), +})); + +describe('FileTokenStorage - Load', () => { + let fileTokenStorage: FileTokenStorage; + let mockJoin: ReturnType; + let mockReadFile: ReturnType; + + const mockTokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + beforeEach(() => { + mockJoin = vi.mocked(join); + mockReadFile = vi.mocked(fs.readFile); + + mockJoin.mockReturnValue('/test/path/token.json'); + mockReadFile.mockResolvedValue(JSON.stringify(mockTokenData)); + + fileTokenStorage = new FileTokenStorage('test-service'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('load', () => { + it('should load valid token data successfully', async () => { + const result = await fileTokenStorage.load(); + + expect(mockReadFile).toHaveBeenCalledWith( + '/test/path/token.json', + 'utf8', + ); + expect(result).toEqual(mockTokenData); + }); + + it('should return null when file does not exist', async () => { + const error = new Error('File not found') as NodeJS.ErrnoException; + + error.code = 'ENOENT'; + mockReadFile.mockRejectedValue(error); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when file read fails with other error', async () => { + mockReadFile.mockRejectedValue(new Error('Read failed')); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is invalid', async () => { + const invalidData = { invalid: 'data' }; + + mockReadFile.mockResolvedValue(JSON.stringify(invalidData)); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data has missing fields', async () => { + const invalidData = { + refresh_token: 'test-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + mockReadFile.mockResolvedValue(JSON.stringify(invalidData)); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is null', async () => { + mockReadFile.mockResolvedValue('null'); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + + it('should return null when token data is not an object', async () => { + mockReadFile.mockResolvedValue('"string"'); + + const result = await fileTokenStorage.load(); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/FileTokenStorage.ts b/workers/main/src/services/OAuth2/FileTokenStorage.ts new file mode 100644 index 00000000..138785fa --- /dev/null +++ b/workers/main/src/services/OAuth2/FileTokenStorage.ts @@ -0,0 +1,72 @@ +import { promises as fs } from 'fs'; +import { join } from 'path'; + +import { OAuth2Error } from '../../common/errors'; +import { TokenStorageProvider } from './types'; +import { TokenData } from './types'; + +export class FileTokenStorage implements TokenStorageProvider { + private readonly tokenFilePath: string; + private readonly serviceName: string; + + constructor(serviceName: string = 'qbo', tokenFilePath?: string) { + this.serviceName = serviceName; + this.tokenFilePath = + tokenFilePath || + join(process.cwd(), 'data', 'oauth2_tokens', `${serviceName}.json`); + } + + async save(tokenData: TokenData): Promise { + try { + const dir = join(this.tokenFilePath, '..'); + + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + this.tokenFilePath, + JSON.stringify(tokenData, null, 2), + ); + } catch { + throw new OAuth2Error('Failed to save token data to file'); + } + } + + async load(): Promise { + try { + const data = await fs.readFile(this.tokenFilePath, 'utf8'); + const tokenData = JSON.parse(data) as TokenData; + + if (!this.isValidTokenData(tokenData)) { + return null; + } + + return tokenData; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + + return null; + } + } + + async clear(): Promise { + try { + await fs.unlink(this.tokenFilePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new OAuth2Error('Failed to clear token data from file'); + } + } + } + + private isValidTokenData(data: unknown): data is TokenData { + return ( + typeof data === 'object' && + data !== null && + typeof (data as TokenData).access_token === 'string' && + typeof (data as TokenData).refresh_token === 'string' && + typeof (data as TokenData).expires_at === 'number' && + typeof (data as TokenData).token_type === 'string' + ); + } +} diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.basic.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.basic.test.ts new file mode 100644 index 00000000..ec40b615 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.basic.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2TokenManager } from './OAuth2TokenManager'; +import { TokenData } from './types'; + +vi.mock('./FileTokenStorage', () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(null), + clear: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('./OAuth2TokenRefreshProvider', () => ({ + OAuth2TokenRefreshProvider: vi.fn().mockImplementation(() => ({ + refreshToken: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }), + })), +})); + +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, +})); + +describe('OAuth2TokenManager - Basic', () => { + let tokenManager: OAuth2TokenManager; + + beforeEach(async () => { + tokenManager = new OAuth2TokenManager('qbo', 'test-refresh-token'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should create OAuth2TokenManager instance', () => { + expect(tokenManager).toBeInstanceOf(OAuth2TokenManager); + }); + + it('should return false when no token is set', () => { + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return false when token is expired', () => { + const expiredTokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(expiredTokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return true when token is valid', () => { + const validTokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(validTokenData); + expect(tokenManager.isTokenValid()).toBe(true); + }); + + it('should return refresh token from config when no cached token', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + }); + + it('should return cached refresh token when available', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'cached-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.getCurrentRefreshToken()).toBe('cached-refresh-token'); + }); + + it('should return cached access token when valid', async () => { + const tokenData: TokenData = { + access_token: 'valid-access-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('valid-access-token'); + }); + + it('should refresh token when expired and return new access token', async () => { + const expiredTokenData: TokenData = { + access_token: 'expired-access-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(expiredTokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('new-access-token'); + }); + + it('should handle malformed token data gracefully', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle empty access token', () => { + const tokenData: TokenData = { + access_token: '', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle empty refresh token', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: '', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle invalid expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: NaN, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle token refresh when token is within buffer time', async () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('valid-token'); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.errors.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.errors.test.ts new file mode 100644 index 00000000..06ee4e42 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.errors.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2TokenManager } from './OAuth2TokenManager'; +import { TokenData } from './types'; + +vi.mock('./FileTokenStorage', () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(null), + clear: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('./OAuth2TokenRefreshProvider', () => ({ + OAuth2TokenRefreshProvider: vi.fn().mockImplementation(() => ({ + refreshToken: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }), + })), +})); + +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, +})); + +describe('OAuth2TokenManager - Error Handling', () => { + let tokenManager: OAuth2TokenManager; + + beforeEach(async () => { + tokenManager = new OAuth2TokenManager('qbo', 'test-refresh-token'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('token validation', () => { + it('should return false for empty access token', () => { + const tokenData: TokenData = { + access_token: '', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return false for null access token', () => { + const tokenData: TokenData = { + access_token: null as unknown as string, + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return false for expired token', () => { + const tokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should return true for valid token', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(true); + }); + }); + + describe('refresh token handling', () => { + it('should return default refresh token when no cached token', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + }); + + it('should return cached refresh token when available', () => { + const tokenData: TokenData = { + access_token: 'test-access-token', + refresh_token: 'cached-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.getCurrentRefreshToken()).toBe( + 'cached-refresh-token', + ); + }); + }); + + describe('edge cases', () => { + it('should handle negative expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: -1000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle zero expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: 0, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle very small expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Number.MIN_SAFE_INTEGER, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.storage.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.storage.test.ts new file mode 100644 index 00000000..b617c4f4 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.storage.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { OAuth2TokenManager } from './OAuth2TokenManager'; +import { TokenData } from './types'; + +vi.mock('./FileTokenStorage', () => ({ + FileTokenStorage: vi.fn().mockImplementation(() => ({ + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(null), + clear: vi.fn().mockResolvedValue(undefined), + })), +})); + +vi.mock('./OAuth2TokenRefreshProvider', () => ({ + OAuth2TokenRefreshProvider: vi.fn().mockImplementation(() => ({ + refreshToken: vi.fn().mockResolvedValue({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }), + })), +})); + +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + refreshToken: 'test-refresh-token', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, +})); + +describe('OAuth2TokenManager - Storage & Edge Cases', () => { + let tokenManager: OAuth2TokenManager; + + beforeEach(async () => { + tokenManager = new OAuth2TokenManager('qbo', 'test-refresh-token'); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should load tokens from storage on initialization', () => { + expect(tokenManager).toBeInstanceOf(OAuth2TokenManager); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle empty string access token', () => { + const tokenData: TokenData = { + access_token: '', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle null access token', () => { + const tokenData: TokenData = { + access_token: null as unknown as string, + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle invalid expiry date', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: NaN, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle expired token', () => { + const tokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle valid token', () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + expect(tokenManager.isTokenValid()).toBe(true); + }); + + it('should return default refresh token when no cached token', () => { + expect(tokenManager.getCurrentRefreshToken()).toBe('test-refresh-token'); + }); + + it('should handle token refresh when token is within buffer time', async () => { + const tokenData: TokenData = { + access_token: 'valid-token', + refresh_token: 'refresh-token', + expires_at: Date.now() + 600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(tokenData); + const accessToken = await tokenManager.getAccessToken(); + + expect(accessToken).toBe('valid-token'); + }); + + it('should handle concurrent token refresh requests', async () => { + const expiredTokenData: TokenData = { + access_token: 'expired-token', + refresh_token: 'refresh-token', + expires_at: Date.now() - 3600000, + token_type: 'Bearer', + }; + + tokenManager.setTokenDataForTesting(expiredTokenData); + + const promises = [ + tokenManager.getAccessToken(), + tokenManager.getAccessToken(), + tokenManager.getAccessToken(), + ]; + + const results = await Promise.all(promises); + + results.forEach((result) => { + expect(result).toBe('new-access-token'); + }); + }); + + it('should handle setTokenDataForTesting with null data', () => { + tokenManager.setTokenDataForTesting(null as unknown as TokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); + + it('should handle setTokenDataForTesting with undefined data', () => { + tokenManager.setTokenDataForTesting(undefined as unknown as TokenData); + expect(tokenManager.isTokenValid()).toBe(false); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenManager.ts b/workers/main/src/services/OAuth2/OAuth2TokenManager.ts new file mode 100644 index 00000000..d92d9d77 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenManager.ts @@ -0,0 +1,221 @@ +import { OAuth2Error } from '../../common/errors'; +import { ERROR_CODES, ERROR_MESSAGES, TOKEN_CONFIG } from './constants'; +import { FileTokenStorage } from './FileTokenStorage'; +import { OAuth2TokenRefreshProvider } from './OAuth2TokenRefreshProvider'; +import { + OAuth2TokenManagerInterface, + TokenRefreshProvider, + TokenStorageProvider, +} from './types'; +import { TokenData } from './types'; + +export class OAuth2TokenManager implements OAuth2TokenManagerInterface { + private accessToken: string | null = null; + private tokenExpiry: Date | null = null; + private refreshToken: string | null = null; + private refreshPromise: Promise | null = null; + + private readonly storage: TokenStorageProvider; + private readonly refreshProvider: TokenRefreshProvider; + private readonly defaultRefreshToken: string; + + constructor(serviceName: string, defaultRefreshToken: string) { + this.storage = new FileTokenStorage(serviceName); + this.refreshProvider = new OAuth2TokenRefreshProvider(); + this.defaultRefreshToken = defaultRefreshToken; + } + + private async initializeTokens(): Promise { + await this.loadTokens(); + } + + async getAccessToken(): Promise { + await this.initializeTokens(); + + if (!this.accessToken) { + throw new OAuth2Error(ERROR_MESSAGES.NO_ACCESS_TOKEN); + } + + await this.refreshAccessToken(); + + if (!this.accessToken) { + throw new OAuth2Error(ERROR_MESSAGES.NO_ACCESS_TOKEN); + } + + return this.accessToken; + } + + getCurrentRefreshToken(): string { + return this.refreshToken ?? this.defaultRefreshToken; + } + + isTokenValid(): boolean { + if (!this.accessToken || !this.refreshToken || !this.tokenExpiry) { + return false; + } + + if (this.accessToken.length === 0 || this.refreshToken.length === 0) { + return false; + } + + return Date.now() < this.tokenExpiry.getTime(); + } + + private async loadTokens(): Promise { + try { + const tokenData = await this.storage.load(); + + if (tokenData) { + this.setTokenData(tokenData); + } + } catch { + throw new OAuth2Error(ERROR_MESSAGES.LOAD_TOKENS_FAILED); + } + + if (!this.refreshToken) { + this.refreshToken = this.defaultRefreshToken; + } + } + + private isValidTokenData(tokenData: TokenData): boolean { + if (!tokenData) { + return false; + } + + if ( + typeof tokenData.access_token !== 'string' || + tokenData.access_token.length === 0 + ) { + return false; + } + + if ( + typeof tokenData.refresh_token !== 'string' || + tokenData.refresh_token.length === 0 + ) { + return false; + } + + if ( + typeof tokenData.expires_at !== 'number' || + !Number.isFinite(tokenData.expires_at) || + tokenData.expires_at <= 0 + ) { + return false; + } + + const expiryDate = new Date(tokenData.expires_at); + + if (isNaN(expiryDate.getTime())) { + return false; + } + + return true; + } + + private async refreshAccessToken(): Promise { + if (this.isWithinRefreshBuffer()) { + return; + } + + if (this.refreshPromise) { + await this.refreshPromise; + + return; + } + + this.refreshPromise = this.performTokenRefresh(); + try { + await this.refreshPromise; + } finally { + this.refreshPromise = null; + } + } + + private async performTokenRefresh(): Promise { + const currentRefreshToken = this.getCurrentRefreshToken(); + + try { + const newTokenData = + await this.refreshProvider.refreshToken(currentRefreshToken); + + this.setTokenData(newTokenData); + await this.saveTokens(); + } catch (error) { + if ( + error instanceof OAuth2Error && + error.code === ERROR_CODES.INVALID_GRANT + ) { + await this.clearStoredTokens(); + } + throw error; + } + } + + private async clearStoredTokens(): Promise { + this.accessToken = null; + this.tokenExpiry = null; + this.refreshToken = null; + this.refreshPromise = null; + + try { + await this.storage.clear(); + } catch { + throw new OAuth2Error(ERROR_MESSAGES.CLEAR_TOKENS_FAILED); + } + } + + private isWithinRefreshBuffer(): boolean { + if (!this.tokenExpiry) { + return false; + } + + const bufferTime = TOKEN_CONFIG.TOKEN_BUFFER_MINUTES * 60 * 1000; + const now = new Date(); + + return now.getTime() < this.tokenExpiry.getTime() - bufferTime; + } + + private setTokenData(tokenData: TokenData): void { + if (!this.isValidTokenData(tokenData)) { + throw new OAuth2Error(ERROR_MESSAGES.INVALID_TOKEN_DATA); + } + + this.accessToken = tokenData.access_token; + this.refreshToken = tokenData.refresh_token; + this.tokenExpiry = new Date(tokenData.expires_at); + } + + private async saveTokens(): Promise { + if (!this.accessToken || !this.refreshToken || !this.tokenExpiry) { + return; + } + + const tokenData: TokenData = { + access_token: this.accessToken, + refresh_token: this.refreshToken, + expires_at: this.tokenExpiry.getTime(), + token_type: TOKEN_CONFIG.DEFAULT_TOKEN_TYPE, + }; + + await this.storage.save(tokenData); + } + + setTokenDataForTesting(tokenData: TokenData): void { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + 'setTokenDataForTesting can only be used in test environments', + ); + } + + if (tokenData) { + this.accessToken = tokenData.access_token; + this.refreshToken = tokenData.refresh_token; + this.tokenExpiry = new Date(tokenData.expires_at); + } else { + this.accessToken = null; + this.refreshToken = null; + this.tokenExpiry = null; + } + } +} diff --git a/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.test.ts b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.test.ts new file mode 100644 index 00000000..6de5db87 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { OAuth2TokenRefreshProvider } from './OAuth2TokenRefreshProvider'; + +describe('OAuth2TokenRefreshProvider', () => { + it('should create instance successfully', () => { + const refreshProvider = new OAuth2TokenRefreshProvider(); + + expect(refreshProvider).toBeInstanceOf(OAuth2TokenRefreshProvider); + }); + + it('should have refreshToken method', () => { + const refreshProvider = new OAuth2TokenRefreshProvider(); + + expect(typeof refreshProvider.refreshToken).toBe('function'); + }); + + it('should have refreshToken method that returns a promise', () => { + const refreshProvider = new OAuth2TokenRefreshProvider(); + const result = refreshProvider.refreshToken('test-token'); + + expect(result).toBeInstanceOf(Promise); + }); +}); diff --git a/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.ts b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.ts new file mode 100644 index 00000000..7d95e810 --- /dev/null +++ b/workers/main/src/services/OAuth2/OAuth2TokenRefreshProvider.ts @@ -0,0 +1,93 @@ +import axios from 'axios'; + +import { OAuth2Error } from '../../common/errors'; +import { qboConfig } from '../../configs/qbo'; +import { ERROR_CODES, TOKEN_CONFIG } from './constants'; +import { TokenRefreshProvider } from './types'; +import { TokenData, TokenResponse } from './types'; + +export class OAuth2TokenRefreshProvider implements TokenRefreshProvider { + async refreshToken(refreshToken: string): Promise { + const tokenData = { + grant_type: 'refresh_token', + refresh_token: refreshToken, + }; + + const authString = Buffer.from( + `${qboConfig.clientId}:${qboConfig.clientSecret}`, + ).toString('base64'); + + try { + const response = await axios.post( + qboConfig.tokenEndpoint, + new URLSearchParams(tokenData).toString(), + { + headers: { + 'Content-Type': TOKEN_CONFIG.CONTENT_TYPE, + 'Authorization': `Basic ${authString}`, + 'Accept': TOKEN_CONFIG.ACCEPT_TYPE, + }, + }, + ); + + return this.mapResponseToTokenData(response.data); + } catch (error) { + throw this.handleRefreshError(error); + } + } + + private handleRefreshError(error: unknown): Error { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const data = error.response?.data as { error?: string } | undefined; + + if (status === 400 && data?.error === 'invalid_grant') { + return new OAuth2Error( + 'Refresh token is invalid or expired. Please obtain a new refresh token from QuickBooks.', + ERROR_CODES.INVALID_GRANT, + ); + } + + if (status === 401) { + return new OAuth2Error( + 'Invalid client credentials. Please check QBO_CLIENT_ID and QBO_CLIENT_SECRET.', + ERROR_CODES.INVALID_CLIENT, + ); + } + + if (status === 403) { + return new OAuth2Error( + 'Access denied. Please check your QuickBooks app permissions.', + ERROR_CODES.ACCESS_DENIED, + ); + } + + return new OAuth2Error( + `QBO API error (${status}): ${data?.error || error.message}`, + ERROR_CODES.API_ERROR, + ); + } + + return new OAuth2Error( + `Failed to refresh access token: ${error instanceof Error ? error.message : String(error)}`, + ERROR_CODES.NETWORK_ERROR, + ); + } + + private mapResponseToTokenData(response: TokenResponse): TokenData { + const expiryTime = new Date(); + + expiryTime.setSeconds( + expiryTime.getSeconds() + + response.expires_in - + TOKEN_CONFIG.EXPIRY_BUFFER_SECONDS, + ); + + return { + access_token: response.access_token, + refresh_token: response.refresh_token, + expires_at: expiryTime.getTime(), + token_type: response.token_type, + }; + } +} diff --git a/workers/main/src/services/OAuth2/constants.ts b/workers/main/src/services/OAuth2/constants.ts new file mode 100644 index 00000000..ff5de860 --- /dev/null +++ b/workers/main/src/services/OAuth2/constants.ts @@ -0,0 +1,27 @@ +export const TOKEN_CONFIG = { + TOKEN_BUFFER_MINUTES: 5, + EXPIRY_BUFFER_SECONDS: 60, + DEFAULT_TOKEN_TYPE: 'Bearer', + CONTENT_TYPE: 'application/x-www-form-urlencoded', + ACCEPT_TYPE: 'application/json', +} as const; + +export const ERROR_MESSAGES = { + NO_ACCESS_TOKEN: 'Failed to obtain access token', + NO_REFRESH_TOKEN: 'No refresh token available in configuration or file', + REFRESH_FAILED: 'Failed to refresh access token', + INVALID_TOKEN_DATA: 'Invalid token data structure', + LOAD_TOKENS_FAILED: 'Failed to load OAuth2 tokens', + CLEAR_TOKENS_FAILED: 'Failed to clear OAuth2 tokens', +} as const; + +export const ERROR_CODES = { + INVALID_GRANT: 'INVALID_GRANT', + INVALID_CLIENT: 'INVALID_CLIENT', + ACCESS_DENIED: 'ACCESS_DENIED', + API_ERROR: 'API_ERROR', + NETWORK_ERROR: 'NETWORK_ERROR', + INVALID_TOKEN_DATA: 'INVALID_TOKEN_DATA', + CLEAR_TOKENS_FAILED: 'CLEAR_TOKENS_FAILED', + LOAD_TOKENS_FAILED: 'LOAD_TOKENS_FAILED', +} as const; diff --git a/workers/main/src/services/OAuth2/types.ts b/workers/main/src/services/OAuth2/types.ts new file mode 100644 index 00000000..78391321 --- /dev/null +++ b/workers/main/src/services/OAuth2/types.ts @@ -0,0 +1,33 @@ +export interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; +} + +export interface TokenData { + access_token: string; + refresh_token: string; + expires_at: number; + token_type: string; +} + +export interface TokenStorageProvider { + save(tokenData: TokenData): Promise; + + load(): Promise; + + clear(): Promise; +} + +export interface TokenRefreshProvider { + refreshToken(refreshToken: string): Promise; +} + +export interface OAuth2TokenManagerInterface { + getAccessToken(): Promise; + + getCurrentRefreshToken(): string; + + isTokenValid(): boolean; +} 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..a8e912c1 --- /dev/null +++ b/workers/main/src/services/QBO/QBORepository.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { QuickBooksRepositoryError } from '../../common/errors'; +import { QBORepository } from './QBORepository'; + +// Mock all dependencies with proper schemas +vi.mock('axios'); +vi.mock('axios-retry'); +vi.mock('../OAuth2/OAuth2TokenManager'); + +// Mock all config schemas +vi.mock('../../configs/qbo', () => ({ + qboConfig: { + effectiveRevenueMonths: 4, + apiUrl: 'https://sandbox-quickbooks.api.intuit.com', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + companyId: 'test-company-id', + refreshToken: 'test-refresh-token', + tokenEndpoint: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, + qboSchema: z.object({ + QBO_API_URL: z.string().url().min(1, 'QBO_API_URL is required'), + 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 + .number() + .int() + .min(1) + .max(12) + .optional() + .default(4), + }), +})); + +vi.mock('../../configs/axios', () => ({ + axiosConfig: { + timeout: 30000, + maxRetries: 3, + headers: { + 'Content-Type': 'application/json', + }, + }, + axiosSchema: z.object({ + AXIOS_TIMEOUT: z.number().optional(), + AXIOS_MAX_RETRIES: z.number().optional(), + }), +})); + +vi.mock('../../configs/mongoDatabase', () => ({ + mongoDatabaseSchema: z.object({ + MONGODB_URI: z.string().optional(), + }), +})); + +vi.mock('../../configs/redmineDatabase', () => ({ + redmineDatabaseSchema: z.object({ + REDMINE_HOST: z.string().optional(), + REDMINE_API_KEY: z.string().optional(), + }), +})); + +vi.mock('../../configs/slack', () => ({ + slackSchema: z.object({ + SLACK_BOT_TOKEN: z.string().optional(), + }), +})); + +vi.mock('../../configs/temporal', () => ({ + temporalSchema: z.object({ + TEMPORAL_HOST: z.string().optional(), + }), +})); + +vi.mock('../../configs/worker', () => ({ + workerSchema: z.object({ + WORKER_NAME: z.string().optional(), + }), +})); + +describe('QBORepository - Comprehensive Tests', () => { + describe('constructor validation', () => { + it('should create instance with valid configuration', () => { + const repository = new QBORepository(); + + expect(repository).toBeInstanceOf(QBORepository); + }); + + it('should implement IQBORepository interface', () => { + const repository = new QBORepository(); + + expect(typeof repository.getEffectiveRevenue).toBe('function'); + }); + }); + + describe('error handling', () => { + it('should throw QuickBooksRepositoryError for configuration errors', () => { + expect(() => { + throw new QuickBooksRepositoryError('Test error'); + }).toThrow(QuickBooksRepositoryError); + }); + + it('should have proper error message format', () => { + const error = new QuickBooksRepositoryError('Test error message'); + + expect(error.message).toBe('Test error message'); + }); + }); + + describe('interface compliance', () => { + it('should have getEffectiveRevenue method', () => { + const repository = new QBORepository(); + + expect(typeof repository.getEffectiveRevenue).toBe('function'); + }); + }); +}); diff --git a/workers/main/src/services/QBO/QBORepository.ts b/workers/main/src/services/QBO/QBORepository.ts new file mode 100644 index 00000000..8f55b105 --- /dev/null +++ b/workers/main/src/services/QBO/QBORepository.ts @@ -0,0 +1,195 @@ +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 { OAuth2TokenManager } from '../OAuth2/OAuth2TokenManager'; +import { CustomerRevenueByRef, Invoice } from './types'; + +interface QBOQueryResponse { + QueryResponse: { + Invoice?: Invoice[]; + }; +} + +export interface IQBORepository { + getEffectiveRevenue(): Promise; +} + +interface DateWindow { + startDate: string; + endDate: string; +} + +export class QBORepository implements IQBORepository { + private readonly tokenManager: OAuth2TokenManager; + private readonly axiosInstance: ReturnType; + + constructor() { + this.tokenManager = new OAuth2TokenManager( + `qbo-${qboConfig.companyId}`, + qboConfig.refreshToken, + ); + + this.axiosInstance = axios.create({ + timeout: axiosConfig.timeout, + headers: { + ...axiosConfig.headers, + }, + }); + + axiosRetry(this.axiosInstance, { + retries: axiosConfig.maxRetries, + retryDelay: (retryCount, error) => this.getRetryDelay(error, retryCount), + retryCondition: (error) => this.isRetryableError(error), + }); + } + + private isRetryableError(error: any): boolean { + if (error.response) { + const statusCode = error.response.status; + + // Rate limiting errors (retryable) + if (statusCode === 429) { + return true; + } + + // Server errors (retryable) - including 502 Bad Gateway + if (statusCode >= 500) { + return true; + } + } + + // Network errors (retryable) + if ( + error.code === 'ECONNRESET' || + error.code === 'ETIMEDOUT' || + error.code === 'ENOTFOUND' + ) { + return true; + } + + // Axios timeout errors (retryable) + if (error.code === 'ECONNABORTED') { + return true; + } + + return false; + } + + private calculateRetryDelay(error: any, attempt: number): number { + if (error.response?.status === 429) { + const retryAfter = error.response.headers['retry-after']; + + if (retryAfter) { + return parseInt(retryAfter) * 1000; + } + } + + // Enhanced exponential backoff with jitter + const baseDelay = Math.pow(2, attempt) * 1000; // 2^attempt * 1000ms + const jitter = Math.random() * 0.1 * baseDelay; // 10% jitter + + // Special handling for 502 errors - longer delays + if (error.response?.status === 502) { + const extendedDelay = baseDelay * 1.5; // 50% longer delay for 502 errors + + return Math.min(extendedDelay + jitter, 60000); // Max 60 seconds for 502 + } + + return Math.min(baseDelay + jitter, 30000); // Max 30 seconds for other errors + } + + private getRetryDelay(error: any, 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; + }; +}