Skip to content

Commit a415895

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(quickbooks): add accounting transaction tools
1 parent 7ea91de commit a415895

9 files changed

Lines changed: 1335 additions & 0 deletions
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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

Comments
 (0)