From 3f585944f66ae0c07ba9a674fb77c87a95f807b2 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 4 Aug 2026 00:00:27 -0700 Subject: [PATCH 1/5] feat(quickbooks): add core webhook triggers --- .../docs/en/integrations/quickbooks.mdx | 212 ++++++++++++++++++ apps/sim/.env.example | 1 + .../app/api/webhooks/quickbooks/route.test.ts | 100 +++++++++ apps/sim/app/api/webhooks/quickbooks/route.ts | 97 ++++++++ .../quickbooks-webhook-ingress.test.ts | 101 +++++++++ .../background/quickbooks-webhook-ingress.ts | 130 +++++++++++ .../quickbooks-webhook-targets.test.ts | 98 ++++++++ .../background/quickbooks-webhook-targets.ts | 112 +++++++++ apps/sim/blocks/blocks/quickbooks.ts | 21 ++ apps/sim/lib/api/contracts/webhooks.ts | 29 +++ .../core/async-jobs/backends/trigger-dev.ts | 1 + apps/sim/lib/core/async-jobs/types.ts | 1 + apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/integrations/integrations.json | 42 +++- .../lib/webhooks/providers/quickbooks.test.ts | 76 +++++++ apps/sim/lib/webhooks/providers/quickbooks.ts | 87 +++++++ apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/triggers/quickbooks/bill_events.ts | 19 ++ .../quickbooks/bill_payment_events.ts | 19 ++ .../triggers/quickbooks/customer_events.ts | 19 ++ apps/sim/triggers/quickbooks/index.ts | 7 + .../sim/triggers/quickbooks/invoice_events.ts | 31 +++ .../sim/triggers/quickbooks/payment_events.ts | 19 ++ .../quickbooks/purchase_order_events.ts | 19 ++ .../triggers/quickbooks/quickbooks.test.ts | 30 +++ apps/sim/triggers/quickbooks/utils.ts | 163 ++++++++++++++ apps/sim/triggers/quickbooks/vendor_events.ts | 19 ++ apps/sim/triggers/registry.ts | 16 ++ scripts/check-api-validation-contracts.ts | 4 +- 29 files changed, 1471 insertions(+), 5 deletions(-) create mode 100644 apps/sim/app/api/webhooks/quickbooks/route.test.ts create mode 100644 apps/sim/app/api/webhooks/quickbooks/route.ts create mode 100644 apps/sim/background/quickbooks-webhook-ingress.test.ts create mode 100644 apps/sim/background/quickbooks-webhook-ingress.ts create mode 100644 apps/sim/background/quickbooks-webhook-targets.test.ts create mode 100644 apps/sim/background/quickbooks-webhook-targets.ts create mode 100644 apps/sim/lib/webhooks/providers/quickbooks.test.ts create mode 100644 apps/sim/lib/webhooks/providers/quickbooks.ts create mode 100644 apps/sim/triggers/quickbooks/bill_events.ts create mode 100644 apps/sim/triggers/quickbooks/bill_payment_events.ts create mode 100644 apps/sim/triggers/quickbooks/customer_events.ts create mode 100644 apps/sim/triggers/quickbooks/index.ts create mode 100644 apps/sim/triggers/quickbooks/invoice_events.ts create mode 100644 apps/sim/triggers/quickbooks/payment_events.ts create mode 100644 apps/sim/triggers/quickbooks/purchase_order_events.ts create mode 100644 apps/sim/triggers/quickbooks/quickbooks.test.ts create mode 100644 apps/sim/triggers/quickbooks/utils.ts create mode 100644 apps/sim/triggers/quickbooks/vendor_events.ts diff --git a/apps/docs/content/docs/en/integrations/quickbooks.mdx b/apps/docs/content/docs/en/integrations/quickbooks.mdx index cf2222d54c4..f56e15d647d 100644 --- a/apps/docs/content/docs/en/integrations/quickbooks.mdx +++ b/apps/docs/content/docs/en/integrations/quickbooks.mdx @@ -2919,3 +2919,215 @@ Download a QuickBooks file attachment through its short-lived URL | `attachmentId` | string | Downloaded QuickBooks attachment ID | + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### QuickBooks Bill Events + +Trigger when selected Bill events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Bill Payment Events + +Trigger when selected Bill Payment events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Customer Events + +Trigger when selected Customer events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Invoice Events + +Trigger when selected Invoice events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Payment Events + +Trigger when selected Payment events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Purchase Order Events + +Trigger when selected Purchase Order events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + + +--- + +### QuickBooks Vendor Events + +Trigger when selected Vendor events occur in QuickBooks + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `triggerCredentials` | string | Yes | QuickBooks Account | +| `eventTypes` | string | Yes | Event Types | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `eventId` | string | Intuit webhook event ID | +| `eventType` | string | Full Intuit CloudEvent type | +| `entityType` | string | QuickBooks entity type | +| `action` | string | QuickBooks webhook action | +| `entityId` | string | QuickBooks entity ID | +| `realmId` | string | QuickBooks company realm ID | +| `eventTime` | string | Event timestamp | +| `specVersion` | string | CloudEvents specification version | +| `source` | string | Intuit event source | +| `contentType` | string | Event content type, when provided | +| `data` | json | Optional event data supplied by Intuit | + diff --git a/apps/sim/.env.example b/apps/sim/.env.example index fc275846d34..a31be15a207 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -125,6 +125,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # QUICKBOOKS_CLIENT_ID= # QUICKBOOKS_CLIENT_SECRET= # QUICKBOOKS_ENV=sandbox # Required when QuickBooks is configured: sandbox or production +# QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN= # Verifier token from the Intuit webhook configuration # Azure Blob Storage takes precedence over S3 if both are configured # AZURE_ACCOUNT_NAME= # Azure storage account name diff --git a/apps/sim/app/api/webhooks/quickbooks/route.test.ts b/apps/sim/app/api/webhooks/quickbooks/route.test.ts new file mode 100644 index 00000000000..9719f288acd --- /dev/null +++ b/apps/sim/app/api/webhooks/quickbooks/route.test.ts @@ -0,0 +1,100 @@ +/** @vitest-environment node */ + +import crypto from 'node:crypto' +import { requestUtilsMockFns, resetEnvMock, setEnv } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnqueue, mockRelease } = vi.hoisted(() => ({ + mockEnqueue: vi.fn(), + mockRelease: vi.fn(), +})) + +vi.mock('@/background/quickbooks-webhook-ingress', () => ({ + enqueueQuickBooksWebhookIngress: mockEnqueue, +})) +vi.mock('@/lib/core/admission/gate', () => ({ + admissionRejectedResponse: vi.fn(() => new Response(null, { status: 503 })), + tryAdmit: vi.fn(() => ({ release: mockRelease })), +})) +vi.mock('@/lib/core/utils/with-route-handler', () => ({ + withRouteHandler: + (handler: (request: NextRequest) => Promise) => (request: NextRequest) => + handler(request), +})) + +import { POST } from '@/app/api/webhooks/quickbooks/route' + +const validEvent = { + specversion: '1.0', + id: 'event-1', + source: 'quickbooks-online', + type: 'qbo.invoice.created.v1', + time: '2026-08-03T12:00:00Z', + intuitentityid: '123', + intuitaccountid: '456', +} + +function request(body: string, signature?: string): NextRequest { + return new NextRequest('http://localhost/api/webhooks/quickbooks', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(signature ? { 'intuit-signature': signature } : {}), + }, + body, + }) +} + +function signedRequest(value: unknown): NextRequest { + const body = JSON.stringify(value) + const signature = crypto.createHmac('sha256', 'verifier').update(body).digest('base64') + return request(body, signature) +} + +describe('QuickBooks webhook ingress route', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnv({ QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN: 'verifier' }) + requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') + mockEnqueue.mockResolvedValue('job-1') + }) + afterAll(() => { + resetEnvMock() + requestUtilsMockFns.mockGenerateRequestId.mockReset() + }) + + it('accepts a signed multi-company batch only after durable enqueue', async () => { + const response = await POST( + signedRequest([validEvent, { ...validEvent, id: 'event-2', intuitaccountid: '789' }]) + ) + expect(response.status).toBe(200) + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + events: [validEvent, { ...validEvent, id: 'event-2', intuitaccountid: '789' }], + requestId: 'request-1', + }) + ) + expect(mockRelease).toHaveBeenCalledOnce() + }) + + it('rejects missing signatures and malformed signed payloads before enqueue', async () => { + expect((await POST(request(JSON.stringify([validEvent])))).status).toBe(401) + expect((await POST(signedRequest({ invalid: true }))).status).toBe(400) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + + it('rejects batches over the 1,000 event bound', async () => { + const events = Array.from({ length: 1001 }, (_, index) => ({ + ...validEvent, + id: `event-${index}`, + })) + expect((await POST(signedRequest(events))).status).toBe(400) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + + it('returns 503 when durable acceptance fails', async () => { + mockEnqueue.mockRejectedValue(new Error('queue unavailable')) + expect((await POST(signedRequest([validEvent]))).status).toBe(503) + }) +}) diff --git a/apps/sim/app/api/webhooks/quickbooks/route.ts b/apps/sim/app/api/webhooks/quickbooks/route.ts new file mode 100644 index 00000000000..fd79e800d46 --- /dev/null +++ b/apps/sim/app/api/webhooks/quickbooks/route.ts @@ -0,0 +1,97 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { quickBooksWebhookEventsSchema } from '@/lib/api/contracts/webhooks' +import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' +import { generateRequestId } from '@/lib/core/utils/request' +import { + assertContentLengthWithinLimit, + isPayloadSizeLimitError, + readStreamToBufferWithLimit, +} 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 { verifyQuickBooksSignature } from '@/lib/webhooks/providers/quickbooks' +import { + enqueueQuickBooksWebhookIngress, + type QuickBooksWebhookIngressPayload, +} from '@/background/quickbooks-webhook-ingress' + +const logger = createLogger('QuickBooksWebhookIngress') +const BODY_LABEL = 'QuickBooks webhook body' + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' +export const maxDuration = 60 + +async function readBody(request: Request): Promise { + assertContentLengthWithinLimit(request.headers, WEBHOOK_MAX_BODY_BYTES, BODY_LABEL) + const buffer = await readStreamToBufferWithLimit(request.body, { + maxBytes: WEBHOOK_MAX_BODY_BYTES, + label: BODY_LABEL, + }) + return new TextDecoder().decode(buffer) +} + +/** App-level Intuit callback. Verifies raw bytes and durably accepts before fanout. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + const ticket = tryAdmit() + if (!ticket) return admissionRejectedResponse() + + const requestId = generateRequestId() + const receivedAt = Date.now() + try { + let rawBody: string + try { + rawBody = await readBody(request) + } catch (error) { + if (isPayloadSizeLimitError(error)) { + return NextResponse.json({ error: 'Request body too large' }, { status: 413 }) + } + throw error + } + + const authError = verifyQuickBooksSignature( + rawBody, + request.headers.get('intuit-signature'), + requestId + ) + if (authError) return authError + + let json: unknown + try { + json = JSON.parse(rawBody) + } 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, + }) + return NextResponse.json({ error: 'Invalid webhook envelope' }, { status: 400 }) + } + + const payload: QuickBooksWebhookIngressPayload = { + events: parsed.data, + headers: { + 'content-type': request.headers.get('content-type') ?? 'application/json', + }, + requestId, + receivedAt, + } + const jobId = await enqueueQuickBooksWebhookIngress(payload) + logger.info(`[${requestId}] Accepted QuickBooks webhook delivery`, { + eventCount: parsed.data.length, + jobId, + }) + return NextResponse.json({ ok: true }) + } catch (error) { + logger.error(`[${requestId}] QuickBooks webhook ingress error`, { + error: getErrorMessage(error, 'Unknown error'), + }) + return NextResponse.json({ error: 'Temporarily unable to accept webhook' }, { status: 503 }) + } finally { + ticket.release() + } +}) diff --git a/apps/sim/background/quickbooks-webhook-ingress.test.ts b/apps/sim/background/quickbooks-webhook-ingress.test.ts new file mode 100644 index 00000000000..1127bbb2d66 --- /dev/null +++ b/apps/sim/background/quickbooks-webhook-ingress.test.ts @@ -0,0 +1,101 @@ +/** @vitest-environment node */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockDispatch, mockEnqueue, mockFindPage } = vi.hoisted(() => ({ + mockDispatch: vi.fn(), + mockEnqueue: vi.fn(), + mockFindPage: vi.fn(), +})) +vi.mock('@trigger.dev/sdk', () => ({ task: vi.fn((config: unknown) => config) })) +vi.mock('@/lib/webhooks/processor', () => ({ dispatchResolvedWebhookTarget: mockDispatch })) +vi.mock('@/background/quickbooks-webhook-targets', () => ({ + findQuickBooksWebhookTargetPage: mockFindPage, +})) +vi.mock('@/lib/core/async-jobs', () => ({ + getJobQueue: vi.fn(async () => ({ enqueue: mockEnqueue })), +})) + +import { + enqueueQuickBooksWebhookIngress, + executeQuickBooksWebhookIngress, + type QuickBooksWebhookIngressPayload, +} from '@/background/quickbooks-webhook-ingress' + +const event = { + specversion: '1.0', + id: 'event-1', + source: 'quickbooks-online', + type: 'qbo.invoice.created.v1', + time: '2026-08-03T12:00:00Z', + intuitentityid: '123', + intuitaccountid: '456', +} +const payload: QuickBooksWebhookIngressPayload = { + events: [event, { ...event, id: 'event-2', intuitaccountid: '789' }], + headers: { 'content-type': 'application/json' }, + requestId: 'request-1', + receivedAt: 1, +} + +describe('QuickBooks webhook ingress job', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnqueue.mockResolvedValue('job-1') + }) + + it('routes one event to one company and dispatches targets sequentially', async () => { + const order: string[] = [] + mockFindPage.mockResolvedValue({ + hasMore: false, + nextCursor: null, + targets: [ + { webhook: { id: 'w1' }, workflow: { id: 'wf1' } }, + { webhook: { id: 'w2' }, workflow: { id: 'wf2' } }, + ], + }) + mockDispatch.mockImplementation(async (webhook: { id: string }) => { + order.push(webhook.id) + return { outcome: 'queued' } + }) + await expect(executeQuickBooksWebhookIngress(payload)).resolves.toEqual({ + ignored: 0, + processed: 2, + targetCount: 2, + }) + expect(mockFindPage).toHaveBeenCalledWith('456', 'request-1', undefined) + expect(order).toEqual(['w1', 'w2']) + }) + + it('continues target pages before advancing to the next event', async () => { + mockFindPage.mockResolvedValue({ + hasMore: true, + nextCursor: 'webhook-100', + targets: [], + }) + await enqueueQuickBooksWebhookIngress(payload) + const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } + await options.runner() + expect(mockEnqueue).toHaveBeenNthCalledWith( + 2, + 'quickbooks-webhook-ingress', + expect.objectContaining({ afterWebhookId: 'webhook-100' }), + expect.objectContaining({ + jobId: 'quickbooks-webhook-ingress:request-1:0:webhook-100', + }) + ) + }) + + it('advances to the next event only after the current event finishes', async () => { + mockFindPage.mockResolvedValue({ hasMore: false, nextCursor: null, targets: [] }) + await enqueueQuickBooksWebhookIngress(payload) + const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } + await options.runner() + expect(mockEnqueue).toHaveBeenNthCalledWith( + 2, + 'quickbooks-webhook-ingress', + expect.objectContaining({ eventIndex: 1, afterWebhookId: undefined }), + expect.objectContaining({ jobId: 'quickbooks-webhook-ingress:request-1:1:root' }) + ) + }) +}) diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts new file mode 100644 index 00000000000..5c0ac6e8a98 --- /dev/null +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { task } from '@trigger.dev/sdk' +import { NextRequest } from 'next/server' +import type { QuickBooksWebhookEvent } from '@/lib/api/contracts/webhooks' +import { getJobQueue } from '@/lib/core/async-jobs' +import { dispatchResolvedWebhookTarget } from '@/lib/webhooks/processor' +import { findQuickBooksWebhookTargetPage } from '@/background/quickbooks-webhook-targets' + +const logger = createLogger('QuickBooksWebhookIngressTask') + +export const QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT = 50 +export const QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS = 3 + +export interface QuickBooksWebhookIngressPayload { + afterWebhookId?: string + eventIndex?: number + events: QuickBooksWebhookEvent[] + headers: { 'content-type': string } + requestId: string + receivedAt: number +} + +export interface QuickBooksWebhookIngressResult { + ignored: number + nextCursor?: string + processed: number + targetCount: number +} + +/** Process one event against one bounded target page. */ +export async function executeQuickBooksWebhookIngress( + payload: QuickBooksWebhookIngressPayload +): Promise { + const eventIndex = payload.eventIndex ?? 0 + const event = payload.events[eventIndex] + if (!event) return { ignored: 0, processed: 0, targetCount: 0 } + + const request = new NextRequest('http://internal/api/webhooks/quickbooks', { + method: 'POST', + headers: payload.headers, + body: JSON.stringify(event), + }) + const page = await findQuickBooksWebhookTargetPage( + event.intuitaccountid, + payload.requestId, + payload.afterWebhookId + ) + const nextCursor = page.hasMore ? page.nextCursor : null + if (page.hasMore && (!nextCursor || nextCursor === payload.afterWebhookId)) { + throw new Error('QuickBooks webhook target pagination did not advance') + } + + let ignored = 0 + let processed = 0 + let failed = 0 + for (const { webhook, workflow } of page.targets) { + const result = await dispatchResolvedWebhookTarget(webhook, workflow, event, request, { + requestId: payload.requestId, + path: webhook.path ?? undefined, + receivedAt: payload.receivedAt, + triggerTimestampMs: Date.parse(event.time), + }) + if (result.outcome === 'queued') processed += 1 + else if (result.outcome === 'ignored') ignored += 1 + else failed += 1 + } + + if (failed > 0) { + throw new Error(`Failed to dispatch ${failed} of ${page.targets.length} QuickBooks targets`) + } + + logger.info(`[${payload.requestId}] QuickBooks webhook page completed`, { + eventId: event.id, + eventIndex, + ignored, + processed, + targetCount: page.targets.length, + }) + return { + ignored, + processed, + targetCount: page.targets.length, + ...(nextCursor ? { nextCursor } : {}), + } +} + +async function runQuickBooksWebhookIngressJob( + payload: QuickBooksWebhookIngressPayload +): Promise { + const eventIndex = payload.eventIndex ?? 0 + const result = await executeQuickBooksWebhookIngress(payload) + if (result.nextCursor) { + await enqueueQuickBooksWebhookIngress({ ...payload, afterWebhookId: result.nextCursor }) + return + } + if (eventIndex + 1 < payload.events.length) { + await enqueueQuickBooksWebhookIngress({ + ...payload, + eventIndex: eventIndex + 1, + afterWebhookId: undefined, + }) + } +} + +export async function enqueueQuickBooksWebhookIngress( + payload: QuickBooksWebhookIngressPayload +): Promise { + const jobQueue = await getJobQueue() + const eventIndex = payload.eventIndex ?? 0 + return jobQueue.enqueue('quickbooks-webhook-ingress', payload, { + jobId: `quickbooks-webhook-ingress:${payload.requestId}:${eventIndex}:${payload.afterWebhookId ?? 'root'}`, + maxAttempts: QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS, + concurrencyKey: 'quickbooks-webhook-ingress', + concurrencyLimit: QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT, + runner: async () => runQuickBooksWebhookIngressJob(payload), + }) +} + +export const quickBooksWebhookIngressTask = task({ + id: 'quickbooks-webhook-ingress', + machine: 'small-1x', + retry: { + maxAttempts: QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS, + factor: 2, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10_000, + }, + queue: { concurrencyLimit: QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT }, + run: async (payload: QuickBooksWebhookIngressPayload) => runQuickBooksWebhookIngressJob(payload), +}) diff --git a/apps/sim/background/quickbooks-webhook-targets.test.ts b/apps/sim/background/quickbooks-webhook-targets.test.ts new file mode 100644 index 00000000000..af0e24c64c4 --- /dev/null +++ b/apps/sim/background/quickbooks-webhook-targets.test.ts @@ -0,0 +1,98 @@ +/** @vitest-environment node */ + +import { dbChainMock, dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCredentialExpression, mockEq, mockGt, tables } = vi.hoisted(() => ({ + mockCredentialExpression: vi.fn(() => 'webhook.credentialId'), + mockEq: vi.fn((left: unknown, right: unknown) => ({ left, right })), + mockGt: vi.fn((left: unknown, right: unknown) => ({ gt: [left, right] })), + tables: { + account: { id: 'account.id', accountId: 'account.accountId', providerId: 'account.providerId' }, + credential: { + id: 'credential.id', + accountId: 'credential.accountId', + providerId: 'credential.providerId', + type: 'credential.type', + workspaceId: 'credential.workspaceId', + }, + webhook: { + deploymentVersionId: 'webhook.deploymentVersionId', + isActive: 'webhook.isActive', + id: 'webhook.id', + archivedAt: 'webhook.archivedAt', + provider: 'webhook.provider', + providerConfig: 'webhook.providerConfig', + workflowId: 'webhook.workflowId', + }, + workflow: { + id: 'workflow.id', + workspaceId: 'workflow.workspaceId', + archivedAt: 'workflow.archivedAt', + }, + workflowDeploymentVersion: { + id: 'workflowDeploymentVersion.id', + workflowId: 'workflowDeploymentVersion.workflowId', + isActive: 'workflowDeploymentVersion.isActive', + }, + }, +})) + +vi.mock('@sim/db', () => ({ + ...dbChainMock, + ...tables, + webhookCredentialIdExpression: mockCredentialExpression, +})) +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...conditions: unknown[]) => conditions), + asc: vi.fn((value: unknown) => ({ asc: value })), + eq: mockEq, + gt: mockGt, + isNull: vi.fn((value: unknown) => ({ isNull: value })), + like: vi.fn((left: unknown, right: unknown) => ({ left, right })), + or: vi.fn((...conditions: unknown[]) => conditions), +})) + +import { + findQuickBooksWebhookTargetPage, + QUICKBOOKS_WEBHOOK_TARGET_PAGE_SIZE, +} from '@/background/quickbooks-webhook-targets' + +const UUID = '11111111-2222-4333-8444-555555555555' + +describe('findQuickBooksWebhookTargetPage', () => { + afterAll(resetDbChainMock) + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns only the exact company identity and enforces provider/workspace bindings', async () => { + queueTableRows(tables.account, [ + { + accountId: `quickbooks:456:user-${UUID}`, + webhookId: 'webhook-1', + webhook: { id: 'webhook-1' }, + workflow: { id: 'workflow-1' }, + }, + { + accountId: `quickbooks:789:user-${UUID}`, + webhookId: 'webhook-2', + webhook: { id: 'webhook-2' }, + workflow: { id: 'workflow-2' }, + }, + ]) + + const page = await findQuickBooksWebhookTargetPage('456', 'request-1') + expect(page.targets).toEqual([{ webhook: { id: 'webhook-1' }, workflow: { id: 'workflow-1' } }]) + expect(mockEq).toHaveBeenCalledWith('credential.providerId', 'quickbooks') + expect(mockEq).toHaveBeenCalledWith('webhook.provider', 'quickbooks') + expect(mockEq).toHaveBeenCalledWith('workflow.workspaceId', 'credential.workspaceId') + }) + + it('uses a fixed 100-target keyset page', async () => { + await findQuickBooksWebhookTargetPage('456', 'request-2', 'webhook-100') + expect(mockGt).toHaveBeenCalledWith('webhook.id', 'webhook-100') + expect(dbChainMockFns.limit).toHaveBeenCalledWith(QUICKBOOKS_WEBHOOK_TARGET_PAGE_SIZE) + }) +}) diff --git a/apps/sim/background/quickbooks-webhook-targets.ts b/apps/sim/background/quickbooks-webhook-targets.ts new file mode 100644 index 00000000000..646ef8f3862 --- /dev/null +++ b/apps/sim/background/quickbooks-webhook-targets.ts @@ -0,0 +1,112 @@ +import { + account, + credential, + db, + webhook, + webhookCredentialIdExpression, + workflow, + workflowDeploymentVersion, +} from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, asc, eq, gt, isNull, like, or } from 'drizzle-orm' +import { parseQuickBooksAccountId } from '@/lib/oauth/quickbooks' +import { deliverableWebhookPredicate } from '@/lib/webhooks/delivery-predicate' + +const logger = createLogger('QuickBooksWebhookTargets') +const ACCOUNT_ID_UUID_LIKE_SUFFIX = '________-____-____-____-____________' + +export const QUICKBOOKS_WEBHOOK_TARGET_PAGE_SIZE = 100 + +export interface QuickBooksWebhookTargetPage { + hasMore: boolean + nextCursor: string | null + targets: Array<{ + webhook: typeof webhook.$inferSelect + workflow: typeof workflow.$inferSelect + }> +} + +function escapeLikePattern(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +} + +/** Resolve one bounded page of active trigger targets for exactly one QuickBooks company. */ +export async function findQuickBooksWebhookTargetPage( + realmId: string, + requestId: string, + afterWebhookId?: string +): Promise { + if (!realmId) return { hasMore: false, nextCursor: null, targets: [] } + + const rows = await db + .select({ accountId: account.accountId, webhookId: webhook.id, webhook, workflow }) + .from(account) + .innerJoin( + credential, + and( + eq(credential.accountId, account.id), + eq(credential.type, 'oauth'), + eq(credential.providerId, 'quickbooks') + ) + ) + .innerJoin( + webhook, + and( + eq(webhookCredentialIdExpression(webhook.providerConfig), credential.id), + eq(webhook.provider, 'quickbooks'), + deliverableWebhookPredicate(webhook) + ) + ) + .innerJoin( + workflow, + and( + eq(workflow.id, webhook.workflowId), + eq(workflow.workspaceId, credential.workspaceId), + isNull(workflow.archivedAt) + ) + ) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflow.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(account.providerId, 'quickbooks'), + like( + account.accountId, + `quickbooks:${escapeLikePattern(realmId)}:%-${ACCOUNT_ID_UUID_LIKE_SUFFIX}` + ), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ), + afterWebhookId ? gt(webhook.id, afterWebhookId) : undefined + ) + ) + .orderBy(asc(webhook.id)) + .limit(QUICKBOOKS_WEBHOOK_TARGET_PAGE_SIZE) + + const targets = rows + .filter((row) => { + try { + return parseQuickBooksAccountId(row.accountId).realmId === realmId + } catch { + return false + } + }) + .map(({ webhook: webhookRecord, workflow: workflowRecord }) => ({ + webhook: webhookRecord, + workflow: workflowRecord, + })) + const nextCursor = rows.at(-1)?.webhookId ?? null + const hasMore = rows.length === QUICKBOOKS_WEBHOOK_TARGET_PAGE_SIZE + + logger.info(`[${requestId}] Resolved QuickBooks webhook target page`, { + hasMore, + targetCount: targets.length, + }) + return { hasMore, nextCursor, targets } +} diff --git a/apps/sim/blocks/blocks/quickbooks.ts b/apps/sim/blocks/blocks/quickbooks.ts index 9f60068370c..af89c89bc63 100644 --- a/apps/sim/blocks/blocks/quickbooks.ts +++ b/apps/sim/blocks/blocks/quickbooks.ts @@ -26,6 +26,7 @@ import { } from '@/tools/quickbooks/sales_utils' import type { QuickBooksReportType, QuickBooksResponse } from '@/tools/quickbooks/types' import { parseQuickBooksAddress } from '@/tools/quickbooks/values' +import { getTrigger } from '@/triggers' const MASTER_DATA_OPERATION = 'quickbooks_read_master_data' const SALES_READ_OPERATION = 'quickbooks_read_sales_transactions' @@ -386,6 +387,7 @@ export const QuickBooksBlock: BlockConfig = { integrationType: IntegrationType.Commerce, bgColor: '#2CA01C', icon: QuickBooksIcon, + triggerAllowed: true, subBlocks: [ { id: 'operation', @@ -1965,7 +1967,26 @@ export const QuickBooksBlock: BlockConfig = { }, value: () => 'no', }, + ...getTrigger('quickbooks_invoice_events').subBlocks, + ...getTrigger('quickbooks_customer_events').subBlocks, + ...getTrigger('quickbooks_payment_events').subBlocks, + ...getTrigger('quickbooks_vendor_events').subBlocks, + ...getTrigger('quickbooks_bill_events').subBlocks, + ...getTrigger('quickbooks_bill_payment_events').subBlocks, + ...getTrigger('quickbooks_purchase_order_events').subBlocks, ], + triggers: { + enabled: true, + available: [ + 'quickbooks_customer_events', + 'quickbooks_invoice_events', + 'quickbooks_payment_events', + 'quickbooks_vendor_events', + 'quickbooks_bill_events', + 'quickbooks_bill_payment_events', + 'quickbooks_purchase_order_events', + ], + }, tools: { access: [ 'quickbooks_get_company_info', diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index 04c2d3ad5ba..85f3a53372d 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -293,3 +293,32 @@ export const tiktokWebhookContract = defineRouteContract({ schema: tiktokWebhookResponseSchema, }, }) + +/** Intuit's app-level QuickBooks CloudEvent envelope. */ +export const quickBooksWebhookEventSchema = z.object({ + specversion: z.string().min(1).max(32), + id: z.string().min(1).max(255), + source: z.string().min(1).max(2048), + type: z.string().min(1).max(255), + datacontenttype: z.string().min(1).max(255).optional(), + time: z.string().datetime({ offset: true }), + intuitentityid: z.string().min(1).max(255), + intuitaccountid: z.string().min(1).max(255), + data: z.unknown().optional(), +}) + +export const quickBooksWebhookEventsSchema = z.array(quickBooksWebhookEventSchema).min(1).max(1000) + +export type QuickBooksWebhookEvent = z.input + +export const quickBooksWebhookContract = defineRouteContract({ + method: 'POST', + path: '/api/webhooks/quickbooks', + headers: z.object({ 'intuit-signature': z.string().min(1) }), + // Body is validated after HMAC verification against the raw payload. + body: quickBooksWebhookEventsSchema, + response: { + mode: 'json', + schema: z.union([z.object({ ok: z.literal(true) }), z.object({ error: z.string().min(1) })]), + }, +}) diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 12f9f15bc88..363ca9d2c1a 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -39,6 +39,7 @@ const JOB_TYPE_TO_TASK_ID: Record = { 'workflow-execution': 'workflow-execution', 'schedule-execution': 'schedule-execution', 'webhook-execution': 'webhook-execution', + 'quickbooks-webhook-ingress': 'quickbooks-webhook-ingress', 'tiktok-webhook-ingress': 'tiktok-webhook-ingress', 'resume-execution': 'resume-execution', 'workflow-group-cell': 'workflow-group-cell', diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 9a1ee04aefa..d38ef28ecc3 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -24,6 +24,7 @@ export type JobType = | 'workflow-execution' | 'schedule-execution' | 'webhook-execution' + | 'quickbooks-webhook-ingress' | 'tiktok-webhook-ingress' | 'resume-execution' | 'workflow-group-cell' diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 90a974f51fa..ba3968c185e 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -420,6 +420,7 @@ export const env = createEnv({ QUICKBOOKS_CLIENT_ID: z.string().optional(), // QuickBooks Online OAuth client ID QUICKBOOKS_CLIENT_SECRET: z.string().optional(), // QuickBooks Online OAuth client secret QUICKBOOKS_ENV: z.enum(['sandbox', 'production']).optional(), // QuickBooks Online API environment (must be configured explicitly) + QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN: z.string().optional(), // Intuit app-level webhook verifier token LINEAR_CLIENT_ID: z.string().optional(), // Linear OAuth client ID LINEAR_CLIENT_SECRET: z.string().optional(), // Linear OAuth client secret CLICKUP_CLIENT_ID: z.string().optional(), // ClickUp OAuth client ID diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index ea5064ee1b3..7e3500901ad 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -1,5 +1,5 @@ { - "updatedAt": "2026-08-03", + "updatedAt": "2026-08-04", "integrations": [ { "type": "onepassword", @@ -14760,8 +14760,44 @@ } ], "operationCount": 47, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "quickbooks_customer_events", + "name": "QuickBooks Customer Events", + "description": "Trigger when selected Customer events occur in QuickBooks" + }, + { + "id": "quickbooks_invoice_events", + "name": "QuickBooks Invoice Events", + "description": "Trigger when selected Invoice events occur in QuickBooks" + }, + { + "id": "quickbooks_payment_events", + "name": "QuickBooks Payment Events", + "description": "Trigger when selected Payment events occur in QuickBooks" + }, + { + "id": "quickbooks_vendor_events", + "name": "QuickBooks Vendor Events", + "description": "Trigger when selected Vendor events occur in QuickBooks" + }, + { + "id": "quickbooks_bill_events", + "name": "QuickBooks Bill Events", + "description": "Trigger when selected Bill events occur in QuickBooks" + }, + { + "id": "quickbooks_bill_payment_events", + "name": "QuickBooks Bill Payment Events", + "description": "Trigger when selected Bill Payment events occur in QuickBooks" + }, + { + "id": "quickbooks_purchase_order_events", + "name": "QuickBooks Purchase Order Events", + "description": "Trigger when selected Purchase Order events occur in QuickBooks" + } + ], + "triggerCount": 7, "authType": "oauth", "oauthServiceId": "quickbooks", "category": "tools", diff --git a/apps/sim/lib/webhooks/providers/quickbooks.test.ts b/apps/sim/lib/webhooks/providers/quickbooks.test.ts new file mode 100644 index 00000000000..76b22c34329 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/quickbooks.test.ts @@ -0,0 +1,76 @@ +import crypto from 'node:crypto' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { quickBooksHandler, verifyQuickBooksSignature } from '@/lib/webhooks/providers/quickbooks' +import { isQuickBooksEventMatch } from '@/triggers/quickbooks/utils' + +const event = { + specversion: '1.0', + id: 'event-1', + source: 'quickbooks-online', + type: 'qbo.invoice.updated.v1', + datacontenttype: 'application/json', + time: '2026-08-03T12:00:00Z', + intuitentityid: '123', + intuitaccountid: '456', + data: { changedFields: ['Balance'] }, +} + +describe('QuickBooks webhook provider', () => { + it('verifies the documented base64 HMAC over the raw body', () => { + const body = JSON.stringify([event]) + const signature = crypto.createHmac('sha256', 'verifier').update(body).digest('base64') + expect(verifyQuickBooksSignature(body, signature, 'request-1', 'verifier')).toBeNull() + expect(verifyQuickBooksSignature(body, 'invalid', 'request-2', 'verifier')?.status).toBe(401) + expect(verifyQuickBooksSignature(body, null, 'request-3', 'verifier')?.status).toBe(401) + }) + + it('matches only configured actions for the selected entity', () => { + expect( + isQuickBooksEventMatch('quickbooks_invoice_events', event.type, ['created', 'updated']) + ).toBe(true) + expect(isQuickBooksEventMatch('quickbooks_invoice_events', event.type, ['created'])).toBe(false) + expect(isQuickBooksEventMatch('quickbooks_bill_events', event.type, ['updated'])).toBe(false) + }) + + it('formats only the common verified event fields', async () => { + const result = await quickBooksHandler.formatInput!({ + body: event, + webhook: {}, + workflow: { id: 'workflow-1', userId: 'user-1' }, + headers: {}, + requestId: 'request-4', + }) + expect(result.input).toEqual({ + eventId: 'event-1', + eventType: 'qbo.invoice.updated.v1', + entityType: 'invoice', + action: 'updated', + entityId: '123', + realmId: '456', + eventTime: '2026-08-03T12:00:00Z', + specVersion: '1.0', + source: 'quickbooks-online', + contentType: 'application/json', + data: { changedFields: ['Balance'] }, + }) + expect(quickBooksHandler.extractIdempotencyId!(event)).toBe('event-1') + }) + + it('uses the provider-local ingress and durable queue modes', async () => { + expect(quickBooksHandler.ingressMode).toBe('provider') + expect(quickBooksHandler.executionMode).toBe('queue') + const matched = await quickBooksHandler.matchEvent!({ + body: event, + request: new NextRequest('http://localhost'), + requestId: 'request-5', + providerConfig: { + triggerId: 'quickbooks_invoice_events', + eventTypes: ['updated'], + }, + webhook: {}, + workflow: {}, + }) + expect(matched).toBe(true) + }) +}) diff --git a/apps/sim/lib/webhooks/providers/quickbooks.ts b/apps/sim/lib/webhooks/providers/quickbooks.ts new file mode 100644 index 00000000000..9b57f5d0303 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/quickbooks.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Base64 } from '@sim/security/hmac' +import { NextResponse } from 'next/server' +import { env } from '@/lib/core/config/env' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:QuickBooks') + +export function verifyQuickBooksSignature( + rawBody: string, + signature: string | null, + requestId: string, + verifierToken: string | undefined = env.QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN +): NextResponse | null { + if (!verifierToken) { + logger.warn(`[${requestId}] QuickBooks webhook verifier token is not configured`) + return new NextResponse('Unauthorized', { status: 401 }) + } + if (!signature) { + logger.warn(`[${requestId}] QuickBooks webhook is missing intuit-signature`) + return new NextResponse('Unauthorized', { status: 401 }) + } + + const expected = hmacSha256Base64(rawBody, verifierToken) + if (!safeCompare(expected, signature.trim())) { + logger.warn(`[${requestId}] QuickBooks webhook signature verification failed`) + return new NextResponse('Unauthorized', { status: 401 }) + } + return null +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + return value as Record +} + +export const quickBooksHandler: WebhookProviderHandler = { + ingressMode: 'provider', + executionMode: 'queue', + + verifyAuth({ request, rawBody, requestId }: AuthContext) { + return verifyQuickBooksSignature(rawBody, request.headers.get('intuit-signature'), requestId) + }, + + async matchEvent({ body, providerConfig }: EventMatchContext) { + const event = asRecord(body) + const triggerId = typeof providerConfig.triggerId === 'string' ? providerConfig.triggerId : '' + const eventType = typeof event?.type === 'string' ? event.type : '' + const { isQuickBooksEventMatch } = await import('@/triggers/quickbooks/utils') + return isQuickBooksEventMatch(triggerId, eventType, providerConfig.eventTypes) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const event = asRecord(body) ?? {} + const eventType = typeof event.type === 'string' ? event.type : '' + const { parseQuickBooksWebhookType } = await import('@/triggers/quickbooks/utils') + const parsed = parseQuickBooksWebhookType(eventType) + + return { + input: { + eventId: typeof event.id === 'string' ? event.id : '', + eventType, + entityType: parsed?.entity ?? '', + action: parsed?.action ?? '', + entityId: typeof event.intuitentityid === 'string' ? event.intuitentityid : '', + realmId: typeof event.intuitaccountid === 'string' ? event.intuitaccountid : '', + eventTime: typeof event.time === 'string' ? event.time : '', + specVersion: typeof event.specversion === 'string' ? event.specversion : '', + source: typeof event.source === 'string' ? event.source : '', + contentType: typeof event.datacontenttype === 'string' ? event.datacontenttype : null, + data: event.data ?? null, + }, + } + }, + + extractIdempotencyId(body: unknown) { + const event = asRecord(body) + return typeof event?.id === 'string' ? event.id : null + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 085280c9d55..83f01a3ae09 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -36,6 +36,7 @@ import { mondayHandler } from '@/lib/webhooks/providers/monday' import { notionHandler } from '@/lib/webhooks/providers/notion' import { outlookHandler } from '@/lib/webhooks/providers/outlook' import { pagerdutyHandler } from '@/lib/webhooks/providers/pagerduty' +import { quickBooksHandler } from '@/lib/webhooks/providers/quickbooks' import { resendHandler } from '@/lib/webhooks/providers/resend' import { revenueCatHandler } from '@/lib/webhooks/providers/revenuecat' import { rootlyHandler } from '@/lib/webhooks/providers/rootly' @@ -104,6 +105,7 @@ const PROVIDER_HANDLERS: Record = { notion: notionHandler, outlook: outlookHandler, pagerduty: pagerdutyHandler, + quickbooks: quickBooksHandler, rss: rssHandler, salesforce: salesforceHandler, sendblue: sendblueHandler, diff --git a/apps/sim/triggers/quickbooks/bill_events.ts b/apps/sim/triggers/quickbooks/bill_events.ts new file mode 100644 index 00000000000..dc6f7ab5e40 --- /dev/null +++ b/apps/sim/triggers/quickbooks/bill_events.ts @@ -0,0 +1,19 @@ +import { QuickBooksIcon } from '@/components/icons' +import { + buildQuickBooksTriggerOutputs, + buildQuickBooksTriggerSubBlocks, + QUICKBOOKS_WEBHOOK_HEADERS, +} from '@/triggers/quickbooks/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const quickBooksBillEventsTrigger: TriggerConfig = { + id: 'quickbooks_bill_events', + name: 'QuickBooks Bill Events', + provider: 'quickbooks', + description: 'Trigger when selected Bill events occur in QuickBooks', + version: '1.0.0', + icon: QuickBooksIcon, + subBlocks: buildQuickBooksTriggerSubBlocks('quickbooks_bill_events'), + outputs: buildQuickBooksTriggerOutputs(), + webhook: { method: 'POST', headers: { ...QUICKBOOKS_WEBHOOK_HEADERS } }, +} diff --git a/apps/sim/triggers/quickbooks/bill_payment_events.ts b/apps/sim/triggers/quickbooks/bill_payment_events.ts new file mode 100644 index 00000000000..01e874b6548 --- /dev/null +++ b/apps/sim/triggers/quickbooks/bill_payment_events.ts @@ -0,0 +1,19 @@ +import { QuickBooksIcon } from '@/components/icons' +import { + buildQuickBooksTriggerOutputs, + buildQuickBooksTriggerSubBlocks, + QUICKBOOKS_WEBHOOK_HEADERS, +} from '@/triggers/quickbooks/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const quickBooksBillPaymentEventsTrigger: TriggerConfig = { + id: 'quickbooks_bill_payment_events', + name: 'QuickBooks Bill Payment Events', + provider: 'quickbooks', + description: 'Trigger when selected Bill Payment events occur in QuickBooks', + version: '1.0.0', + icon: QuickBooksIcon, + subBlocks: buildQuickBooksTriggerSubBlocks('quickbooks_bill_payment_events'), + outputs: buildQuickBooksTriggerOutputs(), + webhook: { method: 'POST', headers: { ...QUICKBOOKS_WEBHOOK_HEADERS } }, +} diff --git a/apps/sim/triggers/quickbooks/customer_events.ts b/apps/sim/triggers/quickbooks/customer_events.ts new file mode 100644 index 00000000000..bace42baa51 --- /dev/null +++ b/apps/sim/triggers/quickbooks/customer_events.ts @@ -0,0 +1,19 @@ +import { QuickBooksIcon } from '@/components/icons' +import { + buildQuickBooksTriggerOutputs, + buildQuickBooksTriggerSubBlocks, + QUICKBOOKS_WEBHOOK_HEADERS, +} from '@/triggers/quickbooks/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const quickBooksCustomerEventsTrigger: TriggerConfig = { + id: 'quickbooks_customer_events', + name: 'QuickBooks Customer Events', + provider: 'quickbooks', + description: 'Trigger when selected Customer events occur in QuickBooks', + version: '1.0.0', + icon: QuickBooksIcon, + subBlocks: buildQuickBooksTriggerSubBlocks('quickbooks_customer_events'), + outputs: buildQuickBooksTriggerOutputs(), + webhook: { method: 'POST', headers: { ...QUICKBOOKS_WEBHOOK_HEADERS } }, +} diff --git a/apps/sim/triggers/quickbooks/index.ts b/apps/sim/triggers/quickbooks/index.ts new file mode 100644 index 00000000000..a5651b1d2d3 --- /dev/null +++ b/apps/sim/triggers/quickbooks/index.ts @@ -0,0 +1,7 @@ +export { quickBooksBillEventsTrigger } from '@/triggers/quickbooks/bill_events' +export { quickBooksBillPaymentEventsTrigger } from '@/triggers/quickbooks/bill_payment_events' +export { quickBooksCustomerEventsTrigger } from '@/triggers/quickbooks/customer_events' +export { quickBooksInvoiceEventsTrigger } from '@/triggers/quickbooks/invoice_events' +export { quickBooksPaymentEventsTrigger } from '@/triggers/quickbooks/payment_events' +export { quickBooksPurchaseOrderEventsTrigger } from '@/triggers/quickbooks/purchase_order_events' +export { quickBooksVendorEventsTrigger } from '@/triggers/quickbooks/vendor_events' diff --git a/apps/sim/triggers/quickbooks/invoice_events.ts b/apps/sim/triggers/quickbooks/invoice_events.ts new file mode 100644 index 00000000000..07e247c7684 --- /dev/null +++ b/apps/sim/triggers/quickbooks/invoice_events.ts @@ -0,0 +1,31 @@ +import { QuickBooksIcon } from '@/components/icons' +import { + buildQuickBooksTriggerOutputs, + buildQuickBooksTriggerSubBlocks, + QUICKBOOKS_WEBHOOK_HEADERS, + quickBooksTriggerOptions, +} from '@/triggers/quickbooks/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const quickBooksInvoiceEventsTrigger: TriggerConfig = { + id: 'quickbooks_invoice_events', + name: 'QuickBooks Invoice Events', + provider: 'quickbooks', + description: 'Trigger when selected Invoice events occur in QuickBooks', + version: '1.0.0', + icon: QuickBooksIcon, + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: quickBooksTriggerOptions, + value: () => 'quickbooks_invoice_events', + required: true, + }, + ...buildQuickBooksTriggerSubBlocks('quickbooks_invoice_events'), + ], + outputs: buildQuickBooksTriggerOutputs(), + webhook: { method: 'POST', headers: { ...QUICKBOOKS_WEBHOOK_HEADERS } }, +} diff --git a/apps/sim/triggers/quickbooks/payment_events.ts b/apps/sim/triggers/quickbooks/payment_events.ts new file mode 100644 index 00000000000..b8139edcd41 --- /dev/null +++ b/apps/sim/triggers/quickbooks/payment_events.ts @@ -0,0 +1,19 @@ +import { QuickBooksIcon } from '@/components/icons' +import { + buildQuickBooksTriggerOutputs, + buildQuickBooksTriggerSubBlocks, + QUICKBOOKS_WEBHOOK_HEADERS, +} from '@/triggers/quickbooks/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const quickBooksPaymentEventsTrigger: TriggerConfig = { + id: 'quickbooks_payment_events', + name: 'QuickBooks Payment Events', + provider: 'quickbooks', + description: 'Trigger when selected Payment events occur in QuickBooks', + version: '1.0.0', + icon: QuickBooksIcon, + subBlocks: buildQuickBooksTriggerSubBlocks('quickbooks_payment_events'), + outputs: buildQuickBooksTriggerOutputs(), + webhook: { method: 'POST', headers: { ...QUICKBOOKS_WEBHOOK_HEADERS } }, +} diff --git a/apps/sim/triggers/quickbooks/purchase_order_events.ts b/apps/sim/triggers/quickbooks/purchase_order_events.ts new file mode 100644 index 00000000000..fa2d04745c3 --- /dev/null +++ b/apps/sim/triggers/quickbooks/purchase_order_events.ts @@ -0,0 +1,19 @@ +import { QuickBooksIcon } from '@/components/icons' +import { + buildQuickBooksTriggerOutputs, + buildQuickBooksTriggerSubBlocks, + QUICKBOOKS_WEBHOOK_HEADERS, +} from '@/triggers/quickbooks/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const quickBooksPurchaseOrderEventsTrigger: TriggerConfig = { + id: 'quickbooks_purchase_order_events', + name: 'QuickBooks Purchase Order Events', + provider: 'quickbooks', + description: 'Trigger when selected Purchase Order events occur in QuickBooks', + version: '1.0.0', + icon: QuickBooksIcon, + subBlocks: buildQuickBooksTriggerSubBlocks('quickbooks_purchase_order_events'), + outputs: buildQuickBooksTriggerOutputs(), + webhook: { method: 'POST', headers: { ...QUICKBOOKS_WEBHOOK_HEADERS } }, +} diff --git a/apps/sim/triggers/quickbooks/quickbooks.test.ts b/apps/sim/triggers/quickbooks/quickbooks.test.ts new file mode 100644 index 00000000000..9cc99e2a52b --- /dev/null +++ b/apps/sim/triggers/quickbooks/quickbooks.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { QuickBooksBlock } from '@/blocks/blocks/quickbooks' +import { getTrigger } from '@/triggers' +import { + QUICKBOOKS_TRIGGER_DEFINITIONS, + quickBooksTriggerOptions, +} from '@/triggers/quickbooks/utils' + +describe('QuickBooks triggers', () => { + it('keeps trigger definitions, dropdown options, and block access in parity', () => { + const ids = QUICKBOOKS_TRIGGER_DEFINITIONS.map(({ id }) => id) + expect(quickBooksTriggerOptions.map(({ id }) => id)).toEqual(ids) + expect(QuickBooksBlock.triggers?.available).toEqual(ids) + for (const id of ids) expect(getTrigger(id).id).toBe(id) + }) + + it('uses one primary trigger dropdown and entity-specific event selectors', () => { + expect( + getTrigger('quickbooks_invoice_events').subBlocks.filter( + (subBlock) => subBlock.id === 'selectedTriggerId' + ) + ).toHaveLength(1) + for (const definition of QUICKBOOKS_TRIGGER_DEFINITIONS) { + const trigger = getTrigger(definition.id) + const eventTypes = trigger.subBlocks.find((subBlock) => subBlock.id === 'eventTypes') + expect(eventTypes?.options?.map((option) => option.id)).toEqual(definition.actions) + expect(trigger.outputs).toEqual(getTrigger('quickbooks_invoice_events').outputs) + } + }) +}) diff --git a/apps/sim/triggers/quickbooks/utils.ts b/apps/sim/triggers/quickbooks/utils.ts new file mode 100644 index 00000000000..b7cbeb4db7d --- /dev/null +++ b/apps/sim/triggers/quickbooks/utils.ts @@ -0,0 +1,163 @@ +import { getScopesForService } from '@/lib/oauth/utils' +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +export type QuickBooksWebhookAction = + | 'created' + | 'updated' + | 'deleted' + | 'merged' + | 'voided' + | 'emailed' + +export interface QuickBooksTriggerDefinition { + actions: readonly QuickBooksWebhookAction[] + entity: string + entityType: string + group: string + id: string + label: string +} + +export const QUICKBOOKS_TRIGGER_DEFINITIONS: readonly QuickBooksTriggerDefinition[] = [ + { + id: 'quickbooks_customer_events', + label: 'Customer Events', + group: 'Sales and Receivables', + entity: 'customer', + entityType: 'Customer', + actions: ['created', 'updated', 'deleted', 'merged'], + }, + { + id: 'quickbooks_invoice_events', + label: 'Invoice Events', + group: 'Sales and Receivables', + entity: 'invoice', + entityType: 'Invoice', + actions: ['created', 'updated', 'deleted', 'voided', 'emailed'], + }, + { + id: 'quickbooks_payment_events', + label: 'Payment Events', + group: 'Sales and Receivables', + entity: 'payment', + entityType: 'Payment', + actions: ['created', 'updated', 'deleted', 'voided', 'emailed'], + }, + { + id: 'quickbooks_vendor_events', + label: 'Vendor Events', + group: 'Purchasing and Payables', + entity: 'vendor', + entityType: 'Vendor', + actions: ['created', 'updated', 'deleted', 'merged'], + }, + { + id: 'quickbooks_bill_events', + label: 'Bill Events', + group: 'Purchasing and Payables', + entity: 'bill', + entityType: 'Bill', + actions: ['created', 'updated', 'deleted'], + }, + { + id: 'quickbooks_bill_payment_events', + label: 'Bill Payment Events', + group: 'Purchasing and Payables', + entity: 'billpayment', + entityType: 'BillPayment', + actions: ['created', 'updated', 'deleted', 'voided'], + }, + { + id: 'quickbooks_purchase_order_events', + label: 'Purchase Order Events', + group: 'Purchasing and Payables', + entity: 'purchaseorder', + entityType: 'PurchaseOrder', + actions: ['created', 'updated', 'deleted', 'emailed'], + }, +] as const + +export const quickBooksTriggerOptions = QUICKBOOKS_TRIGGER_DEFINITIONS.map((definition) => ({ + label: definition.label, + id: definition.id, + group: definition.group, +})) + +export const QUICKBOOKS_WEBHOOK_HEADERS = { + 'Content-Type': 'application/json', + 'intuit-signature': '', +} as const + +export function getQuickBooksTriggerDefinition( + triggerId: string +): QuickBooksTriggerDefinition | undefined { + return QUICKBOOKS_TRIGGER_DEFINITIONS.find((definition) => definition.id === triggerId) +} + +export function parseQuickBooksWebhookType(type: string): { + action: string + entity: string +} | null { + const match = /^qbo\.([a-z]+)\.([a-z]+)\.v1$/.exec(type) + return match ? { entity: match[1], action: match[2] } : null +} + +export function isQuickBooksEventMatch( + triggerId: string, + eventType: string, + selectedActions: unknown +): boolean { + const definition = getQuickBooksTriggerDefinition(triggerId) + const parsed = parseQuickBooksWebhookType(eventType) + if (!definition || !parsed || parsed.entity !== definition.entity) return false + if (!definition.actions.includes(parsed.action as QuickBooksWebhookAction)) return false + return Array.isArray(selectedActions) && selectedActions.includes(parsed.action) +} + +export function buildQuickBooksTriggerSubBlocks(triggerId: string): SubBlockConfig[] { + const definition = getQuickBooksTriggerDefinition(triggerId) + if (!definition) throw new Error(`Unknown QuickBooks trigger: ${triggerId}`) + + return [ + { + id: 'triggerCredentials', + title: 'QuickBooks Account', + type: 'oauth-input', + serviceId: 'quickbooks', + requiredScopes: getScopesForService('quickbooks'), + mode: 'trigger', + required: true, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + { + id: 'eventTypes', + title: 'Event Types', + type: 'dropdown', + multiSelect: true, + options: definition.actions.map((action) => ({ + label: action.charAt(0).toUpperCase() + action.slice(1), + id: action, + })), + mode: 'trigger', + required: true, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +export function buildQuickBooksTriggerOutputs(): Record { + return { + eventId: { type: 'string', description: 'Intuit webhook event ID' }, + eventType: { type: 'string', description: 'Full Intuit CloudEvent type' }, + entityType: { type: 'string', description: 'QuickBooks entity type' }, + action: { type: 'string', description: 'QuickBooks webhook action' }, + entityId: { type: 'string', description: 'QuickBooks entity ID' }, + realmId: { type: 'string', description: 'QuickBooks company realm ID' }, + eventTime: { type: 'string', description: 'Event timestamp' }, + specVersion: { type: 'string', description: 'CloudEvents specification version' }, + source: { type: 'string', description: 'Intuit event source' }, + contentType: { type: 'string', description: 'Event content type, when provided' }, + data: { type: 'json', description: 'Optional event data supplied by Intuit' }, + } +} diff --git a/apps/sim/triggers/quickbooks/vendor_events.ts b/apps/sim/triggers/quickbooks/vendor_events.ts new file mode 100644 index 00000000000..aa06333cad1 --- /dev/null +++ b/apps/sim/triggers/quickbooks/vendor_events.ts @@ -0,0 +1,19 @@ +import { QuickBooksIcon } from '@/components/icons' +import { + buildQuickBooksTriggerOutputs, + buildQuickBooksTriggerSubBlocks, + QUICKBOOKS_WEBHOOK_HEADERS, +} from '@/triggers/quickbooks/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const quickBooksVendorEventsTrigger: TriggerConfig = { + id: 'quickbooks_vendor_events', + name: 'QuickBooks Vendor Events', + provider: 'quickbooks', + description: 'Trigger when selected Vendor events occur in QuickBooks', + version: '1.0.0', + icon: QuickBooksIcon, + subBlocks: buildQuickBooksTriggerSubBlocks('quickbooks_vendor_events'), + outputs: buildQuickBooksTriggerOutputs(), + webhook: { method: 'POST', headers: { ...QUICKBOOKS_WEBHOOK_HEADERS } }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index c9c55455f44..ff39129cc0f 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -369,6 +369,15 @@ import { pagerdutyIncidentTriggeredTrigger, pagerdutyWebhookTrigger, } from '@/triggers/pagerduty' +import { + quickBooksBillEventsTrigger, + quickBooksBillPaymentEventsTrigger, + quickBooksCustomerEventsTrigger, + quickBooksInvoiceEventsTrigger, + quickBooksPaymentEventsTrigger, + quickBooksPurchaseOrderEventsTrigger, + quickBooksVendorEventsTrigger, +} from '@/triggers/quickbooks' import { resendEmailBouncedTrigger, resendEmailClickedTrigger, @@ -738,6 +747,13 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { pagerduty_incident_escalated: pagerdutyIncidentEscalatedTrigger, pagerduty_incident_reassigned: pagerdutyIncidentReassignedTrigger, pagerduty_webhook: pagerdutyWebhookTrigger, + quickbooks_bill_events: quickBooksBillEventsTrigger, + quickbooks_bill_payment_events: quickBooksBillPaymentEventsTrigger, + quickbooks_customer_events: quickBooksCustomerEventsTrigger, + quickbooks_invoice_events: quickBooksInvoiceEventsTrigger, + quickbooks_payment_events: quickBooksPaymentEventsTrigger, + quickbooks_purchase_order_events: quickBooksPurchaseOrderEventsTrigger, + quickbooks_vendor_events: quickBooksVendorEventsTrigger, resend_email_sent: resendEmailSentTrigger, resend_email_delivered: resendEmailDeliveredTrigger, resend_email_bounced: resendEmailBouncedTrigger, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 4bed6a4e7d5..65c47d16de5 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1009, - zodRoutes: 1009, + totalRoutes: 1010, + zodRoutes: 1010, nonZodRoutes: 0, } as const From b1be51dcb03666ade3eb3a7ec3eb47692838986c Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 4 Aug 2026 00:13:46 -0700 Subject: [PATCH 2/5] docs(quickbooks): add webhook setup guidance --- apps/sim/triggers/quickbooks/utils.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/sim/triggers/quickbooks/utils.ts b/apps/sim/triggers/quickbooks/utils.ts index b7cbeb4db7d..fc7a5560561 100644 --- a/apps/sim/triggers/quickbooks/utils.ts +++ b/apps/sim/triggers/quickbooks/utils.ts @@ -115,6 +115,22 @@ export function isQuickBooksEventMatch( return Array.isArray(selectedActions) && selectedActions.includes(parsed.action) } +function quickBooksSetupInstructions(entityLabel: string): string { + const instructions = [ + 'App setup: A Sim operator must register https://<your-sim-domain>/api/webhooks/quickbooks as the app-level endpoint in the Intuit Developer Portal and configure the server-only QUICKBOOKS_WEBHOOK_VERIFIER_TOKEN.', + 'Connect the QuickBooks account whose company should receive these events.', + `Select the ${entityLabel} actions this workflow should handle.`, + 'Deploy the workflow to activate the trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + export function buildQuickBooksTriggerSubBlocks(triggerId: string): SubBlockConfig[] { const definition = getQuickBooksTriggerDefinition(triggerId) if (!definition) throw new Error(`Unknown QuickBooks trigger: ${triggerId}`) @@ -143,6 +159,15 @@ export function buildQuickBooksTriggerSubBlocks(triggerId: string): SubBlockConf required: true, condition: { field: 'selectedTriggerId', value: triggerId }, }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: quickBooksSetupInstructions(definition.label), + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, ] } From 937ec788b0b24f6cfd7caaa8debe1c83a6e5a778 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 4 Aug 2026 00:23:47 -0700 Subject: [PATCH 3/5] fix(quickbooks): continue webhook batches after target failures --- .../quickbooks-webhook-ingress.test.ts | 20 ++++++++++++ .../background/quickbooks-webhook-ingress.ts | 32 ++++++++++++------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/apps/sim/background/quickbooks-webhook-ingress.test.ts b/apps/sim/background/quickbooks-webhook-ingress.test.ts index 1127bbb2d66..a4bd4b9d2c7 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.test.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.test.ts @@ -59,6 +59,7 @@ describe('QuickBooks webhook ingress job', () => { return { outcome: 'queued' } }) await expect(executeQuickBooksWebhookIngress(payload)).resolves.toEqual({ + failed: 0, ignored: 0, processed: 2, targetCount: 2, @@ -98,4 +99,23 @@ describe('QuickBooks webhook ingress job', () => { expect.objectContaining({ jobId: 'quickbooks-webhook-ingress:request-1:1:root' }) ) }) + + it('durably continues the batch before retrying a failed target page', async () => { + mockFindPage.mockResolvedValue({ + hasMore: false, + nextCursor: null, + targets: [{ webhook: { id: 'w1' }, workflow: { id: 'wf1' } }], + }) + mockDispatch.mockResolvedValue({ outcome: 'failed' }) + + await enqueueQuickBooksWebhookIngress(payload) + const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } + await expect(options.runner()).rejects.toThrow('Failed to dispatch 1 of 1 QuickBooks targets') + expect(mockEnqueue).toHaveBeenNthCalledWith( + 2, + 'quickbooks-webhook-ingress', + expect.objectContaining({ eventIndex: 1, afterWebhookId: undefined }), + expect.objectContaining({ jobId: 'quickbooks-webhook-ingress:request-1:1:root' }) + ) + }) }) diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts index 5c0ac6e8a98..3449422b159 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -21,6 +21,7 @@ export interface QuickBooksWebhookIngressPayload { } export interface QuickBooksWebhookIngressResult { + failed: number ignored: number nextCursor?: string processed: number @@ -33,7 +34,7 @@ export async function executeQuickBooksWebhookIngress( ): Promise { const eventIndex = payload.eventIndex ?? 0 const event = payload.events[eventIndex] - if (!event) return { ignored: 0, processed: 0, targetCount: 0 } + if (!event) return { failed: 0, ignored: 0, processed: 0, targetCount: 0 } const request = new NextRequest('http://internal/api/webhooks/quickbooks', { method: 'POST', @@ -65,10 +66,6 @@ export async function executeQuickBooksWebhookIngress( else failed += 1 } - if (failed > 0) { - throw new Error(`Failed to dispatch ${failed} of ${page.targets.length} QuickBooks targets`) - } - logger.info(`[${payload.requestId}] QuickBooks webhook page completed`, { eventId: event.id, eventIndex, @@ -77,6 +74,7 @@ export async function executeQuickBooksWebhookIngress( targetCount: page.targets.length, }) return { + failed, ignored, processed, targetCount: page.targets.length, @@ -84,16 +82,14 @@ export async function executeQuickBooksWebhookIngress( } } -async function runQuickBooksWebhookIngressJob( - payload: QuickBooksWebhookIngressPayload +async function enqueueQuickBooksWebhookContinuation( + payload: QuickBooksWebhookIngressPayload, + result: QuickBooksWebhookIngressResult ): Promise { const eventIndex = payload.eventIndex ?? 0 - const result = await executeQuickBooksWebhookIngress(payload) if (result.nextCursor) { await enqueueQuickBooksWebhookIngress({ ...payload, afterWebhookId: result.nextCursor }) - return - } - if (eventIndex + 1 < payload.events.length) { + } else if (eventIndex + 1 < payload.events.length) { await enqueueQuickBooksWebhookIngress({ ...payload, eventIndex: eventIndex + 1, @@ -102,6 +98,20 @@ async function runQuickBooksWebhookIngressJob( } } +async function runQuickBooksWebhookIngressJob( + payload: QuickBooksWebhookIngressPayload +): Promise { + const result = await executeQuickBooksWebhookIngress(payload) + // The continuation has a deterministic job id, so retries cannot duplicate it. Enqueue it + // before retrying this page to avoid stranding later events in an already-acknowledged batch. + await enqueueQuickBooksWebhookContinuation(payload, result) + if (result.failed > 0) { + throw new Error( + `Failed to dispatch ${result.failed} of ${result.targetCount} QuickBooks targets` + ) + } +} + export async function enqueueQuickBooksWebhookIngress( payload: QuickBooksWebhookIngressPayload ): Promise { From 29824665523dfc2af1ba677e25e246fb2a2c6267 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 4 Aug 2026 00:32:44 -0700 Subject: [PATCH 4/5] fix(quickbooks): align primary webhook trigger fallback --- apps/sim/blocks/blocks/quickbooks.ts | 2 +- apps/sim/triggers/quickbooks/quickbooks.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/sim/blocks/blocks/quickbooks.ts b/apps/sim/blocks/blocks/quickbooks.ts index af89c89bc63..6b75472815b 100644 --- a/apps/sim/blocks/blocks/quickbooks.ts +++ b/apps/sim/blocks/blocks/quickbooks.ts @@ -1978,8 +1978,8 @@ export const QuickBooksBlock: BlockConfig = { triggers: { enabled: true, available: [ - 'quickbooks_customer_events', 'quickbooks_invoice_events', + 'quickbooks_customer_events', 'quickbooks_payment_events', 'quickbooks_vendor_events', 'quickbooks_bill_events', diff --git a/apps/sim/triggers/quickbooks/quickbooks.test.ts b/apps/sim/triggers/quickbooks/quickbooks.test.ts index 9cc99e2a52b..66fe7a6fdb0 100644 --- a/apps/sim/triggers/quickbooks/quickbooks.test.ts +++ b/apps/sim/triggers/quickbooks/quickbooks.test.ts @@ -10,7 +10,10 @@ describe('QuickBooks triggers', () => { it('keeps trigger definitions, dropdown options, and block access in parity', () => { const ids = QUICKBOOKS_TRIGGER_DEFINITIONS.map(({ id }) => id) expect(quickBooksTriggerOptions.map(({ id }) => id)).toEqual(ids) - expect(QuickBooksBlock.triggers?.available).toEqual(ids) + expect(QuickBooksBlock.triggers?.available).toEqual([ + 'quickbooks_invoice_events', + ...ids.filter((id) => id !== 'quickbooks_invoice_events'), + ]) for (const id of ids) expect(getTrigger(id).id).toBe(id) }) From 28c04b1d3504572ccf06f0197bad1f5a111cfbfb Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 4 Aug 2026 01:21:17 -0700 Subject: [PATCH 5/5] fix(quickbooks): bound webhook ingress jobs --- .../quickbooks-webhook-ingress.test.ts | 113 ++++++++------ .../background/quickbooks-webhook-ingress.ts | 138 +++++++++--------- 2 files changed, 138 insertions(+), 113 deletions(-) diff --git a/apps/sim/background/quickbooks-webhook-ingress.test.ts b/apps/sim/background/quickbooks-webhook-ingress.test.ts index a4bd4b9d2c7..2380197b3f9 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.test.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.test.ts @@ -44,16 +44,27 @@ describe('QuickBooks webhook ingress job', () => { mockEnqueue.mockResolvedValue('job-1') }) - it('routes one event to one company and dispatches targets sequentially', async () => { + it('routes the batch by company and dispatches targets sequentially', async () => { const order: string[] = [] - mockFindPage.mockResolvedValue({ - hasMore: false, - nextCursor: null, - targets: [ - { webhook: { id: 'w1' }, workflow: { id: 'wf1' } }, - { webhook: { id: 'w2' }, workflow: { id: 'wf2' } }, - ], - }) + mockFindPage + .mockResolvedValueOnce({ + hasMore: true, + nextCursor: 'w2', + targets: [ + { webhook: { id: 'w1' }, workflow: { id: 'wf1' } }, + { webhook: { id: 'w2' }, workflow: { id: 'wf2' } }, + ], + }) + .mockResolvedValueOnce({ + hasMore: false, + nextCursor: null, + targets: [{ webhook: { id: 'w3' }, workflow: { id: 'wf3' } }], + }) + .mockResolvedValueOnce({ + hasMore: false, + nextCursor: null, + targets: [{ webhook: { id: 'w4' }, workflow: { id: 'wf4' } }], + }) mockDispatch.mockImplementation(async (webhook: { id: string }) => { order.push(webhook.id) return { outcome: 'queued' } @@ -61,61 +72,71 @@ describe('QuickBooks webhook ingress job', () => { await expect(executeQuickBooksWebhookIngress(payload)).resolves.toEqual({ failed: 0, ignored: 0, - processed: 2, - targetCount: 2, + processed: 4, + targetCount: 4, }) expect(mockFindPage).toHaveBeenCalledWith('456', 'request-1', undefined) - expect(order).toEqual(['w1', 'w2']) + expect(mockFindPage).toHaveBeenCalledWith('456', 'request-1', 'w2') + expect(mockFindPage).toHaveBeenCalledWith('789', 'request-1', undefined) + expect(order).toEqual(['w1', 'w2', 'w3', 'w4']) }) - it('continues target pages before advancing to the next event', async () => { - mockFindPage.mockResolvedValue({ - hasMore: true, - nextCursor: 'webhook-100', - targets: [], - }) + it('enqueues the bounded delivery once without copying it into continuation jobs', async () => { + mockFindPage.mockResolvedValue({ hasMore: false, nextCursor: null, targets: [] }) await enqueueQuickBooksWebhookIngress(payload) const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } await options.runner() - expect(mockEnqueue).toHaveBeenNthCalledWith( - 2, + expect(mockEnqueue).toHaveBeenCalledOnce() + expect(mockEnqueue).toHaveBeenCalledWith( 'quickbooks-webhook-ingress', - expect.objectContaining({ afterWebhookId: 'webhook-100' }), + payload, expect.objectContaining({ - jobId: 'quickbooks-webhook-ingress:request-1:0:webhook-100', + jobId: 'quickbooks-webhook-ingress:request-1', }) ) }) - it('advances to the next event only after the current event finishes', async () => { - mockFindPage.mockResolvedValue({ hasMore: false, nextCursor: null, targets: [] }) + it('continues later events before retrying a delivery with failed targets', async () => { + mockFindPage + .mockResolvedValueOnce({ + hasMore: false, + nextCursor: null, + targets: [ + { webhook: { id: 'w1' }, workflow: { id: 'wf1' } }, + { webhook: { id: 'w2' }, workflow: { id: 'wf2' } }, + ], + }) + .mockResolvedValueOnce({ + hasMore: false, + nextCursor: null, + targets: [{ webhook: { id: 'w3' }, workflow: { id: 'wf3' } }], + }) + mockDispatch + .mockRejectedValueOnce(new Error('dispatch unavailable')) + .mockResolvedValueOnce({ outcome: 'failed' }) + .mockResolvedValueOnce({ outcome: 'queued' }) + await enqueueQuickBooksWebhookIngress(payload) const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } - await options.runner() - expect(mockEnqueue).toHaveBeenNthCalledWith( - 2, - 'quickbooks-webhook-ingress', - expect.objectContaining({ eventIndex: 1, afterWebhookId: undefined }), - expect.objectContaining({ jobId: 'quickbooks-webhook-ingress:request-1:1:root' }) + await expect(options.runner()).rejects.toThrow( + 'QuickBooks webhook delivery completed with 2 failures' ) + expect(mockFindPage).toHaveBeenCalledWith('789', 'request-1', undefined) + expect(mockDispatch).toHaveBeenCalledTimes(3) + expect(mockEnqueue).toHaveBeenCalledOnce() }) - it('durably continues the batch before retrying a failed target page', async () => { - mockFindPage.mockResolvedValue({ - hasMore: false, - nextCursor: null, - targets: [{ webhook: { id: 'w1' }, workflow: { id: 'wf1' } }], - }) - mockDispatch.mockResolvedValue({ outcome: 'failed' }) + it('continues later events when a target page cannot be resolved', async () => { + mockFindPage + .mockRejectedValueOnce(new Error('database unavailable')) + .mockResolvedValueOnce({ hasMore: false, nextCursor: null, targets: [] }) - await enqueueQuickBooksWebhookIngress(payload) - const options = mockEnqueue.mock.calls[0][2] as { runner: () => Promise } - await expect(options.runner()).rejects.toThrow('Failed to dispatch 1 of 1 QuickBooks targets') - expect(mockEnqueue).toHaveBeenNthCalledWith( - 2, - 'quickbooks-webhook-ingress', - expect.objectContaining({ eventIndex: 1, afterWebhookId: undefined }), - expect.objectContaining({ jobId: 'quickbooks-webhook-ingress:request-1:1:root' }) - ) + await expect(executeQuickBooksWebhookIngress(payload)).resolves.toEqual({ + failed: 1, + ignored: 0, + processed: 0, + targetCount: 0, + }) + expect(mockFindPage).toHaveBeenCalledWith('789', 'request-1', undefined) }) }) diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts index 3449422b159..b091225fdcd 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -12,8 +12,6 @@ export const QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT = 50 export const QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS = 3 export interface QuickBooksWebhookIngressPayload { - afterWebhookId?: string - eventIndex?: number events: QuickBooksWebhookEvent[] headers: { 'content-type': string } requestId: string @@ -23,92 +21,99 @@ export interface QuickBooksWebhookIngressPayload { export interface QuickBooksWebhookIngressResult { failed: number ignored: number - nextCursor?: string processed: number targetCount: number } -/** Process one event against one bounded target page. */ +/** Process the bounded delivery sequentially, retaining at most one target page at a time. */ export async function executeQuickBooksWebhookIngress( payload: QuickBooksWebhookIngressPayload ): Promise { - const eventIndex = payload.eventIndex ?? 0 - const event = payload.events[eventIndex] - if (!event) return { failed: 0, ignored: 0, processed: 0, targetCount: 0 } - - const request = new NextRequest('http://internal/api/webhooks/quickbooks', { - method: 'POST', - headers: payload.headers, - body: JSON.stringify(event), - }) - const page = await findQuickBooksWebhookTargetPage( - event.intuitaccountid, - payload.requestId, - payload.afterWebhookId - ) - const nextCursor = page.hasMore ? page.nextCursor : null - if (page.hasMore && (!nextCursor || nextCursor === payload.afterWebhookId)) { - throw new Error('QuickBooks webhook target pagination did not advance') - } - let ignored = 0 let processed = 0 let failed = 0 - for (const { webhook, workflow } of page.targets) { - const result = await dispatchResolvedWebhookTarget(webhook, workflow, event, request, { - requestId: payload.requestId, - path: webhook.path ?? undefined, - receivedAt: payload.receivedAt, - triggerTimestampMs: Date.parse(event.time), + let targetCount = 0 + + for (const [eventIndex, event] of payload.events.entries()) { + const request = new NextRequest('http://internal/api/webhooks/quickbooks', { + method: 'POST', + headers: payload.headers, + body: JSON.stringify(event), }) - if (result.outcome === 'queued') processed += 1 - else if (result.outcome === 'ignored') ignored += 1 - else failed += 1 + let afterWebhookId: string | undefined + + while (true) { + try { + const page = await findQuickBooksWebhookTargetPage( + event.intuitaccountid, + payload.requestId, + afterWebhookId + ) + const nextCursor = page.hasMore ? page.nextCursor : null + if (page.hasMore && (!nextCursor || nextCursor === afterWebhookId)) { + throw new Error('QuickBooks webhook target pagination did not advance') + } + + targetCount += page.targets.length + for (const { webhook, workflow } of page.targets) { + try { + const result = await dispatchResolvedWebhookTarget(webhook, workflow, event, request, { + requestId: payload.requestId, + path: webhook.path ?? undefined, + receivedAt: payload.receivedAt, + triggerTimestampMs: Date.parse(event.time), + }) + if (result.outcome === 'queued') processed += 1 + else if (result.outcome === 'ignored') ignored += 1 + else failed += 1 + } catch (error) { + failed += 1 + logger.error(`[${payload.requestId}] QuickBooks webhook target dispatch failed`, { + error, + eventId: event.id, + eventIndex, + webhookId: webhook.id, + }) + } + } + + logger.info(`[${payload.requestId}] QuickBooks webhook page completed`, { + eventId: event.id, + eventIndex, + ignored, + processed, + targetCount: page.targets.length, + }) + if (!nextCursor) break + afterWebhookId = nextCursor + } catch (error) { + failed += 1 + logger.error(`[${payload.requestId}] QuickBooks webhook event page failed`, { + error, + eventId: event.id, + eventIndex, + }) + break + } + } } - logger.info(`[${payload.requestId}] QuickBooks webhook page completed`, { - eventId: event.id, - eventIndex, - ignored, - processed, - targetCount: page.targets.length, - }) - return { + logger.info(`[${payload.requestId}] QuickBooks webhook delivery completed`, { + eventCount: payload.events.length, failed, ignored, processed, - targetCount: page.targets.length, - ...(nextCursor ? { nextCursor } : {}), - } -} - -async function enqueueQuickBooksWebhookContinuation( - payload: QuickBooksWebhookIngressPayload, - result: QuickBooksWebhookIngressResult -): Promise { - const eventIndex = payload.eventIndex ?? 0 - if (result.nextCursor) { - await enqueueQuickBooksWebhookIngress({ ...payload, afterWebhookId: result.nextCursor }) - } else if (eventIndex + 1 < payload.events.length) { - await enqueueQuickBooksWebhookIngress({ - ...payload, - eventIndex: eventIndex + 1, - afterWebhookId: undefined, - }) - } + targetCount, + }) + return { failed, ignored, processed, targetCount } } async function runQuickBooksWebhookIngressJob( payload: QuickBooksWebhookIngressPayload ): Promise { const result = await executeQuickBooksWebhookIngress(payload) - // The continuation has a deterministic job id, so retries cannot duplicate it. Enqueue it - // before retrying this page to avoid stranding later events in an already-acknowledged batch. - await enqueueQuickBooksWebhookContinuation(payload, result) if (result.failed > 0) { - throw new Error( - `Failed to dispatch ${result.failed} of ${result.targetCount} QuickBooks targets` - ) + throw new Error(`QuickBooks webhook delivery completed with ${result.failed} failures`) } } @@ -116,9 +121,8 @@ export async function enqueueQuickBooksWebhookIngress( payload: QuickBooksWebhookIngressPayload ): Promise { const jobQueue = await getJobQueue() - const eventIndex = payload.eventIndex ?? 0 return jobQueue.enqueue('quickbooks-webhook-ingress', payload, { - jobId: `quickbooks-webhook-ingress:${payload.requestId}:${eventIndex}:${payload.afterWebhookId ?? 'root'}`, + jobId: `quickbooks-webhook-ingress:${payload.requestId}`, maxAttempts: QUICKBOOKS_WEBHOOK_INGRESS_MAX_ATTEMPTS, concurrencyKey: 'quickbooks-webhook-ingress', concurrencyLimit: QUICKBOOKS_WEBHOOK_INGRESS_CONCURRENCY_LIMIT,