Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
aa22bdd
fix(quickbooks): harden the app-level webhook ingress
waleedlatif1 Sep 6, 2026
5407a0c
fix(quickbooks): stop item full updates from corrupting inventory and…
waleedlatif1 Sep 6, 2026
dab93a3
fix(quickbooks): gate every internal tool operation and contract-bind…
waleedlatif1 Sep 6, 2026
d51a1d6
Merge branch 'fix/qb-webhook-hardening' into integration/quickbooks-d…
waleedlatif1 Sep 6, 2026
dbec57c
Merge branch 'fix/qb-internal-boundary' into integration/quickbooks-d…
waleedlatif1 Sep 6, 2026
9a91e4b
Merge branch 'fix/qb-core-master-data' into integration/quickbooks-do…
waleedlatif1 Sep 6, 2026
14bf9fc
fix(quickbooks): stop persisting the Intuit identity token and share …
waleedlatif1 Sep 6, 2026
3a36dd3
fix(quickbooks): stop replace-allocations detaching non-invoice payme…
waleedlatif1 Sep 6, 2026
89b6a60
Merge branch 'fix/qb-sales' into integration/quickbooks-doc-alignment
waleedlatif1 Sep 6, 2026
db03216
fix(quickbooks): align purchasing and accounting tools with Intuit's …
waleedlatif1 Sep 6, 2026
eb8fc44
fix(quickbooks): correct the sparse-void TSDoc and narrow receipt cus…
waleedlatif1 Sep 6, 2026
970c092
Merge branch 'fix/qb-purchasing' into integration/quickbooks-doc-alig…
waleedlatif1 Sep 6, 2026
e8cc16d
fix(quickbooks): align reports and attachments with Intuit's report c…
waleedlatif1 Sep 6, 2026
6e1c7d1
Merge branch 'fix/qb-reports-documents' into integration/quickbooks-d…
waleedlatif1 Sep 6, 2026
171c306
fix(quickbooks): document-align the bill payment account check and re…
waleedlatif1 Sep 6, 2026
5ee1614
fix(quickbooks): describe the refund receipt update as sparse
waleedlatif1 Sep 6, 2026
1524160
fix(quickbooks): wire the block and registry to the Wave 1-2 tool cha…
waleedlatif1 Sep 6, 2026
e9e6598
Merge branch 'fix/qb-block-wiring' into integration/quickbooks-doc-al…
waleedlatif1 Sep 6, 2026
da307ae
fix(quickbooks): split the dual-semantic transactionId and register t…
waleedlatif1 Sep 6, 2026
31728f6
fix(quickbooks): declare the bill payment and purchase order fields t…
waleedlatif1 Sep 6, 2026
9ed75c9
test(quickbooks): extend contract parity coverage to the file operations
waleedlatif1 Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 185 additions & 26 deletions apps/docs/content/docs/integrations/quickbooks.mdx

Large diffs are not rendered by default.

