|
| 1 | +import { resetEnvMock, setEnv } from '@sim/testing' |
| 2 | +import { afterEach, beforeEach, describe, expect, it } from 'vitest' |
| 3 | +import { QuickBooksBlock } from '@/blocks/blocks/quickbooks' |
| 4 | +import { |
| 5 | + quickbooksCreateDepositTool, |
| 6 | + quickbooksCreateJournalEntryTool, |
| 7 | + quickbooksReadAccountingTransactionsTool, |
| 8 | + quickbooksUpdateDepositTool, |
| 9 | + quickbooksUpdateJournalEntryTool, |
| 10 | +} from '@/tools/quickbooks' |
| 11 | +import { |
| 12 | + buildQuickBooksCreateDepositBody, |
| 13 | + buildQuickBooksCreateJournalEntryBody, |
| 14 | + buildQuickBooksUpdateDepositBody, |
| 15 | + buildQuickBooksUpdateJournalEntryBody, |
| 16 | + parseQuickBooksDepositLines, |
| 17 | + parseQuickBooksJournalLines, |
| 18 | +} from '@/tools/quickbooks/accounting_utils' |
| 19 | +import type { |
| 20 | + QuickBooksCreateDepositParams, |
| 21 | + QuickBooksCreateJournalEntryParams, |
| 22 | + QuickBooksReadAccountingTransactionsParams, |
| 23 | +} from '@/tools/quickbooks/types' |
| 24 | + |
| 25 | +const authParams = { accessToken: 'access-token', realmId: '123456789' } |
| 26 | +const journalLines = [ |
| 27 | + { postingType: 'debit' as const, amount: 100, accountId: '7', description: 'Sanitized debit' }, |
| 28 | + { postingType: 'credit' as const, amount: 100, accountId: '35' }, |
| 29 | +] |
| 30 | +const depositLines = [{ amount: 100, accountId: '7', description: 'Sanitized source' }] |
| 31 | + |
| 32 | +beforeEach(() => setEnv({ QUICKBOOKS_ENV: 'sandbox' })) |
| 33 | +afterEach(resetEnvMock) |
| 34 | + |
| 35 | +describe('QuickBooks accounting reader', () => { |
| 36 | + const listParams: QuickBooksReadAccountingTransactionsParams = { |
| 37 | + ...authParams, |
| 38 | + transactionType: 'journal_entry', |
| 39 | + readMode: 'list', |
| 40 | + startPosition: 2, |
| 41 | + maxResults: 25, |
| 42 | + } |
| 43 | + |
| 44 | + it.each([ |
| 45 | + ['journal_entry', 'JournalEntry', 'journalentry'], |
| 46 | + ['deposit', 'Deposit', 'deposit'], |
| 47 | + ['transfer', 'Transfer', 'transfer'], |
| 48 | + ] as const)('maps %s to fixed list and by-ID contracts', (transactionType, entity, resource) => { |
| 49 | + const requestUrl = quickbooksReadAccountingTransactionsTool.request.url as ( |
| 50 | + params: QuickBooksReadAccountingTransactionsParams |
| 51 | + ) => string |
| 52 | + const listUrl = new URL(requestUrl({ ...listParams, transactionType })) |
| 53 | + expect(listUrl.pathname).toBe('/v3/company/123456789/query') |
| 54 | + expect(listUrl.searchParams.get('query')).toBe( |
| 55 | + `SELECT * FROM ${entity} STARTPOSITION 2 MAXRESULTS 25` |
| 56 | + ) |
| 57 | + expect(listUrl.searchParams.get('minorversion')).toBe('75') |
| 58 | + |
| 59 | + const byIdUrl = new URL( |
| 60 | + requestUrl({ |
| 61 | + ...listParams, |
| 62 | + transactionType, |
| 63 | + readMode: 'by_id', |
| 64 | + transactionId: ' A/B ', |
| 65 | + }) |
| 66 | + ) |
| 67 | + expect(byIdUrl.pathname).toBe(`/v3/company/123456789/${resource}/A%2FB`) |
| 68 | + }) |
| 69 | + |
| 70 | + it('preserves native list and by-ID records', async () => { |
| 71 | + await expect( |
| 72 | + quickbooksReadAccountingTransactionsTool.transformResponse!( |
| 73 | + Response.json({ |
| 74 | + QueryResponse: { |
| 75 | + JournalEntry: [{ Id: '12', SyncToken: '1', Adjustment: false }], |
| 76 | + startPosition: 2, |
| 77 | + maxResults: 1, |
| 78 | + }, |
| 79 | + time: 'test-time', |
| 80 | + }), |
| 81 | + listParams |
| 82 | + ) |
| 83 | + ).resolves.toMatchObject({ |
| 84 | + output: { |
| 85 | + transactionType: 'journal_entry', |
| 86 | + items: [{ Id: '12', SyncToken: '1', Adjustment: false }], |
| 87 | + nextStartPosition: 3, |
| 88 | + hasMore: false, |
| 89 | + }, |
| 90 | + }) |
| 91 | + |
| 92 | + await expect( |
| 93 | + quickbooksReadAccountingTransactionsTool.transformResponse!( |
| 94 | + Response.json({ Transfer: { Id: 'A/B', SyncToken: '0', Amount: 25 } }), |
| 95 | + { ...listParams, transactionType: 'transfer', readMode: 'by_id', transactionId: 'A/B' } |
| 96 | + ) |
| 97 | + ).resolves.toMatchObject({ |
| 98 | + output: { transactionType: 'transfer', item: { Id: 'A/B', Amount: 25 } }, |
| 99 | + }) |
| 100 | + }) |
| 101 | + |
| 102 | + it('rejects unsupported types, modes, missing IDs, and malformed wrappers', async () => { |
| 103 | + const requestUrl = quickbooksReadAccountingTransactionsTool.request.url as ( |
| 104 | + params: QuickBooksReadAccountingTransactionsParams |
| 105 | + ) => string |
| 106 | + expect(() => requestUrl({ ...listParams, readMode: 'by_id' })).toThrow('transaction ID') |
| 107 | + expect(() => |
| 108 | + requestUrl({ |
| 109 | + ...listParams, |
| 110 | + transactionType: |
| 111 | + 'unsupported' as QuickBooksReadAccountingTransactionsParams['transactionType'], |
| 112 | + }) |
| 113 | + ).toThrow('transaction type') |
| 114 | + expect(() => |
| 115 | + requestUrl({ |
| 116 | + ...listParams, |
| 117 | + readMode: 'unsupported' as QuickBooksReadAccountingTransactionsParams['readMode'], |
| 118 | + }) |
| 119 | + ).toThrow('read mode') |
| 120 | + await expect( |
| 121 | + quickbooksReadAccountingTransactionsTool.transformResponse!( |
| 122 | + Response.json({ QueryResponse: { JournalEntry: [null] } }), |
| 123 | + listParams |
| 124 | + ) |
| 125 | + ).rejects.toThrow('malformed JournalEntry record') |
| 126 | + }) |
| 127 | +}) |
| 128 | + |
| 129 | +describe('QuickBooks accounting line validation', () => { |
| 130 | + it('builds balanced journal lines and optional entities', () => { |
| 131 | + const parsed = parseQuickBooksJournalLines([ |
| 132 | + { ...journalLines[0], entityType: 'customer', entityId: '42' }, |
| 133 | + journalLines[1], |
| 134 | + ]) |
| 135 | + expect(parsed?.[0]).toMatchObject({ entityType: 'customer', entityId: '42' }) |
| 136 | + expect( |
| 137 | + buildQuickBooksCreateJournalEntryBody({ |
| 138 | + ...authParams, |
| 139 | + lines: parsed!, |
| 140 | + confirmPosting: true, |
| 141 | + }) |
| 142 | + ).toMatchObject({ |
| 143 | + Line: [ |
| 144 | + { |
| 145 | + Amount: 100, |
| 146 | + DetailType: 'JournalEntryLineDetail', |
| 147 | + JournalEntryLineDetail: { |
| 148 | + PostingType: 'Debit', |
| 149 | + AccountRef: { value: '7' }, |
| 150 | + Entity: { Type: 'Customer', EntityRef: { value: '42' } }, |
| 151 | + }, |
| 152 | + }, |
| 153 | + { |
| 154 | + Amount: 100, |
| 155 | + JournalEntryLineDetail: { PostingType: 'Credit', AccountRef: { value: '35' } }, |
| 156 | + }, |
| 157 | + ], |
| 158 | + }) |
| 159 | + }) |
| 160 | + |
| 161 | + it('rejects unbalanced, malformed, unpaired-entity, and oversized journal lines', () => { |
| 162 | + expect(() => parseQuickBooksJournalLines([journalLines[0]])).toThrow('at least 2 lines') |
| 163 | + expect(() => |
| 164 | + parseQuickBooksJournalLines([{ ...journalLines[0] }, { ...journalLines[1], amount: 99 }]) |
| 165 | + ).toThrow('must balance') |
| 166 | + expect(() => |
| 167 | + parseQuickBooksJournalLines([{ ...journalLines[0], entityType: 'vendor' }, journalLines[1]]) |
| 168 | + ).toThrow('supplied together') |
| 169 | + expect(() => |
| 170 | + parseQuickBooksJournalLines([{ ...journalLines[0], raw: true }, journalLines[1]]) |
| 171 | + ).toThrow('unsupported field') |
| 172 | + expect(() => |
| 173 | + parseQuickBooksJournalLines( |
| 174 | + Array.from({ length: 102 }, (_, index) => ({ |
| 175 | + postingType: index % 2 === 0 ? 'debit' : 'credit', |
| 176 | + amount: 1, |
| 177 | + accountId: '7', |
| 178 | + })) |
| 179 | + ) |
| 180 | + ).toThrow('more than 100') |
| 181 | + }) |
| 182 | + |
| 183 | + it('builds bounded account-based deposit lines and rejects invalid input', () => { |
| 184 | + expect(parseQuickBooksDepositLines(JSON.stringify(depositLines))).toEqual(depositLines) |
| 185 | + const params: QuickBooksCreateDepositParams = { |
| 186 | + ...authParams, |
| 187 | + depositAccountId: '35', |
| 188 | + lines: depositLines, |
| 189 | + } |
| 190 | + expect(buildQuickBooksCreateDepositBody(params)).toEqual({ |
| 191 | + DepositToAccountRef: { value: '35' }, |
| 192 | + Line: [ |
| 193 | + { |
| 194 | + Amount: 100, |
| 195 | + Description: 'Sanitized source', |
| 196 | + DetailType: 'DepositLineDetail', |
| 197 | + DepositLineDetail: { AccountRef: { value: '7' } }, |
| 198 | + }, |
| 199 | + ], |
| 200 | + }) |
| 201 | + expect(() => parseQuickBooksDepositLines('[]')).toThrow('at least 1 line') |
| 202 | + expect(() => parseQuickBooksDepositLines('[{"amount":0,"accountId":"7"}]')).toThrow( |
| 203 | + 'positive finite' |
| 204 | + ) |
| 205 | + }) |
| 206 | +}) |
| 207 | + |
| 208 | +describe('QuickBooks accounting mutations', () => { |
| 209 | + it('requires journal posting confirmation and builds header-only sparse updates', () => { |
| 210 | + const create: QuickBooksCreateJournalEntryParams = { |
| 211 | + ...authParams, |
| 212 | + lines: journalLines, |
| 213 | + confirmPosting: false, |
| 214 | + } |
| 215 | + expect(() => buildQuickBooksCreateJournalEntryBody(create)).toThrow('Confirm posting') |
| 216 | + expect( |
| 217 | + buildQuickBooksUpdateJournalEntryBody({ |
| 218 | + ...authParams, |
| 219 | + journalEntryId: '12', |
| 220 | + syncToken: '1', |
| 221 | + confirmPosting: true, |
| 222 | + privateNote: 'Updated', |
| 223 | + }) |
| 224 | + ).toEqual({ Id: '12', SyncToken: '1', sparse: true, PrivateNote: 'Updated' }) |
| 225 | + expect(() => |
| 226 | + buildQuickBooksUpdateJournalEntryBody({ |
| 227 | + ...authParams, |
| 228 | + journalEntryId: '12', |
| 229 | + syncToken: '1', |
| 230 | + confirmPosting: true, |
| 231 | + }) |
| 232 | + ).toThrow('at least one field') |
| 233 | + }) |
| 234 | + |
| 235 | + it('builds header-only sparse deposit updates and rejects empty updates', () => { |
| 236 | + expect( |
| 237 | + buildQuickBooksUpdateDepositBody({ |
| 238 | + ...authParams, |
| 239 | + depositId: '13', |
| 240 | + syncToken: '2', |
| 241 | + transactionDate: '2026-08-01', |
| 242 | + }) |
| 243 | + ).toEqual({ Id: '13', SyncToken: '2', sparse: true, TxnDate: '2026-08-01' }) |
| 244 | + expect(() => |
| 245 | + buildQuickBooksUpdateDepositBody({ |
| 246 | + ...authParams, |
| 247 | + depositId: '13', |
| 248 | + syncToken: '2', |
| 249 | + }) |
| 250 | + ).toThrow('at least one field') |
| 251 | + }) |
| 252 | + |
| 253 | + it.each([ |
| 254 | + [quickbooksCreateJournalEntryTool, 'journalentry', 'JournalEntry'], |
| 255 | + [quickbooksCreateDepositTool, 'deposit', 'Deposit'], |
| 256 | + ] as const)('uses one fixed %s create endpoint and wrapper', async (tool, resource, wrapper) => { |
| 257 | + const url = new URL( |
| 258 | + (tool.request.url as (params: Record<string, unknown>) => string)({ |
| 259 | + ...authParams, |
| 260 | + requestId: 'request-1', |
| 261 | + }) |
| 262 | + ) |
| 263 | + expect(url.pathname).toBe(`/v3/company/123456789/${resource}`) |
| 264 | + expect(url.searchParams.get('requestid')).toBe('request-1') |
| 265 | + expect(tool.request.retry).toEqual({ enabled: false }) |
| 266 | + expect(tool.postProcess).toBeUndefined() |
| 267 | + await expect( |
| 268 | + tool.transformResponse!( |
| 269 | + Response.json({ [wrapper]: { Id: '12', SyncToken: '0' }, time: 'test-time' }) |
| 270 | + ) |
| 271 | + ).resolves.toMatchObject({ output: { recordId: '12', syncToken: '0' } }) |
| 272 | + }) |
| 273 | + |
| 274 | + it.each([ |
| 275 | + [quickbooksUpdateJournalEntryTool, 'journalentry'], |
| 276 | + [quickbooksUpdateDepositTool, 'deposit'], |
| 277 | + ] as const)('uses one fixed %s update endpoint', (tool, resource) => { |
| 278 | + expect( |
| 279 | + new URL((tool.request.url as (params: Record<string, unknown>) => string)(authParams)) |
| 280 | + .pathname |
| 281 | + ).toBe(`/v3/company/123456789/${resource}`) |
| 282 | + expect(tool.request.retry).toEqual({ enabled: false }) |
| 283 | + }) |
| 284 | +}) |
| 285 | + |
| 286 | +describe('QuickBooks accounting block', () => { |
| 287 | + it('parses accounting JSON and confirmation after dynamic references resolve', () => { |
| 288 | + expect( |
| 289 | + QuickBooksBlock.tools.config!.params!({ |
| 290 | + operation: 'quickbooks_create_journal_entry', |
| 291 | + oauthCredential: 'credential-id', |
| 292 | + journalLines: JSON.stringify(journalLines), |
| 293 | + confirmPosting: 'yes', |
| 294 | + }) |
| 295 | + ).toMatchObject({ credential: 'credential-id', lines: journalLines, confirmPosting: true }) |
| 296 | + expect( |
| 297 | + QuickBooksBlock.tools.config!.params!({ |
| 298 | + operation: 'quickbooks_create_deposit', |
| 299 | + oauthCredential: 'credential-id', |
| 300 | + depositAccountId: '35', |
| 301 | + depositLines: JSON.stringify(depositLines), |
| 302 | + }) |
| 303 | + ).toMatchObject({ |
| 304 | + credential: 'credential-id', |
| 305 | + depositAccountId: '35', |
| 306 | + lines: depositLines, |
| 307 | + }) |
| 308 | + }) |
| 309 | + |
| 310 | + it('exposes exactly 39 operations with tool/access parity', () => { |
| 311 | + const operation = QuickBooksBlock.subBlocks.find((subBlock) => subBlock.id === 'operation') |
| 312 | + const operationIds = (operation?.options ?? []).map((option) => option.id) |
| 313 | + expect(operationIds).toHaveLength(39) |
| 314 | + expect(new Set(operationIds).size).toBe(39) |
| 315 | + expect(operationIds).toEqual(QuickBooksBlock.tools.access) |
| 316 | + }) |
| 317 | + |
| 318 | + it('keeps accounting updates header-only and every subblock ID unique', () => { |
| 319 | + expect(new Set(QuickBooksBlock.subBlocks.map((subBlock) => subBlock.id)).size).toBe( |
| 320 | + QuickBooksBlock.subBlocks.length |
| 321 | + ) |
| 322 | + for (const operation of ['quickbooks_update_journal_entry', 'quickbooks_update_deposit']) { |
| 323 | + const mapped = QuickBooksBlock.tools.config!.params!({ |
| 324 | + operation, |
| 325 | + oauthCredential: 'credential-id', |
| 326 | + transactionId: '12', |
| 327 | + syncToken: '1', |
| 328 | + confirmPosting: 'yes', |
| 329 | + privateNote: 'Updated', |
| 330 | + journalLines: JSON.stringify(journalLines), |
| 331 | + depositLines: JSON.stringify(depositLines), |
| 332 | + }) as Record<string, unknown> |
| 333 | + expect(mapped.lines).toBeUndefined() |
| 334 | + } |
| 335 | + }) |
| 336 | +}) |
0 commit comments