30 changes: 25 additions & 5 deletions apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vi.mock('@/lib/core/admission/gate', () => ({
tryAdmit: vi.fn(() => ({ release: mockRelease })),
}))
vi.mock('@/lib/webhooks/quickbooks-credentials', () => ({
getQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens,
streamQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens,
}))
vi.mock('@/lib/core/utils/with-route-handler', () => ({
withRouteHandler:
Expand Down Expand Up @@ -68,10 +68,16 @@ function callPost(webhookRequest: NextRequest, appKey = APP_KEY): Promise<Respon
return POST(webhookRequest, { params: Promise.resolve({ appKey }) })
}

function mockTokens(...tokens: string[]): void {
mockVerifierTokens.mockImplementation(async function* (): AsyncGenerator<string> {
yield* tokens
})
}

describe('QuickBooks webhook ingress route', () => {
beforeEach(() => {
vi.clearAllMocks()
mockVerifierTokens.mockResolvedValue(['verifier'])
mockTokens('verifier')
requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1')
mockEnqueue.mockResolvedValue('job-1')
})
Expand All @@ -97,19 +103,33 @@ describe('QuickBooks webhook ingress route', () => {
})

it('accepts any verifier token configured by a connection for the same Intuit app', async () => {
mockVerifierTokens.mockResolvedValue(['stale-verifier', 'current-verifier'])
mockTokens('stale-verifier', 'current-verifier')

expect((await callPost(signedRequest([validEvent], 'current-verifier'))).status).toBe(200)
})

it('fails closed for unknown app keys and missing signatures', async () => {
expect((await callPost(signedRequest([validEvent]), 'invalid')).status).toBe(404)
mockVerifierTokens.mockResolvedValueOnce([])
expect((await callPost(signedRequest([validEvent]))).status).toBe(404)
mockTokens()
expect((await callPost(signedRequest([validEvent]))).status).toBe(401)
mockTokens('verifier')
expect((await callPost(request(JSON.stringify([validEvent])))).status).toBe(401)
expect(mockEnqueue).not.toHaveBeenCalled()
})

it('acknowledges a batch that carries an unmodelled event instead of stalling the app queue', async () => {
const unmodelledEvent = { ...validEvent, id: 'event-2', type: undefined }
const response = await callPost(signedRequest([validEvent, unmodelledEvent]))

expect(response.status).toBe(200)
expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ events: [validEvent] }))
})

it('acknowledges a batch whose events are all unmodelled without enqueueing', async () => {
expect((await callPost(signedRequest([{ id: 'event-1' }]))).status).toBe(200)
expect(mockEnqueue).not.toHaveBeenCalled()
})

it('rejects malformed signed payloads and batches over the event bound', async () => {
expect((await callPost(signedRequest({ invalid: true }))).status).toBe(400)
const events = Array.from({ length: 1001 }, (_, index) => ({
Expand Down
46 changes: 30 additions & 16 deletions apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import {
quickBooksWebhookEventsSchema,
QUICKBOOKS_WEBHOOK_MAX_EVENTS,
type QuickBooksWebhookEvent,
quickBooksWebhookEventSchema,
quickBooksWebhookParamsSchema,
} from '@/lib/api/contracts/webhooks'
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
Expand All @@ -14,8 +16,8 @@ import {
} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
import { verifyQuickBooksSignatureAgainstVerifierTokens } from '@/lib/webhooks/providers/quickbooks'
import { getQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials'
import { verifyQuickBooksSignatureAgainstVerifierTokenStream } from '@/lib/webhooks/providers/quickbooks'
import { streamQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials'
import {
enqueueQuickBooksWebhookIngress,
type QuickBooksWebhookIngressPayload,
Expand Down Expand Up @@ -62,14 +64,10 @@ export const POST = withRouteHandler(
throw error
}

const verifierTokens = await getQuickBooksWebhookVerifierTokensByAppKey(appKey)
if (verifierTokens.length === 0) {
return NextResponse.json({ error: 'Webhook not found' }, { status: 404 })
}
const authError = verifyQuickBooksSignatureAgainstVerifierTokens(
const authError = await verifyQuickBooksSignatureAgainstVerifierTokenStream(
rawBody,
request.headers.get('intuit-signature'),
verifierTokens,
streamQuickBooksWebhookVerifierTokensByAppKey(appKey),
requestId
)
if (authError) return authError
Expand All @@ -80,17 +78,33 @@ export const POST = withRouteHandler(
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = quickBooksWebhookEventsSchema.safeParse(json)
if (!parsed.success) {
logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`, {
issues: parsed.error.issues,
})
if (
!Array.isArray(json) ||
json.length === 0 ||
json.length > QUICKBOOKS_WEBHOOK_MAX_EVENTS
) {
logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`)
return NextResponse.json({ error: 'Invalid webhook envelope' }, { status: 400 })
}

const events: QuickBooksWebhookEvent[] = []
let droppedCount = 0
for (const entry of json) {
const parsedEvent = quickBooksWebhookEventSchema.safeParse(entry)
if (parsedEvent.success) events.push(parsedEvent.data)
else droppedCount += 1
}
if (droppedCount > 0) {
logger.warn(`[${requestId}] Dropped unmodelled QuickBooks webhook events`, {
droppedCount,
eventCount: json.length,
})
}
if (events.length === 0) return NextResponse.json({ ok: true })

const payload: QuickBooksWebhookIngressPayload = {
appKey,
events: parsed.data,
events,
headers: {
'content-type': request.headers.get('content-type') ?? 'application/json',
},
Expand All @@ -99,7 +113,7 @@ export const POST = withRouteHandler(
}
const jobId = await enqueueQuickBooksWebhookIngress(payload)
logger.info(`[${requestId}] Accepted QuickBooks webhook delivery`, {
eventCount: parsed.data.length,
eventCount: events.length,
jobId,
})
return NextResponse.json({ ok: true })
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/background/quickbooks-webhook-ingress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ describe('QuickBooks webhook ingress job', () => {
expect(mockEnqueue).toHaveBeenCalledOnce()
})

it('ignores an event whose company identity can never be routed', async () => {
mockFindWebhooks.mockResolvedValue([])
const unroutablePayload: QuickBooksWebhookIngressPayload = {
...payload,
events: [{ ...event, intuitaccountid: 'not-a-realm' }, payload.events[1]],
}

await expect(executeQuickBooksWebhookIngress(unroutablePayload)).resolves.toEqual({
failed: 0,
ignored: 1,
processed: 0,
targetCount: 0,
})
expect(mockFindWebhooks).toHaveBeenCalledOnce()
expect(mockFindWebhooks).toHaveBeenCalledWith(
`${payload.appKey}:789`,
'request-1',
'quickbooks'
)
})

it('continues later events when targets cannot be resolved', async () => {
mockFindWebhooks
.mockRejectedValueOnce(new Error('database unavailable'))
Expand Down
15 changes: 14 additions & 1 deletion apps/sim/background/quickbooks-webhook-ingress.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { task } from '@trigger.dev/sdk'
import { NextRequest } from 'next/server'
import type { QuickBooksWebhookEvent } from '@/lib/api/contracts/webhooks'
Expand Down Expand Up @@ -37,6 +38,19 @@ export async function executeQuickBooksWebhookIngress(
let targetCount = 0

for (const [eventIndex, event] of payload.events.entries()) {
let routingKey: string
try {
routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid)
} catch (error) {
ignored += 1
logger.warn(`[${payload.requestId}] QuickBooks webhook event is not routable`, {
error: getErrorMessage(error, 'Unknown error'),
eventId: event.id,
eventIndex,
})
continue
}

const request = new NextRequest(
`http://internal/api/webhooks/quickbooks/${encodeURIComponent(payload.appKey)}`,
{
Expand All @@ -47,7 +61,6 @@ export async function executeQuickBooksWebhookIngress(
)

try {
const routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid)
const targets = await findWebhooksByRoutingKey(routingKey, payload.requestId, 'quickbooks')
targetCount += targets.length

Expand Down
Loading
Loading