diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index efedba2000d..f57951a6df9 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -8759,6 +8759,23 @@ export function RocketlaneIcon(props: SVGProps) { ) } +export function QuickBooksIcon(props: SVGProps) { + return ( + + + + + + + ) +} + /** * Pydantic Logfire. Single-fill mark drawn with `fill='currentColor'` so it * takes white on its brand tile and the block's `iconColor` when rendered bare. diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 8070694868b..63c8ebd32ed 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -176,6 +176,7 @@ import { PulseIcon, QdrantIcon, QuartrIcon, + QuickBooksIcon, QuiverIcon, RailwayIcon, RB2BIcon, @@ -455,6 +456,7 @@ export const blockTypeToIconMap: Record = { pulse_v2: PulseIcon, qdrant: QdrantIcon, quartr: QuartrIcon, + quickbooks: QuickBooksIcon, quiver: QuiverIcon, railway: RailwayIcon, rb2b: RB2BIcon, diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json index 56235d7d9c2..2acc26542c6 100644 --- a/apps/docs/content/docs/en/integrations/meta.json +++ b/apps/docs/content/docs/en/integrations/meta.json @@ -187,6 +187,7 @@ "pulse", "qdrant", "quartr", + "quickbooks", "quiver", "railway", "rb2b", diff --git a/apps/docs/content/docs/en/integrations/quickbooks.mdx b/apps/docs/content/docs/en/integrations/quickbooks.mdx new file mode 100644 index 00000000000..dbcfcbd61d9 --- /dev/null +++ b/apps/docs/content/docs/en/integrations/quickbooks.mdx @@ -0,0 +1,510 @@ +--- +title: QuickBooks +description: Manage and report on QuickBooks Online accounting data +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +{/* MANUAL-CONTENT-START:intro */} +## Setup + +Connect QuickBooks with OAuth 2.0, then enter the `realmId` for the company you authorized. Intuit returns this company ID as the `realmId` query parameter during the OAuth callback. Use a sandbox company ID with sandbox credentials and a production company ID with production credentials. + +QuickBooks Online does not provide a permanent API key or static per-user token for Accounting API access. Access tokens expire and are renewed through the connected OAuth credential. + +Entity availability can depend on the QuickBooks locale or subscription. For example, `TaxPayment` is limited to supported non-US locales, `JournalCode` is France-only, `RecurringTransaction` requires Essentials, Plus, or Advanced, and `InventoryAdjustment` requires a supported US Plus or Advanced company. +{/* MANUAL-CONTENT-END */} + + +## Usage Instructions + +Integrate QuickBooks Online into procurement and accounting workflows. Manage supported records, inventory adjustments, preferences, currencies, reports, attachments, PDFs, change-data-capture syncs, batches, and custom queries. + + + +## Actions + +### `quickbooks_batch` + +Run up to 10 QuickBooks Online entity or query operations in one batch + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `batch` | json | Yes | QuickBooks batch payload containing a BatchItemRequest array | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `batchItems` | array | QuickBooks batch item responses in request order | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_create_record` + +Create a supported QuickBooks Online accounting record + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entity` | string | Yes | Creatable QuickBooks entity name | +| `payload` | json | Yes | Entity payload using the fields documented by the QuickBooks API | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Created entity-specific QuickBooks record | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_delete_record` + +Delete a supported QuickBooks Online transaction or attachment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entity` | string | Yes | QuickBooks entity that supports hard delete | +| `recordId` | string | Yes | QuickBooks record ID | +| `syncToken` | string | Yes | Latest QuickBooks SyncToken for the transaction | +| `payload` | json | No | Full entity payload when the selected transaction does not support simplified delete | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Deleted entity-specific QuickBooks record | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_download_document` + +Download a supported QuickBooks transaction as a PDF + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entity` | string | Yes | QuickBooks transaction type that supports PDF download | +| `recordId` | string | Yes | QuickBooks transaction ID | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | QuickBooks transaction PDF | +| `entity` | string | QuickBooks transaction type | +| `recordId` | string | QuickBooks transaction ID | + +### `quickbooks_get_attachment_url` + +Get a temporary download URL for a QuickBooks attachment or thumbnail + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `attachmentId` | string | Yes | QuickBooks Attachable ID | +| `thumbnail` | boolean | No | Return a thumbnail URL instead of the original attachment URL | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `url` | string | Temporary QuickBooks attachment URL | +| `attachmentId` | string | QuickBooks Attachable ID | +| `thumbnail` | boolean | Whether the URL points to a thumbnail | + +### `quickbooks_get_changes` + +Get QuickBooks Online records changed during the last 30 days using CDC + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entities` | string | Yes | Comma-separated QuickBooks entity names to track | +| `changedSince` | string | Yes | ISO date or date-time within the last 30 days | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `changes` | array | Changed QuickBooks records grouped by entity | +| ↳ `entity` | string | QuickBooks entity name | +| ↳ `records` | array | Changed or deleted entity records | +| ↳ `startPosition` | number | Start position returned by QuickBooks | +| ↳ `maxResults` | number | Maximum results returned by QuickBooks | +| ↳ `totalCount` | number | Total changes returned for the entity | +| `changedSince` | string | Requested change look-back date | +| `mayBeTruncated` | boolean | Whether the 1,000-object CDC response limit may have been reached | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_get_exchange_rate` + +Get a QuickBooks exchange rate for an ISO currency code and optional date + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `sourceCurrencyCode` | string | Yes | Three-letter ISO 4217 source currency code | +| `asOfDate` | string | No | Exchange rate effective date in YYYY-MM-DD format; defaults to today | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | QuickBooks exchange rate | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_get_preferences` + +Get company accounting, sales, purchasing, tax, and currency preferences + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | QuickBooks company preferences | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_get_record` + +Get a QuickBooks Online accounting record by entity and ID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entity` | string | Yes | Readable QuickBooks entity name | +| `recordId` | string | Yes | QuickBooks record ID | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Entity-specific QuickBooks record | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_list_bills` + +List bills from QuickBooks Online + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `startPosition` | string | No | One-based start position for QuickBooks query pagination | +| `maxResults` | string | No | Maximum number of bills to return, up to 1000 | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Bills returned by QuickBooks | +| ↳ `Id` | string | QuickBooks bill ID | +| ↳ `DocNumber` | string | Bill document number | +| ↳ `TxnDate` | string | Bill transaction date | +| ↳ `DueDate` | string | Bill due date | +| ↳ `TotalAmt` | number | Bill total amount | +| ↳ `Balance` | number | Open bill balance | +| `entity` | string | QuickBooks entity name | +| `totalCount` | number | Total count returned by QuickBooks | +| `startPosition` | number | Start position returned by QuickBooks | +| `maxResults` | number | Maximum records returned by QuickBooks | +| `query` | string | Query that was executed | + +### `quickbooks_list_purchase_orders` + +List purchase orders from QuickBooks Online + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `startPosition` | string | No | One-based start position for QuickBooks query pagination | +| `maxResults` | string | No | Maximum number of purchase orders to return, up to 1000 | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Purchase orders returned by QuickBooks | +| ↳ `Id` | string | QuickBooks purchase order ID | +| ↳ `DocNumber` | string | Purchase order document number | +| ↳ `TxnDate` | string | Purchase order transaction date | +| ↳ `TotalAmt` | number | Purchase order total amount | +| `entity` | string | QuickBooks entity name | +| `totalCount` | number | Total count returned by QuickBooks | +| `startPosition` | number | Start position returned by QuickBooks | +| `maxResults` | number | Maximum records returned by QuickBooks | +| `query` | string | Query that was executed | + +### `quickbooks_list_records` + +List or filter records for a supported QuickBooks Online accounting entity + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entity` | string | Yes | Queryable QuickBooks entity name | +| `whereClause` | string | No | QuickBooks query WHERE clause without the WHERE keyword | +| `orderBy` | string | No | QuickBooks query ORDERBY clause without the ORDERBY keyword | +| `startPosition` | string | No | One-based start position for QuickBooks query pagination | +| `maxResults` | string | No | Maximum number of records to return, up to 1000 | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | QuickBooks records for the selected entity | +| `entity` | string | QuickBooks entity name | +| `totalCount` | number | Total count returned by QuickBooks | +| `startPosition` | number | Start position returned by QuickBooks | +| `maxResults` | number | Maximum records returned by QuickBooks | +| `query` | string | Query that was executed | + +### `quickbooks_list_vendors` + +List vendors from QuickBooks Online + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `activeOnly` | boolean | No | Only return active vendors | +| `startPosition` | string | No | One-based start position for QuickBooks query pagination | +| `maxResults` | string | No | Maximum number of vendors to return, up to 1000 | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Vendors returned by QuickBooks | +| ↳ `Id` | string | QuickBooks vendor ID | +| ↳ `DisplayName` | string | Vendor display name | +| ↳ `Active` | boolean | Whether the vendor is active | +| `entity` | string | QuickBooks entity name | +| `totalCount` | number | Total count returned by QuickBooks | +| `startPosition` | number | Start position returned by QuickBooks | +| `maxResults` | number | Maximum records returned by QuickBooks | +| `query` | string | Query that was executed | + +### `quickbooks_query` + +Run a QuickBooks Online Accounting API query + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `query` | string | Yes | QuickBooks SQL-like query, e.g. SELECT * FROM Vendor MAXRESULTS 10 | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `items` | array | Records returned by the QuickBooks query | +| `entity` | string | QuickBooks entity name returned by the query | +| `totalCount` | number | Total count returned by QuickBooks for the query | +| `startPosition` | number | Start position returned by QuickBooks | +| `maxResults` | number | Maximum records returned by QuickBooks | +| `query` | string | Query that was executed | + +### `quickbooks_run_report` + +Run a supported QuickBooks Online financial or accounting report + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `report` | string | Yes | QuickBooks Reports API endpoint name | +| `reportParams` | json | No | Report query parameters such as start_date, end_date, and accounting_method | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `report` | string | QuickBooks report endpoint name | +| `header` | json | Report header metadata, including period, basis, and currency when returned | +| `columns` | json | QuickBooks report column definitions | +| `rows` | json | QuickBooks report rows, including nested sections and summaries | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_send_document` + +Email a supported QuickBooks transaction and return its rendered PDF + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entity` | string | Yes | QuickBooks transaction type that supports email delivery | +| `recordId` | string | Yes | QuickBooks transaction ID | +| `sendTo` | string | No | Recipient email address; defaults to the address stored on the transaction | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `file` | file | QuickBooks PDF returned after email delivery | +| `entity` | string | QuickBooks transaction type | +| `recordId` | string | QuickBooks transaction ID | + +### `quickbooks_update_exchange_rate` + +Create or update a dated QuickBooks exchange rate + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `payload` | json | Yes | ExchangeRate payload with SourceCurrencyCode, TargetCurrencyCode, Rate, AsOfDate, and SyncToken | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Updated QuickBooks exchange rate | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_update_preferences` + +Update supported QuickBooks company accounting and workflow preferences + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `payload` | json | Yes | QuickBooks Preferences payload containing the supported settings to update | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Updated QuickBooks company preferences | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_update_record` + +Update a supported QuickBooks Online accounting record + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `entity` | string | Yes | Updatable QuickBooks entity name | +| `recordId` | string | Yes | QuickBooks record ID | +| `syncToken` | string | Yes | Latest QuickBooks SyncToken for the record | +| `payload` | json | Yes | Fields to update using the selected entity API schema | +| `sparse` | boolean | No | Use a sparse update so omitted fields are preserved. Defaults to true. | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `record` | json | Updated entity-specific QuickBooks record | +| `entity` | string | QuickBooks entity name | +| `time` | string | QuickBooks response timestamp | + +### `quickbooks_upload_attachment` + +Upload and link a supported file up to 25 MB to a QuickBooks transaction or item + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `realmId` | string | Yes | QuickBooks company ID returned by Intuit as realmId during OAuth | +| `file` | file | Yes | Supported attachment file up to 25 MB to upload and link in QuickBooks | +| `entity` | string | Yes | QuickBooks entity type to link the attachment to | +| `entityId` | string | Yes | QuickBooks entity ID to link the attachment to | +| `note` | string | No | Optional note stored with the attachment | +| `includeOnSend` | boolean | No | Include the attachment when QuickBooks sends the linked transaction | +| `apiEnvironment` | string | No | QuickBooks API environment: production or sandbox. Defaults to production. | +| `minorVersion` | string | No | QuickBooks Accounting API minor version. Defaults to 75. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `result` | json | QuickBooks attachment upload response | diff --git a/apps/sim/.env.example b/apps/sim/.env.example index ef123188499..27dc6492d07 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -112,6 +112,10 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # TIKTOK_CLIENT_ID= # TIKTOK_CLIENT_SECRET= +# QuickBooks OAuth (Optional - Client ID/Secret from the Intuit Developer app) +# QUICKBOOKS_CLIENT_ID= +# QUICKBOOKS_CLIENT_SECRET= + # Azure Blob Storage takes precedence over S3 if both are configured # AZURE_ACCOUNT_NAME= # Azure storage account name # AZURE_ACCOUNT_KEY= # Azure storage account key diff --git a/apps/sim/app/api/tools/quickbooks/upload-attachment/route.test.ts b/apps/sim/app/api/tools/quickbooks/upload-attachment/route.test.ts new file mode 100644 index 00000000000..35eef79c948 --- /dev/null +++ b/apps/sim/app/api/tools/quickbooks/upload-attachment/route.test.ts @@ -0,0 +1,313 @@ +/** + * @vitest-environment node + */ +import { createMockRequest, hybridAuthMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockProcessFilesToUserFiles, mockDownloadFileFromStorage, mockAssertToolFileAccess } = + vi.hoisted(() => ({ + mockProcessFilesToUserFiles: vi.fn(), + mockDownloadFileFromStorage: vi.fn(), + mockAssertToolFileAccess: vi.fn(), + })) + +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + processFilesToUserFiles: mockProcessFilesToUserFiles, +})) +vi.mock('@/lib/uploads/utils/file-utils.server', () => ({ + downloadServableFileFromStorage: mockDownloadFileFromStorage, +})) +vi.mock('@/app/api/files/authorization', () => ({ + assertToolFileAccess: mockAssertToolFileAccess, +})) + +import { POST } from '@/app/api/tools/quickbooks/upload-attachment/route' + +const mockFetch = vi.fn() +const baseBody = { + accessToken: 'quickbooks-token', + realmId: '123145', + file: { + key: 'uploads/receipt.pdf', + name: 'receipt.pdf', + size: 13, + type: 'application/pdf', + }, + entity: 'Bill', + entityId: '17', + note: 'Vendor receipt', + includeOnSend: true, +} + +beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('fetch', mockFetch) + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-1', + authType: 'internal_jwt', + }) + mockProcessFilesToUserFiles.mockReturnValue([baseBody.file]) + mockAssertToolFileAccess.mockResolvedValue(null) + mockDownloadFileFromStorage.mockResolvedValue({ + buffer: Buffer.from('receipt-bytes'), + contentType: 'application/pdf', + }) + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + AttachableResponse: [{ Attachable: { Id: '100', FileName: 'receipt.pdf' } }], + }), + { headers: { 'content-type': 'application/json' }, status: 200 } + ) + ) +}) + +describe('POST /api/tools/quickbooks/upload-attachment', () => { + it('rejects unauthenticated requests before resolving the file', async () => { + hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValueOnce({ + success: false, + error: 'Unauthorized', + }) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(401) + expect(mockProcessFilesToUserFiles).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects header-breaking access tokens at the API boundary', async () => { + const response = await POST( + createMockRequest('POST', { + ...baseBody, + accessToken: 'token\r\nX-Injected: true', + }) + ) + + expect(response.status).toBe(400) + expect(mockProcessFilesToUserFiles).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects whitespace-only access tokens at the API boundary', async () => { + const response = await POST( + createMockRequest('POST', { + ...baseBody, + accessToken: ' ', + }) + ) + + expect(response.status).toBe(400) + expect(mockProcessFilesToUserFiles).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('uploads an authorized file using QuickBooks multipart field names', async () => { + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + output: { + result: { + AttachableResponse: [{ Attachable: { Id: '100', FileName: 'receipt.pdf' } }], + }, + }, + }) + + expect(mockAssertToolFileAccess).toHaveBeenCalledWith( + 'uploads/receipt.pdf', + 'user-1', + expect.any(String), + expect.anything() + ) + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://quickbooks.api.intuit.com/v3/company/123145/upload?minorversion=75') + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ + Authorization: 'Bearer quickbooks-token', + Accept: 'application/json', + }) + + const formData = init.body as FormData + expect(formData.get('file_content_01')).toBeInstanceOf(Blob) + const metadata = formData.get('file_metadata_01') + expect(metadata).toBeInstanceOf(Blob) + await expect((metadata as Blob).text()).resolves.toBe( + JSON.stringify({ + AttachableRef: [ + { + EntityRef: { type: 'Bill', value: '17' }, + IncludeOnSend: true, + }, + ], + FileName: 'receipt.pdf', + ContentType: 'application/pdf', + Note: 'Vendor receipt', + }) + ) + }) + + it('defaults a blank minor version instead of rejecting the upload', async () => { + const response = await POST( + createMockRequest('POST', { + ...baseBody, + minorVersion: ' ', + }) + ) + + expect(response.status).toBe(200) + expect(mockFetch).toHaveBeenCalledWith( + 'https://quickbooks.api.intuit.com/v3/company/123145/upload?minorversion=75', + expect.any(Object) + ) + }) + + it('normalizes linked entity names before building attachment metadata', async () => { + const response = await POST( + createMockRequest('POST', { + ...baseBody, + entity: 'purchaseorder', + }) + ) + expect(response.status).toBe(200) + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit] + const metadata = (init.body as FormData).get('file_metadata_01') + expect(metadata).toBeInstanceOf(Blob) + await expect((metadata as Blob).text()).resolves.toContain( + '"EntityRef":{"type":"PurchaseOrder","value":"17"}' + ) + }) + + it('rejects unsupported linked entity types before downloading the file', async () => { + const response = await POST( + createMockRequest('POST', { + ...baseBody, + entity: 'CompanyInfo', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'QuickBooks entity "CompanyInfo" cannot be linked to an attachment', + }) + expect(mockDownloadFileFromStorage).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects file types outside the QuickBooks attachment whitelist', async () => { + mockProcessFilesToUserFiles.mockReturnValueOnce([ + { + ...baseBody.file, + key: 'uploads/archive.zip', + name: 'archive.zip', + type: 'application/zip', + }, + ]) + + const response = await POST(createMockRequest('POST', baseBody)) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'QuickBooks does not support .zip attachment files', + }) + expect(mockDownloadFileFromStorage).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects resolved content types that do not match the file extension', async () => { + mockDownloadFileFromStorage.mockResolvedValueOnce({ + buffer: Buffer.from('not-a-pdf'), + contentType: 'text/plain', + }) + + const response = await POST(createMockRequest('POST', baseBody)) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'QuickBooks does not support text/plain content for .pdf attachments', + }) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('rejects nested QuickBooks upload faults returned with HTTP 200', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + AttachableResponse: [ + { + Fault: { + Error: [{ Message: 'ValidationFault', Detail: 'Unsupported entity reference' }], + }, + }, + ], + time: '2026-07-29T23:00:00Z', + }), + { headers: { 'content-type': 'application/json' }, status: 200 } + ) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'QuickBooks API error (200): Unsupported entity reference', + }) + }) + + it('rejects timestamp-only QuickBooks upload responses', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ time: '2026-07-29T23:00:00Z' }), { + headers: { 'content-type': 'application/json' }, + status: 200, + }) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'QuickBooks attachment upload returned no attachment', + }) + }) + + it('rejects QuickBooks upload responses with an empty attachment object', async () => { + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + AttachableResponse: [{ Attachable: {} }], + time: '2026-07-29T23:00:00Z', + }), + { + headers: { 'content-type': 'application/json' }, + status: 200, + } + ) + ) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + success: false, + error: 'QuickBooks attachment upload returned no attachment', + }) + }) + + it('rejects files over the buffered 25 MB attachment limit', async () => { + mockProcessFilesToUserFiles.mockReturnValueOnce([ + { ...baseBody.file, size: 25 * 1024 * 1024 + 1 }, + ]) + + const response = await POST(createMockRequest('POST', baseBody)) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ + success: false, + error: expect.stringContaining('25MB'), + }) + expect(mockFetch).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/tools/quickbooks/upload-attachment/route.ts b/apps/sim/app/api/tools/quickbooks/upload-attachment/route.ts new file mode 100644 index 00000000000..b657bbdc598 --- /dev/null +++ b/apps/sim/app/api/tools/quickbooks/upload-attachment/route.ts @@ -0,0 +1,178 @@ +import { extname } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { quickBooksUploadAttachmentContract } from '@/lib/api/contracts/tools/quickbooks' +import { parseRequest } from '@/lib/api/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { processFilesToUserFiles, type RawFileInput } from '@/lib/uploads/utils/file-utils' +import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' +import { assertToolFileAccess } from '@/app/api/files/authorization' +import { + assertQuickBooksAttachmentUploadResponse, + buildQuickBooksHeaders, + buildQuickBooksUploadUrl, + normalizeQuickBooksAttachmentEntity, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('QuickBooksUploadAttachmentAPI') +const QUICKBOOKS_MAX_UPLOAD_BYTES = 25 * 1024 * 1024 +const QUICKBOOKS_ATTACHMENT_CONTENT_TYPES: Record = { + ai: ['application/postscript'], + csv: ['text/csv'], + doc: ['application/msword'], + docx: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + eps: ['application/postscript'], + gif: ['image/gif'], + jpeg: ['image/jpeg'], + jpg: ['image/jpeg', 'image/jpg'], + ods: ['application/vnd.oasis.opendocument.spreadsheet'], + pdf: ['application/pdf'], + png: ['image/png'], + rtf: ['application/rtf', 'text/rtf'], + tif: ['image/tiff'], + txt: ['text/plain'], + xls: ['application/vnd.ms-excel'], + xlsx: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + xml: ['application/xml', 'text/xml'], +} + +function uploadSizeError(bytes: number): NextResponse { + const sizeMb = (bytes / (1024 * 1024)).toFixed(2) + return NextResponse.json( + { + success: false, + error: `File size (${sizeMb}MB) exceeds Sim's QuickBooks attachment limit of 25MB`, + }, + { status: 400 } + ) +} + +function attachmentValidationError(error: unknown): NextResponse { + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Invalid QuickBooks attachment') }, + { status: 400 } + ) +} + +function validateQuickBooksAttachmentFile(fileName: string, contentType?: string): void { + const extension = extname(fileName).slice(1).toLowerCase() + const allowedContentTypes = QUICKBOOKS_ATTACHMENT_CONTENT_TYPES[extension] + if (!allowedContentTypes) { + throw new Error(`QuickBooks does not support .${extension || 'unknown'} attachment files`) + } + + const normalizedContentType = contentType?.split(';', 1)[0]?.trim().toLowerCase() + if (normalizedContentType && !allowedContentTypes.includes(normalizedContentType)) { + throw new Error( + `QuickBooks does not support ${normalizedContentType} content for .${extension} attachments` + ) + } +} + +export const POST = withRouteHandler(async (request: NextRequest) => { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + return NextResponse.json( + { success: false, error: authResult.error || 'Authentication required' }, + { status: 401 } + ) + } + + const parsed = await parseRequest(quickBooksUploadAttachmentContract, request, {}) + if (!parsed.success) return parsed.response + const params = parsed.data.body + const requestId = generateRequestId() + const userFiles = processFilesToUserFiles([params.file as RawFileInput], requestId, logger) + if (userFiles.length === 0) { + return NextResponse.json({ success: false, error: 'Invalid file input' }, { status: 400 }) + } + + const userFile = userFiles[0] + const denied = await assertToolFileAccess(userFile.key, authResult.userId, requestId, logger) + if (denied) return denied + if (userFile.size > QUICKBOOKS_MAX_UPLOAD_BYTES) return uploadSizeError(userFile.size) + let entity: ReturnType + try { + entity = normalizeQuickBooksAttachmentEntity(params.entity) + validateQuickBooksAttachmentFile(userFile.name, userFile.type) + } catch (error) { + return attachmentValidationError(error) + } + + try { + const downloaded = await downloadServableFileFromStorage(userFile, requestId, logger, { + maxBytes: QUICKBOOKS_MAX_UPLOAD_BYTES, + }) + if (downloaded.buffer.length > QUICKBOOKS_MAX_UPLOAD_BYTES) { + return uploadSizeError(downloaded.buffer.length) + } + + const contentType = downloaded.contentType || userFile.type || 'application/octet-stream' + try { + validateQuickBooksAttachmentFile(userFile.name, contentType) + } catch (error) { + return attachmentValidationError(error) + } + const metadata = { + AttachableRef: [ + { + EntityRef: { + type: entity, + value: params.entityId, + }, + IncludeOnSend: params.includeOnSend ?? false, + }, + ], + FileName: userFile.name, + ContentType: contentType, + ...(params.note ? { Note: params.note } : {}), + } + const formData = new FormData() + formData.append( + 'file_content_01', + new Blob([new Uint8Array(downloaded.buffer)], { type: contentType }), + userFile.name + ) + formData.append( + 'file_metadata_01', + new Blob([JSON.stringify(metadata)], { type: 'application/json' }) + ) + + const response = await fetch( + buildQuickBooksUploadUrl({ + realmId: params.realmId, + apiEnvironment: params.apiEnvironment, + minorVersion: params.minorVersion, + }), + { + method: 'POST', + headers: { + Authorization: buildQuickBooksHeaders(params.accessToken).Authorization, + Accept: 'application/json', + }, + body: formData, + } + ) + const data = assertQuickBooksAttachmentUploadResponse(await parseQuickBooksJson(response)) + return NextResponse.json({ success: true, output: { result: data } }) + } catch (error) { + const notReady = docNotReadyResponse(error) + if (notReady) return notReady + if (error instanceof PayloadSizeLimitError) { + return uploadSizeError(error.observedBytes ?? userFile.size) + } + logger.error('QuickBooks attachment upload failed', error) + return NextResponse.json( + { success: false, error: getErrorMessage(error, 'Failed to upload QuickBooks attachment') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/blocks/blocks/quickbooks.test.ts b/apps/sim/blocks/blocks/quickbooks.test.ts new file mode 100644 index 00000000000..63909265c34 --- /dev/null +++ b/apps/sim/blocks/blocks/quickbooks.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { QuickBooksBlock } from '@/blocks/blocks/quickbooks' + +describe('QuickBooksBlock', () => { + const buildParams = QuickBooksBlock.tools.config.params! + const selectTool = QuickBooksBlock.tools.config.tool! + + it('routes every operation to its registered QuickBooks tool', () => { + for (const operation of [ + 'list_records', + 'get_record', + 'create_record', + 'update_record', + 'delete_record', + 'run_report', + 'get_preferences', + 'update_preferences', + 'get_exchange_rate', + 'update_exchange_rate', + 'download_document', + 'send_document', + 'upload_attachment', + 'get_attachment_url', + 'get_changes', + 'batch', + 'query', + 'list_vendors', + 'list_purchase_orders', + 'list_bills', + ]) { + expect(selectTool({ operation })).toBe(`quickbooks_${operation}`) + expect(QuickBooksBlock.tools.access).toContain(`quickbooks_${operation}`) + } + }) + + it('maps operation-specific entity inputs without coercing dynamic values', () => { + expect( + buildParams({ + operation: 'list_records', + oauthCredential: 'credential', + realmId: '', + listEntity: 'InventoryAdjustment', + maxResults: '', + }) + ).toMatchObject({ + credential: 'credential', + realmId: '', + entity: 'InventoryAdjustment', + maxResults: '', + }) + + expect( + buildParams({ + operation: 'send_document', + pdfEntity: 'PurchaseOrder', + recordId: '42', + sendTo: 'purchasing@example.com', + }) + ).toMatchObject({ + entity: 'PurchaseOrder', + recordId: '42', + sendTo: 'purchasing@example.com', + }) + }) + + it('normalizes a single attachment file after variable resolution', () => { + const file = { + name: 'receipt.pdf', + path: 'workspace/receipt.pdf', + mimeType: 'application/pdf', + } + expect( + buildParams({ + operation: 'upload_attachment', + attachmentEntity: 'Bill', + attachmentEntityId: '17', + file: JSON.stringify(file), + }) + ).toMatchObject({ + entity: 'Bill', + entityId: '17', + file, + }) + + const attachmentInputs = QuickBooksBlock.subBlocks.filter( + (subBlock) => subBlock.canonicalParamId === 'file' + ) + expect(attachmentInputs).toMatchObject([ + { id: 'attachmentFile', mode: 'basic', required: expect.anything() }, + { id: 'attachmentFileRef', mode: 'advanced', required: expect.anything() }, + ]) + }) + + it('requires full entity payloads for non-simplified deletes', () => { + const payload = QuickBooksBlock.subBlocks.find((subBlock) => subBlock.id === 'payload') + if (!payload || typeof payload.required !== 'function') { + throw new Error('Expected QuickBooks payload to use conditional required state') + } + + expect( + payload.required({ + operation: 'delete_record', + deleteEntity: 'InventoryAdjustment', + }) + ).toEqual({ + field: 'deleteEntity', + value: [ + 'Attachable', + 'CreditCardPayment', + 'Deposit', + 'InventoryAdjustment', + 'RecurringTransaction', + 'Transfer', + ], + }) + expect( + payload.required({ + operation: 'create_record', + createEntity: 'Vendor', + }) + ).toEqual({ + field: 'operation', + value: ['create_record', 'update_record', 'update_exchange_rate', 'update_preferences'], + }) + }) + + it('exposes only documented operations for newer and locale-specific entities', () => { + const optionsFor = (id: string) => { + const subBlock = QuickBooksBlock.subBlocks.find((candidate) => candidate.id === id) + if (!subBlock || !('options' in subBlock) || !Array.isArray(subBlock.options)) { + throw new Error(`Expected ${id} entity options`) + } + return subBlock.options.map((option) => option.id) + } + + expect(optionsFor('listEntity')).toEqual( + expect.arrayContaining([ + 'CreditCardPayment', + 'TaxPayment', + 'RecurringTransaction', + 'JournalCode', + ]) + ) + expect(optionsFor('getEntity')).toEqual( + expect.arrayContaining([ + 'CreditCardPayment', + 'TaxPayment', + 'RecurringTransaction', + 'JournalCode', + ]) + ) + expect(optionsFor('createEntity')).toEqual( + expect.arrayContaining(['CreditCardPayment', 'RecurringTransaction', 'JournalCode']) + ) + expect(optionsFor('createEntity')).not.toContain('TaxPayment') + expect(optionsFor('updateEntity')).toContain('CreditCardPayment') + expect(optionsFor('updateEntity')).toContain('JournalCode') + expect(optionsFor('updateEntity')).not.toContain('RecurringTransaction') + expect(optionsFor('updateEntity')).not.toContain('TaxPayment') + expect(optionsFor('deleteEntity')).toContain('CreditCardPayment') + expect(optionsFor('deleteEntity')).toContain('RecurringTransaction') + expect(optionsFor('deleteEntity')).not.toContain('JournalCode') + expect(optionsFor('deleteEntity')).not.toContain('TaxPayment') + }) +}) diff --git a/apps/sim/blocks/blocks/quickbooks.ts b/apps/sim/blocks/blocks/quickbooks.ts new file mode 100644 index 00000000000..a01122d742b --- /dev/null +++ b/apps/sim/blocks/blocks/quickbooks.ts @@ -0,0 +1,810 @@ +import { QuickBooksIcon } from '@/components/icons' +import { getScopesForService } from '@/lib/oauth/utils' +import type { BlockConfig, BlockMeta } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { normalizeFileInput } from '@/blocks/utils' +import type { QuickBooksResponse } from '@/tools/quickbooks/types' +import { + QUICKBOOKS_ATTACHMENT_ENTITIES, + QUICKBOOKS_CREATABLE_ENTITIES, + QUICKBOOKS_DELETABLE_ENTITIES, + QUICKBOOKS_FULL_DELETE_ENTITIES, + QUICKBOOKS_PDF_ENTITIES, + QUICKBOOKS_QUERYABLE_ENTITIES, + QUICKBOOKS_READABLE_ENTITIES, + QUICKBOOKS_REPORTS, + QUICKBOOKS_UPDATABLE_ENTITIES, +} from '@/tools/quickbooks/types' + +const LIST_OPERATIONS = ['list_records', 'list_vendors', 'list_purchase_orders', 'list_bills'] +const RECORD_ID_OPERATIONS = [ + 'get_record', + 'update_record', + 'delete_record', + 'download_document', + 'send_document', +] +const SYNC_TOKEN_OPERATIONS = ['update_record', 'delete_record'] + +const formatQuickBooksName = (value: string) => value.replace(/([a-z])([A-Z])/g, '$1 $2') +const buildEntityOptions = (entities: readonly string[]) => + entities.map((entity) => ({ label: formatQuickBooksName(entity), id: entity })) +const REPORT_LABELS: Record = { + AccountListDetail: 'Account List Detail', + AgedPayableDetail: 'A/P Aging Detail', + AgedPayables: 'A/P Aging Summary', + AgedReceivableDetail: 'A/R Aging Detail', + AgedReceivables: 'A/R Aging Summary', + BalanceSheet: 'Balance Sheet', + CashFlow: 'Statement of Cash Flows', + ClassSales: 'Sales by Class', + CustomerBalance: 'Customer Balance Summary', + CustomerBalanceDetail: 'Customer Balance Detail', + CustomerIncome: 'Income by Customer', + CustomerSales: 'Sales by Customer', + DepartmentSales: 'Sales by Department', + GeneralLedgerDetail: 'General Ledger', + InventoryValuationDetail: 'Inventory Valuation Detail', + InventoryValuationSummary: 'Inventory Valuation Summary', + ItemSales: 'Sales by Product/Service', + ProfitAndLoss: 'Profit and Loss', + ProfitAndLossDetail: 'Profit and Loss Detail', + TaxSummary: 'Tax Summary', + TrialBalance: 'Trial Balance', + VendorBalance: 'Vendor Balance Summary', + VendorBalanceDetail: 'Vendor Balance Detail', + VendorExpenses: 'Expenses by Vendor', +} + +export const QuickBooksBlock: BlockConfig = { + type: 'quickbooks', + name: 'QuickBooks', + description: 'Manage and report on QuickBooks Online accounting data', + authMode: AuthMode.OAuth, + longDescription: + 'Integrate QuickBooks Online into procurement and accounting workflows. Manage supported records, inventory adjustments, preferences, currencies, reports, attachments, PDFs, change-data-capture syncs, batches, and custom queries.', + docsLink: 'https://docs.sim.ai/integrations/quickbooks', + category: 'tools', + integrationType: IntegrationType.Commerce, + bgColor: '#2CA01C', + icon: QuickBooksIcon, + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'List Records', id: 'list_records' }, + { label: 'Get Record', id: 'get_record' }, + { label: 'Create Record', id: 'create_record' }, + { label: 'Update Record', id: 'update_record' }, + { label: 'Delete Record', id: 'delete_record' }, + { label: 'Run Report', id: 'run_report' }, + { label: 'Get Preferences', id: 'get_preferences' }, + { label: 'Update Preferences', id: 'update_preferences' }, + { label: 'Get Exchange Rate', id: 'get_exchange_rate' }, + { label: 'Update Exchange Rate', id: 'update_exchange_rate' }, + { label: 'Download Document PDF', id: 'download_document' }, + { label: 'Send Document', id: 'send_document' }, + { label: 'Upload Attachment', id: 'upload_attachment' }, + { label: 'Get Attachment URL', id: 'get_attachment_url' }, + { label: 'Get Changes', id: 'get_changes' }, + { label: 'Run Batch', id: 'batch' }, + { label: 'Run Query', id: 'query' }, + { label: 'List Vendors', id: 'list_vendors' }, + { label: 'List Purchase Orders', id: 'list_purchase_orders' }, + { label: 'List Bills', id: 'list_bills' }, + ], + value: () => 'list_records', + }, + { + id: 'credential', + title: 'QuickBooks Account', + type: 'oauth-input', + serviceId: 'quickbooks', + canonicalParamId: 'oauthCredential', + mode: 'basic', + requiredScopes: getScopesForService('quickbooks'), + placeholder: 'Select QuickBooks account', + required: true, + }, + { + id: 'manualCredential', + title: 'QuickBooks Account', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + placeholder: 'Enter credential ID', + required: true, + }, + { + id: 'realmId', + title: 'Company ID', + type: 'short-input', + placeholder: 'Company ID returned by Intuit as realmId', + required: true, + }, + { + id: 'listEntity', + title: 'Entity', + type: 'dropdown', + options: buildEntityOptions(QUICKBOOKS_QUERYABLE_ENTITIES), + value: () => 'Vendor', + condition: { field: 'operation', value: 'list_records' }, + required: { field: 'operation', value: 'list_records' }, + }, + { + id: 'getEntity', + title: 'Entity', + type: 'dropdown', + options: buildEntityOptions(QUICKBOOKS_READABLE_ENTITIES), + value: () => 'Vendor', + condition: { field: 'operation', value: 'get_record' }, + required: { field: 'operation', value: 'get_record' }, + }, + { + id: 'createEntity', + title: 'Entity', + type: 'dropdown', + options: buildEntityOptions(QUICKBOOKS_CREATABLE_ENTITIES), + value: () => 'Vendor', + condition: { field: 'operation', value: 'create_record' }, + required: { field: 'operation', value: 'create_record' }, + }, + { + id: 'updateEntity', + title: 'Entity', + type: 'dropdown', + options: buildEntityOptions(QUICKBOOKS_UPDATABLE_ENTITIES), + value: () => 'Vendor', + condition: { field: 'operation', value: 'update_record' }, + required: { field: 'operation', value: 'update_record' }, + }, + { + id: 'deleteEntity', + title: 'Entity', + type: 'dropdown', + options: buildEntityOptions(QUICKBOOKS_DELETABLE_ENTITIES), + value: () => 'Bill', + condition: { field: 'operation', value: 'delete_record' }, + required: { field: 'operation', value: 'delete_record' }, + }, + { + id: 'recordId', + title: 'Record ID', + type: 'short-input', + placeholder: 'QuickBooks record ID', + condition: { field: 'operation', value: RECORD_ID_OPERATIONS }, + required: { field: 'operation', value: RECORD_ID_OPERATIONS }, + }, + { + id: 'syncToken', + title: 'Sync Token', + type: 'short-input', + placeholder: 'Latest SyncToken from QuickBooks', + condition: { field: 'operation', value: SYNC_TOKEN_OPERATIONS }, + required: { field: 'operation', value: SYNC_TOKEN_OPERATIONS }, + }, + { + id: 'payload', + title: 'Record Payload', + type: 'code', + language: 'json', + placeholder: '{\n "DisplayName": "Acme Supplies"\n}', + condition: { + field: 'operation', + value: [ + 'create_record', + 'update_record', + 'delete_record', + 'update_exchange_rate', + 'update_preferences', + ], + }, + required: (values) => + values?.operation === 'delete_record' + ? { field: 'deleteEntity', value: [...QUICKBOOKS_FULL_DELETE_ENTITIES] } + : { + field: 'operation', + value: [ + 'create_record', + 'update_record', + 'update_exchange_rate', + 'update_preferences', + ], + }, + wandConfig: { + enabled: true, + prompt: + 'Generate a QuickBooks Online Accounting API entity payload for the selected operation and entity. Return ONLY the JSON object - no explanations, no extra text.', + placeholder: 'Describe the QuickBooks payload...', + generationType: 'json-object', + }, + }, + { + id: 'pdfEntity', + title: 'Transaction Type', + type: 'dropdown', + options: buildEntityOptions(QUICKBOOKS_PDF_ENTITIES), + value: () => 'PurchaseOrder', + condition: { field: 'operation', value: ['download_document', 'send_document'] }, + required: { field: 'operation', value: ['download_document', 'send_document'] }, + }, + { + id: 'sendTo', + title: 'Recipient Email', + type: 'short-input', + placeholder: 'Defaults to the email stored on the transaction', + condition: { field: 'operation', value: 'send_document' }, + mode: 'advanced', + }, + { + id: 'sourceCurrencyCode', + title: 'Source Currency', + type: 'short-input', + placeholder: 'EUR', + condition: { field: 'operation', value: 'get_exchange_rate' }, + required: { field: 'operation', value: 'get_exchange_rate' }, + }, + { + id: 'asOfDate', + title: 'As Of Date', + type: 'short-input', + placeholder: '2026-01-31', + condition: { field: 'operation', value: 'get_exchange_rate' }, + mode: 'advanced', + }, + { + id: 'attachmentFile', + title: 'File', + type: 'file-upload', + canonicalParamId: 'file', + mode: 'basic', + condition: { field: 'operation', value: 'upload_attachment' }, + required: { field: 'operation', value: 'upload_attachment' }, + }, + { + id: 'attachmentFileRef', + title: 'File', + type: 'short-input', + canonicalParamId: 'file', + mode: 'advanced', + placeholder: 'Reference a file from a previous block', + condition: { field: 'operation', value: 'upload_attachment' }, + required: { field: 'operation', value: 'upload_attachment' }, + }, + { + id: 'attachmentEntity', + title: 'Linked Entity Type', + type: 'dropdown', + options: buildEntityOptions(QUICKBOOKS_ATTACHMENT_ENTITIES), + value: () => 'PurchaseOrder', + condition: { field: 'operation', value: 'upload_attachment' }, + required: { field: 'operation', value: 'upload_attachment' }, + }, + { + id: 'attachmentEntityId', + title: 'Linked Entity ID', + type: 'short-input', + placeholder: 'QuickBooks transaction or list entity ID', + condition: { field: 'operation', value: 'upload_attachment' }, + required: { field: 'operation', value: 'upload_attachment' }, + }, + { + id: 'attachmentNote', + title: 'Note', + type: 'long-input', + placeholder: 'Receipt or supporting document details', + condition: { field: 'operation', value: 'upload_attachment' }, + mode: 'advanced', + }, + { + id: 'includeOnSend', + title: 'Include When Sending', + type: 'switch', + condition: { field: 'operation', value: 'upload_attachment' }, + mode: 'advanced', + }, + { + id: 'attachmentId', + title: 'Attachment ID', + type: 'short-input', + placeholder: 'QuickBooks Attachable ID', + condition: { field: 'operation', value: 'get_attachment_url' }, + required: { field: 'operation', value: 'get_attachment_url' }, + }, + { + id: 'thumbnail', + title: 'Thumbnail URL', + type: 'switch', + condition: { field: 'operation', value: 'get_attachment_url' }, + mode: 'advanced', + }, + { + id: 'sparse', + title: 'Sparse Update', + type: 'switch', + value: () => 'true', + condition: { + field: 'operation', + value: 'update_record', + and: { field: 'updateEntity', value: 'InventoryAdjustment', not: true }, + }, + mode: 'advanced', + }, + { + id: 'activeOnly', + title: 'Active Vendors Only', + type: 'switch', + condition: { field: 'operation', value: 'list_vendors' }, + }, + { + id: 'whereClause', + title: 'Where Clause', + type: 'long-input', + placeholder: "Active = true AND MetaData.LastUpdatedTime >= '2026-01-01'", + condition: { field: 'operation', value: 'list_records' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a QuickBooks Online query WHERE clause without the WHERE keyword. QuickBooks supports AND but not OR. Return ONLY the clause - no explanations, no extra text.', + placeholder: 'Describe which QuickBooks records to include...', + }, + }, + { + id: 'orderBy', + title: 'Order By', + type: 'short-input', + placeholder: 'MetaData.LastUpdatedTime DESC', + condition: { field: 'operation', value: 'list_records' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate a QuickBooks Online query ORDERBY clause without the ORDERBY keyword. Return ONLY the clause - no explanations, no extra text.', + placeholder: 'Describe how the QuickBooks records should be sorted...', + }, + }, + { + id: 'startPosition', + title: 'Start Position', + type: 'short-input', + placeholder: '1', + mode: 'advanced', + condition: { field: 'operation', value: LIST_OPERATIONS }, + }, + { + id: 'maxResults', + title: 'Max Results', + type: 'short-input', + placeholder: '1000 or less', + mode: 'advanced', + condition: { field: 'operation', value: LIST_OPERATIONS }, + }, + { + id: 'query', + title: 'Query', + type: 'long-input', + placeholder: 'SELECT * FROM Vendor MAXRESULTS 10', + condition: { field: 'operation', value: 'query' }, + required: { field: 'operation', value: 'query' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a QuickBooks Online Accounting API SQL-like query. Return ONLY the query text - no explanations, no extra text.', + placeholder: 'Describe what QuickBooks records to query...', + }, + }, + { + id: 'report', + title: 'Report', + type: 'dropdown', + options: QUICKBOOKS_REPORTS.map((report) => ({ + label: REPORT_LABELS[report] ?? formatQuickBooksName(report), + id: report, + })), + value: () => 'AgedPayables', + condition: { field: 'operation', value: 'run_report' }, + required: { field: 'operation', value: 'run_report' }, + }, + { + id: 'reportParams', + title: 'Report Parameters', + type: 'code', + language: 'json', + placeholder: + '{\n "start_date": "2026-01-01",\n "end_date": "2026-01-31",\n "accounting_method": "Accrual"\n}', + condition: { field: 'operation', value: 'run_report' }, + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: + 'Generate QuickBooks Reports API query parameters as a JSON object. Return ONLY the JSON object - no explanations, no extra text.', + placeholder: 'Describe the report period, basis, and filters...', + generationType: 'json-object', + }, + }, + { + id: 'entities', + title: 'Entities', + type: 'short-input', + placeholder: 'Vendor,PurchaseOrder,Bill,BillPayment', + condition: { field: 'operation', value: 'get_changes' }, + required: { field: 'operation', value: 'get_changes' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a comma-separated list of QuickBooks Online entity names to track with change data capture. Return ONLY the comma-separated list - no explanations, no extra text.', + placeholder: 'Describe which accounting records to monitor...', + }, + }, + { + id: 'changedSince', + title: 'Changed Since', + type: 'short-input', + placeholder: '2026-01-15T09:00:00-08:00', + condition: { field: 'operation', value: 'get_changes' }, + required: { field: 'operation', value: 'get_changes' }, + wandConfig: { + enabled: true, + prompt: + 'Generate an ISO 8601 date-time within the last 30 days based on the user description. Return ONLY the timestamp - no explanations, no extra text.', + placeholder: 'Describe when the change window should begin...', + generationType: 'timestamp', + }, + }, + { + id: 'batch', + title: 'Batch Payload', + type: 'code', + language: 'json', + placeholder: + '{\n "BatchItemRequest": [\n {\n "bId": "vendor-query",\n "Query": "SELECT * FROM Vendor MAXRESULTS 10"\n }\n ]\n}', + condition: { field: 'operation', value: 'batch' }, + required: { field: 'operation', value: 'batch' }, + wandConfig: { + enabled: true, + prompt: + 'Generate a QuickBooks Online batch payload with at most 10 BatchItemRequest entries. Return ONLY the JSON object - no explanations, no extra text.', + placeholder: 'Describe the QuickBooks operations to batch...', + generationType: 'json-object', + }, + }, + { + id: 'minorVersion', + title: 'Minor Version', + type: 'short-input', + placeholder: '75', + mode: 'advanced', + }, + { + id: 'apiEnvironment', + title: 'Environment', + type: 'dropdown', + options: [ + { label: 'Production', id: 'production' }, + { label: 'Sandbox', id: 'sandbox' }, + ], + value: () => 'production', + mode: 'advanced', + }, + ], + tools: { + access: [ + 'quickbooks_batch', + 'quickbooks_create_record', + 'quickbooks_delete_record', + 'quickbooks_download_document', + 'quickbooks_get_attachment_url', + 'quickbooks_get_changes', + 'quickbooks_get_exchange_rate', + 'quickbooks_get_preferences', + 'quickbooks_get_record', + 'quickbooks_list_bills', + 'quickbooks_list_purchase_orders', + 'quickbooks_list_records', + 'quickbooks_list_vendors', + 'quickbooks_query', + 'quickbooks_run_report', + 'quickbooks_send_document', + 'quickbooks_update_exchange_rate', + 'quickbooks_update_preferences', + 'quickbooks_update_record', + 'quickbooks_upload_attachment', + ], + config: { + tool: (params) => `quickbooks_${params.operation}`, + params: (params) => { + const entity = + params.operation === 'list_records' + ? params.listEntity + : params.operation === 'get_record' + ? params.getEntity + : params.operation === 'create_record' + ? params.createEntity + : params.operation === 'update_record' + ? params.updateEntity + : params.operation === 'delete_record' + ? params.deleteEntity + : params.operation === 'download_document' + ? params.pdfEntity + : params.operation === 'send_document' + ? params.pdfEntity + : params.operation === 'upload_attachment' + ? params.attachmentEntity + : undefined + return { + credential: params.oauthCredential, + realmId: params.realmId, + entity, + recordId: params.recordId, + syncToken: params.syncToken, + payload: params.payload, + sparse: params.sparse, + activeOnly: params.activeOnly, + whereClause: params.whereClause, + orderBy: params.orderBy, + startPosition: params.startPosition, + maxResults: params.maxResults, + query: params.query, + report: params.report, + reportParams: params.reportParams, + entities: params.entities, + changedSince: params.changedSince, + batch: params.batch, + sourceCurrencyCode: params.sourceCurrencyCode, + asOfDate: params.asOfDate, + sendTo: params.sendTo, + file: normalizeFileInput(params.file, { single: true }), + attachmentId: params.attachmentId, + thumbnail: params.thumbnail, + entityId: params.attachmentEntityId, + note: params.attachmentNote, + includeOnSend: params.includeOnSend, + apiEnvironment: params.apiEnvironment, + minorVersion: params.minorVersion, + } + }, + }, + }, + inputs: { + operation: { type: 'string', description: 'QuickBooks operation to perform' }, + oauthCredential: { type: 'string', description: 'QuickBooks OAuth credential' }, + realmId: { + type: 'string', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + activeOnly: { type: 'boolean', description: 'Only return active vendors' }, + listEntity: { type: 'string', description: 'QuickBooks entity to list' }, + getEntity: { type: 'string', description: 'QuickBooks entity to read' }, + createEntity: { type: 'string', description: 'QuickBooks entity to create' }, + updateEntity: { type: 'string', description: 'QuickBooks entity to update' }, + deleteEntity: { type: 'string', description: 'QuickBooks entity to delete' }, + recordId: { type: 'string', description: 'QuickBooks record ID' }, + syncToken: { type: 'string', description: 'Latest QuickBooks record SyncToken' }, + payload: { type: 'json', description: 'QuickBooks entity payload' }, + sparse: { type: 'boolean', description: 'Whether to perform a sparse update' }, + whereClause: { type: 'string', description: 'QuickBooks query WHERE clause' }, + orderBy: { type: 'string', description: 'QuickBooks query ORDERBY clause' }, + startPosition: { type: 'string', description: 'One-based start position for pagination' }, + maxResults: { type: 'string', description: 'Maximum number of records to return, up to 1000' }, + query: { type: 'string', description: 'QuickBooks SQL-like query to execute' }, + report: { type: 'string', description: 'QuickBooks report endpoint name' }, + reportParams: { type: 'json', description: 'QuickBooks report query parameters' }, + entities: { type: 'string', description: 'QuickBooks CDC entity list' }, + changedSince: { type: 'string', description: 'QuickBooks CDC look-back date-time' }, + batch: { type: 'json', description: 'QuickBooks batch request payload' }, + pdfEntity: { type: 'string', description: 'QuickBooks transaction type for PDF download' }, + sourceCurrencyCode: { type: 'string', description: 'ISO 4217 source currency code' }, + asOfDate: { type: 'string', description: 'Exchange rate effective date' }, + sendTo: { type: 'string', description: 'Optional document recipient email address' }, + file: { type: 'file', description: 'File to upload to QuickBooks' }, + attachmentEntity: { type: 'string', description: 'QuickBooks entity linked to the file' }, + attachmentEntityId: { type: 'string', description: 'QuickBooks entity ID linked to the file' }, + attachmentNote: { type: 'string', description: 'QuickBooks attachment note' }, + includeOnSend: { + type: 'boolean', + description: 'Whether QuickBooks includes the attachment when sending', + }, + attachmentId: { type: 'string', description: 'QuickBooks Attachable ID' }, + thumbnail: { type: 'boolean', description: 'Whether to request a thumbnail URL' }, + apiEnvironment: { + type: 'string', + description: 'QuickBooks API environment: production or sandbox', + }, + minorVersion: { type: 'string', description: 'QuickBooks Accounting API minor version' }, + }, + outputs: { + items: { + type: 'json', + description: 'QuickBooks records returned by the query', + }, + entity: { + type: 'string', + description: 'QuickBooks entity name returned by the query', + }, + totalCount: { + type: 'number', + description: 'Total count returned by QuickBooks for the query', + }, + startPosition: { + type: 'number', + description: 'Start position returned by QuickBooks', + }, + maxResults: { + type: 'number', + description: 'Maximum records returned by QuickBooks', + }, + query: { + type: 'string', + description: 'Query that was executed', + }, + record: { + type: 'json', + description: 'Entity-specific QuickBooks record returned by a CRUD operation', + }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + }, + report: { + type: 'string', + description: 'QuickBooks report endpoint name', + }, + header: { + type: 'json', + description: 'QuickBooks report header metadata', + }, + columns: { + type: 'json', + description: 'QuickBooks report column definitions', + }, + rows: { + type: 'json', + description: 'QuickBooks report rows and nested sections', + }, + changes: { + type: 'json', + description: 'Changed QuickBooks records grouped by entity', + }, + changedSince: { + type: 'string', + description: 'Requested QuickBooks CDC look-back date-time', + }, + mayBeTruncated: { + type: 'boolean', + description: 'Whether the QuickBooks CDC response may have reached its object limit', + }, + batchItems: { + type: 'json', + description: 'QuickBooks batch item responses in request order', + }, + file: { + type: 'file', + description: 'QuickBooks transaction PDF', + }, + url: { + type: 'string', + description: 'Temporary QuickBooks attachment URL', + }, + attachmentId: { + type: 'string', + description: 'QuickBooks Attachable ID', + }, + thumbnail: { + type: 'boolean', + description: 'Whether the attachment URL is for a thumbnail', + }, + result: { + type: 'json', + description: 'QuickBooks attachment upload response', + }, + }, +} + +export const QuickBooksBlockMeta = { + tags: ['payments', 'automation'], + url: 'https://quickbooks.intuit.com', + templates: [ + { + icon: QuickBooksIcon, + title: 'QuickBooks vendor intake', + prompt: + 'Build a workflow that lists QuickBooks vendors, compares them against submitted procurement requests in a table, and flags requests that reference unknown vendors.', + modules: ['workflows', 'tables'], + category: 'operations', + tags: ['automation'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks purchase order monitor', + prompt: + 'Build a scheduled workflow that lists recent QuickBooks purchase orders, summarizes new commitments by vendor, and posts the digest to a Slack channel.', + modules: ['workflows', 'scheduled'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['slack'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks bill approval queue', + prompt: + 'Build a scheduled workflow that lists QuickBooks bills, identifies bills with open balances, and emails finance a queue grouped by due date.', + modules: ['workflows', 'scheduled'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['gmail'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks procurement assistant', + prompt: + 'Build an agent that answers procurement questions by querying QuickBooks vendors, bills, and purchase orders on demand.', + modules: ['agent'], + category: 'operations', + tags: ['automation'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks vendor spend report', + prompt: + 'Build a weekly workflow that queries QuickBooks bills, rolls up total open balance by vendor, stores the result in a table, and emails the report to finance.', + modules: ['workflows', 'scheduled', 'tables'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['gmail'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks purchase order reconciliation', + prompt: + 'Build a workflow that lists QuickBooks purchase orders and bills, matches them by vendor and amount, and flags bills without a matching purchase order.', + modules: ['workflows', 'tables'], + category: 'operations', + tags: ['automation'], + }, + { + icon: QuickBooksIcon, + title: 'QuickBooks aging alert', + prompt: + 'Build a scheduled workflow that queries QuickBooks bills by due date and sends a Slack alert for any unpaid bills due this week.', + modules: ['workflows', 'scheduled'], + category: 'operations', + tags: ['automation'], + alsoIntegrations: ['slack'], + }, + ], + skills: [ + { + name: 'vendor-master-audit', + description: 'Compare procurement requests against active QuickBooks vendors.', + content: + '# Vendor Master Audit\n\nCheck whether procurement requests reference vendors that exist in QuickBooks Online.\n\n## Steps\n1. List vendors from QuickBooks with activeOnly enabled.\n2. Compare the vendor names or IDs in the incoming request table against the returned vendor records.\n3. Flag requests that do not match an active vendor.\n4. Route flagged rows to the procurement owner for review.\n\n## Output\nReturn the matched vendors, unmatched requests, and a short summary of follow-up work.', + }, + { + name: 'open-bill-aging-alert', + description: 'Find open QuickBooks bills that need attention before payment deadlines.', + content: + '# Open Bill Aging Alert\n\nMonitor unpaid QuickBooks bills and surface near-term payment risk.\n\n## Steps\n1. Query QuickBooks bills with a MAXRESULTS limit and the needed date filters.\n2. Identify bills with an open Balance and group them by due date or vendor.\n3. Summarize the highest-priority bills for finance.\n4. Send the summary to the preferred notification channel.\n\n## Output\nReturn the overdue or upcoming bills, grouped totals, and the notification text.', + }, + { + name: 'purchase-order-monitor', + description: 'Summarize recent QuickBooks purchase orders for procurement review.', + content: + '# Purchase Order Monitor\n\nTrack recent purchase order activity in QuickBooks Online.\n\n## Steps\n1. List or query QuickBooks purchase orders for the review window.\n2. Group purchase orders by vendor and transaction date.\n3. Highlight high-value or unusual purchase orders for manual review.\n4. Save or send the procurement digest.\n\n## Output\nReturn the reviewed purchase orders, vendor totals, and a concise digest.', + }, + { + name: 'po-bill-reconciliation', + description: 'Compare QuickBooks purchase orders and bills to spot mismatches.', + content: + '# PO Bill Reconciliation\n\nUse QuickBooks purchase orders and bills to identify possible matching issues.\n\n## Steps\n1. List purchase orders and bills for the same company and time window.\n2. Match records by vendor, date, document number, or amount where available.\n3. Flag bills without a likely purchase order match.\n4. Summarize exceptions for accounting review.\n\n## Output\nReturn matched records, unmatched bills, and the reconciliation summary.', + }, + { + name: 'vendor-spend-summary', + description: 'Roll up QuickBooks bill data into a vendor spend summary.', + content: + '# Vendor Spend Summary\n\nCreate a vendor-level spend view from QuickBooks bill records.\n\n## Steps\n1. Query QuickBooks bills with a bounded MAXRESULTS value.\n2. Group bill totals and balances by vendor reference when present.\n3. Rank vendors by total amount or open balance.\n4. Store or send the summarized report.\n\n## Output\nReturn vendor totals, open balances, and a short explanation of the largest spend drivers.', + }, + ], +} as const satisfies BlockMeta diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index aa4dfac717f..e50b8c1f293 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -245,6 +245,7 @@ import { ProspeoBlock, ProspeoBlockMeta } from '@/blocks/blocks/prospeo' import { PulseBlock, PulseBlockMeta, PulseV2Block } from '@/blocks/blocks/pulse' import { QdrantBlock, QdrantBlockMeta } from '@/blocks/blocks/qdrant' import { QuartrBlock, QuartrBlockMeta } from '@/blocks/blocks/quartr' +import { QuickBooksBlock, QuickBooksBlockMeta } from '@/blocks/blocks/quickbooks' import { QuiverBlock, QuiverBlockMeta } from '@/blocks/blocks/quiver' import { RailwayBlock, RailwayBlockMeta } from '@/blocks/blocks/railway' import { RB2BBlock, RB2BBlockMeta } from '@/blocks/blocks/rb2b' @@ -560,6 +561,7 @@ export const BLOCK_REGISTRY: Record = { pulse_v2: PulseV2Block, qdrant: QdrantBlock, quartr: QuartrBlock, + quickbooks: QuickBooksBlock, quiver: QuiverBlock, railway: RailwayBlock, rb2b: RB2BBlock, @@ -853,6 +855,7 @@ export const BLOCK_META_REGISTRY: Record = { pulse: PulseBlockMeta, qdrant: QdrantBlockMeta, quartr: QuartrBlockMeta, + quickbooks: QuickBooksBlockMeta, quiver: QuiverBlockMeta, railway: RailwayBlockMeta, rb2b: RB2BBlockMeta, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index efedba2000d..f57951a6df9 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -8759,6 +8759,23 @@ export function RocketlaneIcon(props: SVGProps) { ) } +export function QuickBooksIcon(props: SVGProps) { + return ( + + + + + + + ) +} + /** * Pydantic Logfire. Single-fill mark drawn with `fill='currentColor'` so it * takes white on its brand tile and the block's `iconColor` when rendered bare. diff --git a/apps/sim/lib/api/contracts/tools/quickbooks.ts b/apps/sim/lib/api/contracts/tools/quickbooks.ts new file mode 100644 index 00000000000..fc333b6dc4a --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/quickbooks.ts @@ -0,0 +1,67 @@ +import { z } from 'zod' +import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' + +export const quickBooksUploadAttachmentBodySchema = z.object({ + accessToken: z + .string() + .min(1, 'Access token is required') + .max(4096, 'Access token must be 4096 characters or less') + .regex(/^[^\r\n]+$/, 'Access token contains invalid characters') + .refine((value) => value.trim().length > 0, 'Access token is required'), + realmId: z + .string() + .min(1, 'QuickBooks company ID is required') + .max(64, 'QuickBooks company ID must be 64 characters or less') + .regex(/^\d+$/, 'QuickBooks company ID must contain only digits'), + file: RawFileInputSchema, + entity: z + .string() + .min(1, 'QuickBooks entity type is required') + .max(100, 'QuickBooks entity type must be 100 characters or less') + .regex(/^[A-Za-z][A-Za-z0-9]*$/, 'QuickBooks entity type is invalid'), + entityId: z + .string() + .min(1, 'QuickBooks entity ID is required') + .max(255, 'QuickBooks entity ID must be 255 characters or less'), + note: z.string().max(2000, 'Attachment note must be 2000 characters or less').optional(), + includeOnSend: z.boolean().optional(), + apiEnvironment: z.enum(['production', 'sandbox']).optional(), + minorVersion: z.preprocess( + (value) => { + if (typeof value !== 'string') return value + const trimmed = value.trim() + return trimmed || undefined + }, + z + .string() + .regex(/^\d{1,5}$/, 'Minor version must contain one to five digits') + .optional() + ), +}) + +const quickBooksUploadAttachmentResponseSchema = z.union([ + z.object({ + success: z.literal(true), + output: z.object({ + result: z.record(z.string(), z.unknown()), + }), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]) + +export const quickBooksUploadAttachmentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/upload-attachment', + body: quickBooksUploadAttachmentBodySchema, + response: { mode: 'json', schema: quickBooksUploadAttachmentResponseSchema }, +}) + +export type QuickBooksUploadAttachmentBody = ContractBody +export type QuickBooksUploadAttachmentApiResponse = ContractJsonResponse< + typeof quickBooksUploadAttachmentContract +> diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 48d9097e109..dc3a6f236b8 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -102,6 +102,7 @@ import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider, } from '@/lib/oauth/microsoft' +import { fetchQuickBooksUserInfo, mapQuickBooksUserInfo } from '@/lib/oauth/quickbooks' import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack' import { clearDeadFlag } from '@/lib/oauth/terminal-errors' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' @@ -1906,6 +1907,34 @@ export const auth = betterAuth({ }, }, + { + providerId: 'quickbooks', + clientId: env.QUICKBOOKS_CLIENT_ID as string, + clientSecret: env.QUICKBOOKS_CLIENT_SECRET as string, + authorizationUrl: 'https://appcenter.intuit.com/connect/oauth2', + tokenUrl: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + userInfoUrl: 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo', + scopes: getCanonicalScopesForProvider('quickbooks'), + responseType: 'code', + authentication: 'basic', + accessType: 'offline', + prompt: 'consent', + redirectURI: `${getBaseUrl()}/api/auth/oauth2/callback/quickbooks`, + getUserInfo: async (tokens) => { + try { + logger.info('Fetching QuickBooks user profile') + const profile = await fetchQuickBooksUserInfo( + tokens.accessToken, + env.NODE_ENV !== 'production' + ) + return mapQuickBooksUserInfo(profile) + } catch (error) { + logger.error('Error in QuickBooks getUserInfo:', { error }) + return null + } + }, + }, + { providerId: 'hubspot', clientId: env.HUBSPOT_CLIENT_ID as string, diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 8c28ba643f8..23f7b859ebb 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -412,6 +412,8 @@ export const env = createEnv({ WEALTHBOX_CLIENT_SECRET: z.string().optional(), // WealthBox OAuth client secret PIPEDRIVE_CLIENT_ID: z.string().optional(), // Pipedrive OAuth client ID PIPEDRIVE_CLIENT_SECRET: z.string().optional(), // Pipedrive OAuth client secret + QUICKBOOKS_CLIENT_ID: z.string().optional(), // QuickBooks OAuth client ID + QUICKBOOKS_CLIENT_SECRET: z.string().optional(), // QuickBooks OAuth client secret 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/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index ff7c8965ac8..a926df143d4 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -175,6 +175,7 @@ import { PulseIcon, QdrantIcon, QuartrIcon, + QuickBooksIcon, QuiverIcon, RailwayIcon, RB2BIcon, @@ -439,6 +440,7 @@ export const blockTypeToIconMap: Record = { pulse_v2: PulseIcon, qdrant: QdrantIcon, quartr: QuartrIcon, + quickbooks: QuickBooksIcon, quiver: QuiverIcon, railway: RailwayIcon, rb2b: RB2BIcon, diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index deadb0012bc..b1031c10a5b 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -14515,6 +14515,106 @@ "integrationType": "analytics", "tags": ["data-analytics", "enrichment", "document-processing"] }, + { + "type": "quickbooks", + "slug": "quickbooks", + "name": "QuickBooks", + "description": "Manage and report on QuickBooks Online accounting data", + "longDescription": "Integrate QuickBooks Online into procurement and accounting workflows. Manage supported records, inventory adjustments, preferences, currencies, reports, attachments, PDFs, change-data-capture syncs, batches, and custom queries.", + "bgColor": "#2CA01C", + "iconName": "QuickBooksIcon", + "docsUrl": "https://docs.sim.ai/integrations/quickbooks", + "operations": [ + { + "name": "List Records", + "description": "List or filter records for a supported QuickBooks Online accounting entity" + }, + { + "name": "Get Record", + "description": "Get a QuickBooks Online accounting record by entity and ID" + }, + { + "name": "Create Record", + "description": "Create a supported QuickBooks Online accounting record" + }, + { + "name": "Update Record", + "description": "Update a supported QuickBooks Online accounting record" + }, + { + "name": "Delete Record", + "description": "Delete a supported QuickBooks Online transaction or attachment" + }, + { + "name": "Run Report", + "description": "Run a supported QuickBooks Online financial or accounting report" + }, + { + "name": "Get Preferences", + "description": "Get company accounting, sales, purchasing, tax, and currency preferences" + }, + { + "name": "Update Preferences", + "description": "Update supported QuickBooks company accounting and workflow preferences" + }, + { + "name": "Get Exchange Rate", + "description": "Get a QuickBooks exchange rate for an ISO currency code and optional date" + }, + { + "name": "Update Exchange Rate", + "description": "Create or update a dated QuickBooks exchange rate" + }, + { + "name": "Download Document PDF", + "description": "Download a supported QuickBooks transaction as a PDF" + }, + { + "name": "Send Document", + "description": "Email a supported QuickBooks transaction and return its rendered PDF" + }, + { + "name": "Upload Attachment", + "description": "Upload and link a supported file up to 25 MB to a QuickBooks transaction or item" + }, + { + "name": "Get Attachment URL", + "description": "Get a temporary download URL for a QuickBooks attachment or thumbnail" + }, + { + "name": "Get Changes", + "description": "Get QuickBooks Online records changed during the last 30 days using CDC" + }, + { + "name": "Run Batch", + "description": "Run up to 10 QuickBooks Online entity or query operations in one batch" + }, + { + "name": "Run Query", + "description": "Run a QuickBooks Online Accounting API query" + }, + { + "name": "List Vendors", + "description": "List vendors from QuickBooks Online" + }, + { + "name": "List Purchase Orders", + "description": "List purchase orders from QuickBooks Online" + }, + { + "name": "List Bills", + "description": "List bills from QuickBooks Online" + } + ], + "operationCount": 20, + "triggers": [], + "triggerCount": 0, + "authType": "oauth", + "oauthServiceId": "quickbooks", + "category": "tools", + "integrationType": "commerce", + "tags": ["payments", "automation"] + }, { "type": "quiver", "slug": "quiver", diff --git a/apps/sim/lib/oauth/oauth.test.ts b/apps/sim/lib/oauth/oauth.test.ts index d6273e5af40..a9225357fc8 100644 --- a/apps/sim/lib/oauth/oauth.test.ts +++ b/apps/sim/lib/oauth/oauth.test.ts @@ -39,6 +39,8 @@ beforeAll(() => { ASANA_CLIENT_SECRET: 'asana_client_secret', PIPEDRIVE_CLIENT_ID: 'pipedrive_client_id', PIPEDRIVE_CLIENT_SECRET: 'pipedrive_client_secret', + QUICKBOOKS_CLIENT_ID: 'quickbooks_client_id', + QUICKBOOKS_CLIENT_SECRET: 'quickbooks_client_secret', HUBSPOT_CLIENT_ID: 'hubspot_client_id', HUBSPOT_CLIENT_SECRET: 'hubspot_client_secret', LINKEDIN_CLIENT_ID: 'linkedin_client_id', @@ -120,6 +122,11 @@ describe('OAuth Token Refresh', () => { providerId: 'spotify', endpoint: 'https://accounts.spotify.com/api/token', }, + { + name: 'QuickBooks', + providerId: 'quickbooks', + endpoint: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + }, ] basicAuthProviders.forEach(({ name, providerId, endpoint }) => { diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts index fc3c6bc7064..5655b3377fd 100644 --- a/apps/sim/lib/oauth/oauth.ts +++ b/apps/sim/lib/oauth/oauth.ts @@ -42,6 +42,7 @@ import { NotionIcon, OutlookIcon, PipedriveIcon, + QuickBooksIcon, RedditIcon, SalesforceIcon, ShopifyIcon, @@ -732,6 +733,21 @@ export const OAUTH_PROVIDERS: Record = { }, defaultService: 'monday', }, + quickbooks: { + name: 'QuickBooks', + icon: QuickBooksIcon, + services: { + quickbooks: { + name: 'QuickBooks', + description: 'Manage QuickBooks Online accounting records, reports, and sync data.', + providerId: 'quickbooks', + icon: QuickBooksIcon, + baseProviderIcon: QuickBooksIcon, + scopes: ['openid', 'profile', 'email', 'com.intuit.quickbooks.accounting'], + }, + }, + defaultService: 'quickbooks', + }, box: { name: 'Box', icon: BoxCompanyIcon, @@ -1488,6 +1504,19 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig { supportsRefreshTokenRotation: true, } } + case 'quickbooks': { + const { clientId, clientSecret } = getCredentials( + env.QUICKBOOKS_CLIENT_ID, + env.QUICKBOOKS_CLIENT_SECRET + ) + return { + tokenEndpoint: 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', + clientId, + clientSecret, + useBasicAuth: true, + supportsRefreshTokenRotation: true, + } + } case 'hubspot': { const { clientId, clientSecret } = getCredentials( env.HUBSPOT_CLIENT_ID, diff --git a/apps/sim/lib/oauth/quickbooks-token.ts b/apps/sim/lib/oauth/quickbooks-token.ts new file mode 100644 index 00000000000..74f19279d36 --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks-token.ts @@ -0,0 +1,18 @@ +const QUICKBOOKS_MAX_ACCESS_TOKEN_LENGTH = 4096 + +export function normalizeQuickBooksAccessToken(accessToken: string): string { + if (/[\r\n]/.test(accessToken)) { + throw new Error('QuickBooks access token contains invalid characters') + } + if (accessToken.length > QUICKBOOKS_MAX_ACCESS_TOKEN_LENGTH) { + throw new Error( + `QuickBooks access token must be ${QUICKBOOKS_MAX_ACCESS_TOKEN_LENGTH} characters or less` + ) + } + + const normalizedAccessToken = accessToken.trim() + if (!normalizedAccessToken) { + throw new Error('QuickBooks access token is required') + } + return normalizedAccessToken +} diff --git a/apps/sim/lib/oauth/quickbooks.test.ts b/apps/sim/lib/oauth/quickbooks.test.ts new file mode 100644 index 00000000000..6408341fa52 --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks.test.ts @@ -0,0 +1,157 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + fetchQuickBooksUserInfo, + getQuickBooksUserInfoEndpoints, + mapQuickBooksUserInfo, +} from '@/lib/oauth/quickbooks' + +describe('getQuickBooksUserInfoEndpoints', () => { + it('prefers the sandbox endpoint for local development', () => { + expect(getQuickBooksUserInfoEndpoints(true)).toEqual([ + 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo', + 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo', + ]) + }) + + it('prefers the production endpoint for production deployments', () => { + expect(getQuickBooksUserInfoEndpoints(false)).toEqual([ + 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo', + 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo', + ]) + }) +}) + +describe('fetchQuickBooksUserInfo', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('uses the sandbox user-info endpoint and required headers locally', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + sub: 'intuit-user-1', + email: 'user@example.com', + emailVerified: true, + }) + ) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchQuickBooksUserInfo('access-token', true)).resolves.toMatchObject({ + sub: 'intuit-user-1', + }) + expect(fetchMock).toHaveBeenCalledOnce() + expect(fetchMock).toHaveBeenCalledWith( + 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo', + { + headers: { + Accept: 'application/json', + Authorization: 'Bearer access-token', + }, + } + ) + }) + + it('normalizes and validates access tokens before making a user-info request', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + sub: 'intuit-user-1', + }) + ) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchQuickBooksUserInfo(' access-token ', true)).resolves.toMatchObject({ + sub: 'intuit-user-1', + }) + expect(fetchMock).toHaveBeenCalledWith( + 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer access-token' }), + }) + ) + + await expect(fetchQuickBooksUserInfo('token\r\nX-Injected: true', true)).rejects.toThrow( + 'QuickBooks access token contains invalid characters' + ) + await expect(fetchQuickBooksUserInfo('x'.repeat(4097), true)).rejects.toThrow( + 'QuickBooks access token must be 4096 characters or less' + ) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('falls back to production when the sandbox endpoint rejects the token', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + sub: 'intuit-user-2', + email: 'user@example.com', + emailVerified: true, + }) + ) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchQuickBooksUserInfo('access-token', true)).resolves.toMatchObject({ + sub: 'intuit-user-2', + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe( + 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo' + ) + }) + + it('fails with endpoint statuses when neither environment accepts the token', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })) + .mockResolvedValueOnce(new Response('Forbidden', { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + + await expect(fetchQuickBooksUserInfo('access-token', true)).rejects.toThrow( + 'sandbox-accounts.platform.intuit.com: HTTP 401; accounts.platform.intuit.com: HTTP 403' + ) + }) +}) + +describe('mapQuickBooksUserInfo', () => { + it('maps Intuit camel-case profile fields to a stable OAuth identity', () => { + expect( + mapQuickBooksUserInfo({ + sub: 'intuit-user-3', + givenName: 'Ada', + familyName: 'Lovelace', + email: 'ada@example.com', + emailVerified: true, + }) + ).toMatchObject({ + id: 'intuit-user-3', + name: 'Ada Lovelace', + email: 'ada@example.com', + emailVerified: true, + }) + }) + + it('provides non-empty fallback fields when optional profile scopes omit them', () => { + expect(mapQuickBooksUserInfo({ sub: 'intuit-user-4' })).toMatchObject({ + id: 'intuit-user-4', + name: 'QuickBooks User', + email: 'intuit-user-4@quickbooks.user', + emailVerified: false, + }) + }) + + it('rejects a profile without Intuit subject identity', () => { + expect(() => mapQuickBooksUserInfo({ email: 'user@example.com' })).toThrow( + 'did not include a subject' + ) + }) +}) diff --git a/apps/sim/lib/oauth/quickbooks.ts b/apps/sim/lib/oauth/quickbooks.ts new file mode 100644 index 00000000000..23df6b244bd --- /dev/null +++ b/apps/sim/lib/oauth/quickbooks.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + readResponseJsonWithLimit, + readResponseTextWithLimit, +} from '@/lib/core/utils/stream-limits' +import { normalizeQuickBooksAccessToken } from '@/lib/oauth/quickbooks-token' + +const logger = createLogger('QuickBooksOAuth') + +const QUICKBOOKS_USER_INFO_URLS = { + production: 'https://accounts.platform.intuit.com/v1/openid_connect/userinfo', + sandbox: 'https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo', +} as const + +const MAX_USER_INFO_RESPONSE_BYTES = 1024 * 1024 + +export interface QuickBooksUserInfo { + sub?: string + given_name?: string + givenName?: string + family_name?: string + familyName?: string + email?: string + email_verified?: boolean + emailVerified?: boolean +} + +export function getQuickBooksUserInfoEndpoints(preferSandbox: boolean): string[] { + return preferSandbox + ? [QUICKBOOKS_USER_INFO_URLS.sandbox, QUICKBOOKS_USER_INFO_URLS.production] + : [QUICKBOOKS_USER_INFO_URLS.production, QUICKBOOKS_USER_INFO_URLS.sandbox] +} + +export async function fetchQuickBooksUserInfo( + accessToken: string | undefined, + preferSandbox: boolean +): Promise { + if (!accessToken) { + throw new Error('QuickBooks OAuth token response did not include an access token') + } + const normalizedAccessToken = normalizeQuickBooksAccessToken(accessToken) + + const failures: string[] = [] + + for (const endpoint of getQuickBooksUserInfoEndpoints(preferSandbox)) { + try { + const response = await fetch(endpoint, { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${normalizedAccessToken}`, + }, + }) + + if (!response.ok) { + await readResponseTextWithLimit(response, { + maxBytes: MAX_USER_INFO_RESPONSE_BYTES, + label: 'QuickBooks user info error response', + }).catch(() => {}) + failures.push(`${new URL(endpoint).hostname}: HTTP ${response.status}`) + logger.warn('QuickBooks user info endpoint rejected the access token', { + endpointHost: new URL(endpoint).hostname, + status: response.status, + }) + continue + } + + const profile = await readResponseJsonWithLimit(response, { + maxBytes: MAX_USER_INFO_RESPONSE_BYTES, + label: 'QuickBooks user info response', + }) + + if (!profile.sub) { + failures.push(`${new URL(endpoint).hostname}: missing sub claim`) + logger.warn('QuickBooks user info response did not include a subject', { + endpointHost: new URL(endpoint).hostname, + }) + continue + } + + return profile + } catch (error) { + failures.push(`${new URL(endpoint).hostname}: ${getErrorMessage(error)}`) + logger.warn('QuickBooks user info request failed', { + endpointHost: new URL(endpoint).hostname, + error: getErrorMessage(error), + }) + } + } + + throw new Error(`QuickBooks user info request failed (${failures.join('; ')})`) +} + +export function mapQuickBooksUserInfo(profile: QuickBooksUserInfo) { + if (!profile.sub) { + throw new Error('QuickBooks user info response did not include a subject') + } + + const givenName = profile.given_name ?? profile.givenName ?? '' + const familyName = profile.family_name ?? profile.familyName ?? '' + const name = `${givenName} ${familyName}`.trim() || profile.email || 'QuickBooks User' + + return { + id: profile.sub, + name, + email: profile.email || `${profile.sub}@quickbooks.user`, + emailVerified: profile.email_verified ?? profile.emailVerified ?? Boolean(profile.email), + image: undefined, + createdAt: new Date(), + updatedAt: new Date(), + } +} diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts index 40c24fe8547..0a94b1c72c2 100644 --- a/apps/sim/lib/oauth/types.ts +++ b/apps/sim/lib/oauth/types.ts @@ -75,6 +75,7 @@ export type OAuthProvider = | 'asana' | 'attio' | 'pipedrive' + | 'quickbooks' | 'hubspot' | 'salesforce' | 'linkedin' @@ -129,6 +130,7 @@ export type OAuthService = | 'asana' | 'attio' | 'pipedrive' + | 'quickbooks' | 'hubspot' | 'salesforce' | 'linkedin' diff --git a/apps/sim/lib/oauth/utils.ts b/apps/sim/lib/oauth/utils.ts index ce4e754d727..c5bb77697b7 100644 --- a/apps/sim/lib/oauth/utils.ts +++ b/apps/sim/lib/oauth/utils.ts @@ -337,6 +337,9 @@ export const SCOPE_DESCRIPTIONS: Record = { api: 'Access Salesforce API', refresh_token: 'Maintain long-term access to Salesforce account', + // QuickBooks scopes + 'com.intuit.quickbooks.accounting': 'Access QuickBooks Online accounting data', + // Asana scopes default: 'Access Asana workspace', diff --git a/apps/sim/tools/quickbooks/batch.ts b/apps/sim/tools/quickbooks/batch.ts new file mode 100644 index 00000000000..f844ee0a368 --- /dev/null +++ b/apps/sim/tools/quickbooks/batch.ts @@ -0,0 +1,97 @@ +import type { QuickBooksBatchParams, QuickBooksBatchResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksBatchBody, + buildQuickBooksBatchUrl, + buildQuickBooksHeaders, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksBatchTool: ToolConfig = { + id: 'quickbooks_batch', + name: 'QuickBooks Run Batch', + description: 'Run up to 10 QuickBooks Online entity or query operations in one batch', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + batch: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks batch payload containing a BatchItemRequest array', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => buildQuickBooksBatchUrl(params), + method: 'POST', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + body: (params) => buildQuickBooksBatchBody(params.batch), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks batch parameters are required') + const data = await parseQuickBooksJson(response) + const requestItems = buildQuickBooksBatchBody(params.batch).BatchItemRequest + const batchItems = data.BatchItemResponse + + if (!Array.isArray(batchItems) || batchItems.length === 0) { + throw new Error('QuickBooks batch response did not include any item responses') + } + if (Array.isArray(requestItems) && batchItems.length !== requestItems.length) { + throw new Error( + `QuickBooks batch response returned ${batchItems.length} of ${requestItems.length} item responses` + ) + } + + return { + success: true, + output: { + batchItems, + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + + outputs: { + batchItems: { + type: 'array', + description: 'QuickBooks batch item responses in request order', + items: { + type: 'json', + description: 'Entity, query, report, or fault response identified by bId', + }, + }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/create_record.ts b/apps/sim/tools/quickbooks/create_record.ts new file mode 100644 index 00000000000..a311cc6e5e3 --- /dev/null +++ b/apps/sim/tools/quickbooks/create_record.ts @@ -0,0 +1,102 @@ +import type { + QuickBooksCreateRecordParams, + QuickBooksRecordResponse, +} from '@/tools/quickbooks/types' +import { + buildQuickBooksCreateBody, + buildQuickBooksHeaders, + buildQuickBooksRecordUrl, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksCreateRecordTool: ToolConfig< + QuickBooksCreateRecordParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_create_record', + name: 'QuickBooks Create Record', + description: 'Create a supported QuickBooks Online accounting record', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Creatable QuickBooks entity name', + }, + payload: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Entity payload using the fields documented by the QuickBooks API', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => + buildQuickBooksRecordUrl({ + ...params, + operation: 'create', + }).url, + method: 'POST', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + body: (params) => buildQuickBooksCreateBody(params.payload), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks record parameters are required') + const { entity } = buildQuickBooksRecordUrl({ ...params, operation: 'create' }) + const data = await parseQuickBooksJson(response) + + return { + success: true, + output: { + record: extractQuickBooksRecord(data, entity), + entity, + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + + outputs: { + record: { + type: 'json', + description: 'Created entity-specific QuickBooks record', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/delete_record.ts b/apps/sim/tools/quickbooks/delete_record.ts new file mode 100644 index 00000000000..10f9bba9faf --- /dev/null +++ b/apps/sim/tools/quickbooks/delete_record.ts @@ -0,0 +1,115 @@ +import type { + QuickBooksDeleteRecordParams, + QuickBooksRecordResponse, +} from '@/tools/quickbooks/types' +import { + buildQuickBooksDeleteBody, + buildQuickBooksHeaders, + buildQuickBooksRecordUrl, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksDeleteRecordTool: ToolConfig< + QuickBooksDeleteRecordParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_delete_record', + name: 'QuickBooks Delete Record', + description: 'Delete a supported QuickBooks Online transaction or attachment', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks entity that supports hard delete', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks record ID', + }, + syncToken: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Latest QuickBooks SyncToken for the transaction', + }, + payload: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Full entity payload when the selected transaction does not support simplified delete', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => + buildQuickBooksRecordUrl({ + ...params, + operation: 'delete', + }).url, + method: 'POST', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + body: (params) => buildQuickBooksDeleteBody(params), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks record parameters are required') + const { entity } = buildQuickBooksRecordUrl({ ...params, operation: 'delete' }) + const data = await parseQuickBooksJson(response) + + return { + success: true, + output: { + record: extractQuickBooksRecord(data, entity), + entity, + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + + outputs: { + record: { + type: 'json', + description: 'Deleted entity-specific QuickBooks record', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/download_document.ts b/apps/sim/tools/quickbooks/download_document.ts new file mode 100644 index 00000000000..e4c1e838e98 --- /dev/null +++ b/apps/sim/tools/quickbooks/download_document.ts @@ -0,0 +1,107 @@ +import { truncate } from '@sim/utils/string' +import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits' +import type { + QuickBooksDownloadDocumentParams, + QuickBooksFileResponse, +} from '@/tools/quickbooks/types' +import { + assertQuickBooksPdfResponse, + buildQuickBooksDocumentUrl, + buildQuickBooksHeaders, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +const QUICKBOOKS_MAX_PDF_BYTES = 25 * 1024 * 1024 + +export const quickBooksDownloadDocumentTool: ToolConfig< + QuickBooksDownloadDocumentParams, + QuickBooksFileResponse +> = { + id: 'quickbooks_download_document', + name: 'QuickBooks Download Document', + description: 'Download a supported QuickBooks transaction as a PDF', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks transaction type that supports PDF download', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks transaction ID', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: (params) => buildQuickBooksDocumentUrl(params).url, + method: 'GET', + headers: (params) => { + const { Authorization } = buildQuickBooksHeaders(params.accessToken) + return { Authorization, Accept: 'application/pdf' } + }, + }, + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks document parameters are required') + const { entity } = buildQuickBooksDocumentUrl(params) + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_PDF_BYTES, + label: 'QuickBooks PDF response', + }) + if (!response.ok) { + throw new Error( + `QuickBooks API error (${response.status}): ${truncate(buffer.toString('utf8'), 500) || response.statusText || 'Request failed'}` + ) + } + assertQuickBooksPdfResponse(response, buffer, 'PDF download') + return { + success: true, + output: { + file: { + name: `${entity}-${params.recordId.trim()}.pdf`, + mimeType: 'application/pdf', + data: buffer.toString('base64'), + size: buffer.length, + }, + entity, + recordId: params.recordId.trim(), + }, + } + }, + outputs: { + file: { + type: 'file', + description: 'QuickBooks transaction PDF', + fileConfig: { mimeType: 'application/pdf', extension: 'pdf' }, + }, + entity: { type: 'string', description: 'QuickBooks transaction type' }, + recordId: { type: 'string', description: 'QuickBooks transaction ID' }, + }, +} diff --git a/apps/sim/tools/quickbooks/generic_operations.test.ts b/apps/sim/tools/quickbooks/generic_operations.test.ts new file mode 100644 index 00000000000..0291048fa8c --- /dev/null +++ b/apps/sim/tools/quickbooks/generic_operations.test.ts @@ -0,0 +1,957 @@ +import { describe, expect, it, vi } from 'vitest' +import { quickBooksBatchTool } from '@/tools/quickbooks/batch' +import { quickBooksCreateRecordTool } from '@/tools/quickbooks/create_record' +import { quickBooksDeleteRecordTool } from '@/tools/quickbooks/delete_record' +import { quickBooksDownloadDocumentTool } from '@/tools/quickbooks/download_document' +import { quickBooksGetAttachmentUrlTool } from '@/tools/quickbooks/get_attachment_url' +import { quickBooksGetChangesTool } from '@/tools/quickbooks/get_changes' +import { quickBooksGetExchangeRateTool } from '@/tools/quickbooks/get_exchange_rate' +import { quickBooksGetPreferencesTool } from '@/tools/quickbooks/get_preferences' +import { quickBooksGetRecordTool } from '@/tools/quickbooks/get_record' +import { quickBooksListRecordsTool } from '@/tools/quickbooks/list_records' +import { quickBooksRunReportTool } from '@/tools/quickbooks/run_report' +import { quickBooksSendDocumentTool } from '@/tools/quickbooks/send_document' +import { quickBooksUpdateExchangeRateTool } from '@/tools/quickbooks/update_exchange_rate' +import { quickBooksUpdatePreferencesTool } from '@/tools/quickbooks/update_preferences' +import { quickBooksUpdateRecordTool } from '@/tools/quickbooks/update_record' +import { + buildQuickBooksAttachmentUrl, + buildQuickBooksBatchBody, + buildQuickBooksCdcUrl, + buildQuickBooksDocumentUrl, + buildQuickBooksExchangeRateUrl, + buildQuickBooksHeaders, + buildQuickBooksListRecordsQuery, + buildQuickBooksPreferencesUrl, + buildQuickBooksRecordUrl, + buildQuickBooksReportUrl, + buildQuickBooksSendDocumentUrl, + parseQuickBooksJson, + quickBooksCdcMayBeTruncated, +} from '@/tools/quickbooks/utils' + +describe('QuickBooks generic operations', () => { + it('builds generic list queries for documented QuickBooks entities', () => { + expect( + buildQuickBooksListRecordsQuery({ + entity: 'VendorCredit', + whereClause: "TxnDate >= '2026-01-01'", + orderBy: 'TxnDate DESC', + startPosition: '11', + maxResults: '10', + }) + ).toEqual({ + entity: 'VendorCredit', + query: + "SELECT * FROM VendorCredit WHERE TxnDate >= '2026-01-01' ORDERBY TxnDate DESC STARTPOSITION 11 MAXRESULTS 10", + }) + }) + + it('builds entity read and delete URLs using QuickBooks resource names', () => { + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'PurchaseOrder', + operation: 'read', + recordId: 'po/42', + }) + ).toEqual({ + entity: 'PurchaseOrder', + url: 'https://quickbooks.api.intuit.com/v3/company/123145/purchaseorder/po%2F42?minorversion=75', + }) + + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'BillPayment', + operation: 'delete', + }) + ).toEqual({ + entity: 'BillPayment', + url: 'https://quickbooks.api.intuit.com/v3/company/123145/billpayment?minorversion=75&operation=delete', + }) + }) + + it('rejects unsupported entity and operation combinations', () => { + expect(() => + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'Vendor', + operation: 'delete', + }) + ).toThrow('QuickBooks entity "Vendor" cannot be deleted') + + expect(() => + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'TaxPayment', + operation: 'create', + }) + ).toThrow('QuickBooks entity "TaxPayment" cannot be created') + + expect(() => + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'RecurringTransaction', + operation: 'update', + }) + ).toThrow('QuickBooks entity "RecurringTransaction" cannot be updated') + + expect(() => + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'JournalCode', + operation: 'delete', + }) + ).toThrow('QuickBooks entity "JournalCode" cannot be deleted') + }) + + it('builds current locale and SKU-dependent entity endpoints', () => { + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'Budget', + operation: 'read', + recordId: '40', + }).url + ).toBe('https://quickbooks.api.intuit.com/v3/company/123145/budget/40?minorversion=75') + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'CreditCardPayment', + operation: 'read', + recordId: '41', + }).url + ).toBe( + 'https://quickbooks.api.intuit.com/v3/company/123145/creditcardpayment/41?minorversion=75' + ) + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'TaxPayment', + operation: 'read', + recordId: '42', + }).url + ).toBe('https://quickbooks.api.intuit.com/v3/company/123145/taxpayment/42?minorversion=75') + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'RecurringTransaction', + operation: 'create', + }).url + ).toBe( + 'https://quickbooks.api.intuit.com/v3/company/123145/recurringtransaction?minorversion=75' + ) + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'JournalCode', + operation: 'update', + }).url + ).toBe('https://quickbooks.api.intuit.com/v3/company/123145/journalcode?minorversion=75') + }) + + it('rejects invalid access tokens before constructing QuickBooks headers', () => { + expect(() => buildQuickBooksHeaders('')).toThrow('QuickBooks access token is required') + expect(() => buildQuickBooksHeaders(`token\r\nX-Injected: true`)).toThrow( + 'QuickBooks access token contains invalid characters' + ) + expect(() => buildQuickBooksHeaders('x'.repeat(4097))).toThrow( + 'QuickBooks access token must be 4096 characters or less' + ) + }) + + it('normalizes create, update, and delete request payloads', () => { + const createBody = quickBooksCreateRecordTool.request.body + const updateBody = quickBooksUpdateRecordTool.request.body + const deleteBody = quickBooksDeleteRecordTool.request.body + if (!createBody || !updateBody || !deleteBody) { + throw new Error('Expected QuickBooks record request bodies') + } + + expect( + createBody({ + accessToken: 'token', + realmId: '123145', + entity: 'Vendor', + payload: '{"DisplayName":"Acme Supplies"}', + }) + ).toEqual({ DisplayName: 'Acme Supplies' }) + + expect( + updateBody({ + accessToken: 'token', + realmId: '123145', + entity: 'Vendor', + recordId: '42', + syncToken: '3', + payload: { DisplayName: 'Acme Industrial' }, + }) + ).toEqual({ + DisplayName: 'Acme Industrial', + Id: '42', + SyncToken: '3', + sparse: true, + }) + + expect( + updateBody({ + accessToken: 'token', + realmId: '123145', + entity: 'InventoryAdjustment', + recordId: '43', + syncToken: '4', + payload: { Line: [{ Id: '1' }] }, + sparse: false, + }) + ).toEqual({ + Line: [{ Id: '1' }], + Id: '43', + SyncToken: '4', + sparse: true, + }) + + expect( + deleteBody({ + accessToken: 'token', + realmId: '123145', + entity: 'Bill', + recordId: '17', + syncToken: '2', + }) + ).toEqual({ Id: '17', SyncToken: '2' }) + + expect( + deleteBody({ + accessToken: 'token', + realmId: '123145', + entity: 'InventoryAdjustment', + recordId: '18', + syncToken: '4', + payload: { + Id: '18', + SyncToken: '4', + AdjustmentAccountRef: { value: '91' }, + Line: [{ DetailType: 'ItemAdjustmentLineDetail' }], + }, + }) + ).toEqual({ + Id: '18', + SyncToken: '4', + AdjustmentAccountRef: { value: '91' }, + Line: [{ DetailType: 'ItemAdjustmentLineDetail' }], + }) + + expect( + deleteBody({ + accessToken: 'token', + realmId: '123145', + entity: 'Deposit', + recordId: '19', + syncToken: '5', + payload: { + Id: 'stale-id', + SyncToken: 'stale-token', + DepositToAccountRef: { value: '35' }, + Line: [{ Amount: 125 }], + }, + }) + ).toEqual({ + Id: '19', + SyncToken: '5', + DepositToAccountRef: { value: '35' }, + Line: [{ Amount: 125 }], + }) + + expect(() => + deleteBody({ + accessToken: 'token', + realmId: '123145', + entity: 'InventoryAdjustment', + recordId: '18', + syncToken: '4', + }) + ).toThrow('QuickBooks InventoryAdjustment deletion requires the full entity payload') + }) + + it('builds inventory adjustment CRUD and query requests', () => { + expect( + buildQuickBooksListRecordsQuery({ + entity: 'InventoryAdjustment', + maxResults: '25', + }) + ).toEqual({ + entity: 'InventoryAdjustment', + query: 'SELECT * FROM InventoryAdjustment MAXRESULTS 25', + }) + + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'InventoryAdjustment', + operation: 'create', + }) + ).toEqual({ + entity: 'InventoryAdjustment', + url: 'https://quickbooks.api.intuit.com/v3/company/123145/inventoryadjustment?minorversion=75', + }) + + expect( + buildQuickBooksRecordUrl({ + realmId: '123145', + entity: 'InventoryAdjustment', + operation: 'read', + recordId: '88', + }) + ).toEqual({ + entity: 'InventoryAdjustment', + url: 'https://quickbooks.api.intuit.com/v3/company/123145/inventoryadjustment/88?minorversion=75', + }) + }) + + it('builds report URLs with scalar query parameters', () => { + expect( + buildQuickBooksReportUrl({ + realmId: '123145', + report: 'AgedPayables', + reportParams: { + start_date: '2026-01-01', + end_date: '2026-01-31', + accounting_method: 'Accrual', + }, + }) + ).toEqual({ + report: 'AgedPayables', + url: 'https://quickbooks.api.intuit.com/v3/company/123145/reports/AgedPayables?minorversion=75&start_date=2026-01-01&end_date=2026-01-31&accounting_method=Accrual', + }) + }) + + it('builds CDC URLs and enforces QuickBooks look-back constraints', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-29T12:00:00Z')) + try { + expect( + buildQuickBooksCdcUrl({ + realmId: '123145', + entities: 'Vendor, PurchaseOrder, Bill, Budget', + changedSince: '2026-07-15T09:00:00-08:00', + }) + ).toBe( + 'https://quickbooks.api.intuit.com/v3/company/123145/cdc?minorversion=75&entities=Vendor%2CPurchaseOrder%2CBill%2CBudget&changedSince=2026-07-15T09%3A00%3A00-08%3A00' + ) + + expect(() => + buildQuickBooksCdcUrl({ + realmId: '123145', + entities: 'TaxRate', + changedSince: '2026-07-15', + }) + ).toThrow('QuickBooks entity "TaxRate" cannot be tracked by CDC') + + expect(() => + buildQuickBooksCdcUrl({ + realmId: '123145', + entities: 'JournalCode', + changedSince: '2026-07-15', + }) + ).toThrow('QuickBooks entity "JournalCode" cannot be tracked by CDC') + + expect( + buildQuickBooksCdcUrl({ + realmId: '123145', + entities: 'CreditCardPayment,TaxPayment,RecurringTransaction', + changedSince: '2026-07-15', + }) + ).toContain('entities=CreditCardPayment%2CTaxPayment%2CRecurringTransaction') + + expect(() => + buildQuickBooksCdcUrl({ + realmId: '123145', + entities: 'Vendor', + changedSince: '2026-06-01', + }) + ).toThrow('Changed since must be within the last 30 days') + + expect(() => + buildQuickBooksCdcUrl({ + realmId: '123145', + entities: 'Vendor', + changedSince: '2026-07-30', + }) + ).toThrow('Changed since cannot be in the future') + + expect(() => + buildQuickBooksCdcUrl({ + realmId: '123145', + entities: 'Vendor', + changedSince: 'July 15, 2026', + }) + ).toThrow('Changed since must be an ISO date or date-time') + } finally { + vi.useRealTimers() + } + }) + + it('detects when a CDC response may have reached the 1,000-object limit', () => { + expect( + quickBooksCdcMayBeTruncated([ + { + entity: 'Vendor', + records: Array.from({ length: 1000 }, (_, index) => ({ Id: String(index + 1) })), + }, + ]) + ).toBe(true) + + expect( + quickBooksCdcMayBeTruncated([ + { + entity: 'Vendor', + records: Array.from({ length: 999 }, (_, index) => ({ Id: String(index + 1) })), + }, + ]) + ).toBe(false) + }) + + it('builds preferences, currency, PDF, and attachment endpoint URLs', () => { + expect(buildQuickBooksPreferencesUrl({ realmId: '123145' })).toBe( + 'https://quickbooks.api.intuit.com/v3/company/123145/preferences?minorversion=75' + ) + + expect( + buildQuickBooksExchangeRateUrl({ + realmId: '123145', + sourceCurrencyCode: 'eur', + asOfDate: '2026-07-15', + }) + ).toBe( + 'https://quickbooks.api.intuit.com/v3/company/123145/exchangerate?minorversion=75&sourcecurrencycode=EUR&asofdate=2026-07-15' + ) + + expect( + buildQuickBooksDocumentUrl({ + realmId: '123145', + entity: 'PurchaseOrder', + recordId: 'po/42', + }) + ).toEqual({ + entity: 'PurchaseOrder', + url: 'https://quickbooks.api.intuit.com/v3/company/123145/purchaseorder/po%2F42/pdf?minorversion=75', + }) + + expect( + buildQuickBooksAttachmentUrl({ + realmId: '123145', + attachmentId: 'attachment/7', + thumbnail: true, + }) + ).toEqual({ + thumbnail: true, + url: 'https://quickbooks.api.intuit.com/v3/company/123145/attachable-thumbnail/attachment%2F7?minorversion=75', + }) + + expect( + buildQuickBooksSendDocumentUrl({ + realmId: '123145', + entity: 'PurchaseOrder', + recordId: '42', + sendTo: 'purchasing@example.com', + }) + ).toEqual({ + entity: 'PurchaseOrder', + url: 'https://quickbooks.api.intuit.com/v3/company/123145/purchaseorder/42/send?minorversion=75&sendTo=purchasing%40example.com', + }) + }) + + it('validates QuickBooks batch payload size', () => { + expect( + buildQuickBooksBatchBody({ + BatchItemRequest: [{ bId: 'vendor-query', Query: 'SELECT * FROM Vendor' }], + }) + ).toEqual({ + BatchItemRequest: [{ bId: 'vendor-query', Query: 'SELECT * FROM Vendor' }], + }) + + expect(() => + buildQuickBooksBatchBody({ + BatchItemRequest: Array.from({ length: 11 }, (_, index) => ({ bId: String(index) })), + }) + ).toThrow('QuickBooks batch payload must contain 10 items or less') + }) + + it('rejects QuickBooks responses larger than 10 MB before reading the body', async () => { + const response = new Response(null, { + headers: { 'content-length': String(10 * 1024 * 1024 + 1) }, + status: 200, + }) + + await expect(parseQuickBooksJson(response)).rejects.toThrow( + 'QuickBooks API response exceeds maximum size of 10485760 bytes' + ) + }) + + it('transforms generic list QueryResponse arrays', async () => { + const response = new Response( + JSON.stringify({ + QueryResponse: { + VendorCredit: [{ Id: '9', TotalAmt: 125 }], + startPosition: 1, + maxResults: 1, + }, + }), + { status: 200 } + ) + + const result = await quickBooksListRecordsTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + entity: 'VendorCredit', + maxResults: '1', + }) + + expect(result?.output).toEqual({ + items: [{ Id: '9', TotalAmt: 125 }], + entity: 'VendorCredit', + totalCount: null, + startPosition: 1, + maxResults: 1, + query: 'SELECT * FROM VendorCredit MAXRESULTS 1', + }) + }) + + it('transforms generic record envelopes', async () => { + const response = new Response( + JSON.stringify({ + PurchaseOrder: { Id: '42', SyncToken: '1', TotalAmt: 500 }, + time: '2026-01-20T10:00:00-08:00', + }), + { status: 200 } + ) + + const result = await quickBooksGetRecordTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + entity: 'PurchaseOrder', + recordId: '42', + }) + + expect(result?.output).toEqual({ + record: { Id: '42', SyncToken: '1', TotalAmt: 500 }, + entity: 'PurchaseOrder', + time: '2026-01-20T10:00:00-08:00', + }) + }) + + it('rejects successful record responses that omit the entity record', async () => { + const emptyResponse = () => + new Response(JSON.stringify({ time: '2026-01-20T10:00:00-08:00' }), { status: 200 }) + + await expect( + quickBooksCreateRecordTool.transformResponse?.(emptyResponse(), { + accessToken: 'token', + realmId: '123145', + entity: 'Vendor', + payload: { DisplayName: 'Acme Supplies' }, + }) + ).rejects.toThrow('QuickBooks API response did not include Vendor') + + await expect( + quickBooksGetRecordTool.transformResponse?.(emptyResponse(), { + accessToken: 'token', + realmId: '123145', + entity: 'PurchaseOrder', + recordId: '42', + }) + ).rejects.toThrow('QuickBooks API response did not include PurchaseOrder') + + await expect( + quickBooksUpdateRecordTool.transformResponse?.(emptyResponse(), { + accessToken: 'token', + realmId: '123145', + entity: 'Vendor', + recordId: '7', + syncToken: '1', + payload: { DisplayName: 'Acme Industrial' }, + }) + ).rejects.toThrow('QuickBooks API response did not include Vendor') + + await expect( + quickBooksDeleteRecordTool.transformResponse?.(emptyResponse(), { + accessToken: 'token', + realmId: '123145', + entity: 'Bill', + recordId: '17', + syncToken: '2', + }) + ).rejects.toThrow('QuickBooks API response did not include Bill') + + await expect( + quickBooksGetPreferencesTool.transformResponse?.(emptyResponse(), { + accessToken: 'token', + realmId: '123145', + }) + ).rejects.toThrow('QuickBooks API response did not include Preferences') + + await expect( + quickBooksGetExchangeRateTool.transformResponse?.(emptyResponse(), { + accessToken: 'token', + realmId: '123145', + sourceCurrencyCode: 'EUR', + }) + ).rejects.toThrow('QuickBooks API response did not include ExchangeRate') + }) + + it('rejects successful entity responses without a record ID', async () => { + await expect( + quickBooksCreateRecordTool.transformResponse?.( + new Response(JSON.stringify({ Vendor: {} }), { status: 200 }), + { + accessToken: 'token', + realmId: '123145', + entity: 'Vendor', + payload: { DisplayName: 'Acme Supplies' }, + } + ) + ).rejects.toThrow('QuickBooks API response did not include Vendor') + + await expect( + quickBooksCreateRecordTool.transformResponse?.( + new Response(JSON.stringify({ Vendor: { DisplayName: 'Acme Supplies' } }), { status: 200 }), + { + accessToken: 'token', + realmId: '123145', + entity: 'Vendor', + payload: { DisplayName: 'Acme Supplies' }, + } + ) + ).rejects.toThrow('QuickBooks API response did not include Vendor') + }) + + it('transforms QuickBooks report sections', async () => { + const response = new Response( + JSON.stringify({ + Header: { ReportName: 'AgedPayables', ReportBasis: 'Accrual' }, + Columns: { Column: [{ ColTitle: 'Vendor' }] }, + Rows: { Row: [{ type: 'Data' }] }, + time: '2026-01-20T10:00:00-08:00', + }), + { status: 200 } + ) + + const result = await quickBooksRunReportTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + report: 'AgedPayables', + }) + + expect(result?.output).toEqual({ + report: 'AgedPayables', + header: { ReportName: 'AgedPayables', ReportBasis: 'Accrual' }, + columns: { Column: [{ ColTitle: 'Vendor' }] }, + rows: { Row: [{ type: 'Data' }] }, + time: '2026-01-20T10:00:00-08:00', + }) + }) + + it('rejects successful report responses without report sections', async () => { + await expect( + quickBooksRunReportTool.transformResponse?.( + new Response(JSON.stringify({ time: '2026-01-20T10:00:00-08:00' }), { status: 200 }), + { + accessToken: 'token', + realmId: '123145', + report: 'AgedPayables', + } + ) + ).rejects.toThrow('QuickBooks report response did not include Header, Columns, and Rows') + }) + + it('transforms QuickBooks CDC entity groups and deleted records', async () => { + const response = new Response( + JSON.stringify({ + CDCResponse: [ + { + QueryResponse: [ + { + Vendor: [ + { Id: '1', DisplayName: 'Acme' }, + { Id: '2', status: 'Deleted' }, + ], + startPosition: 1, + maxResults: 2, + totalCount: 2, + }, + ], + }, + ], + time: '2026-01-20T10:00:00-08:00', + }), + { status: 200 } + ) + + const result = await quickBooksGetChangesTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + entities: 'Vendor', + changedSince: '2026-01-19', + }) + + expect(result?.output).toEqual({ + changes: [ + { + entity: 'Vendor', + records: [ + { Id: '1', DisplayName: 'Acme' }, + { Id: '2', status: 'Deleted' }, + ], + startPosition: 1, + maxResults: 2, + totalCount: 2, + }, + ], + changedSince: '2026-01-19', + mayBeTruncated: false, + time: '2026-01-20T10:00:00-08:00', + }) + }) + + it('transforms preferences and exchange rate envelopes', async () => { + const preferencesResponse = new Response( + JSON.stringify({ + Preferences: { VendorAndPurchasesPrefs: { UsePurchaseOrder: true } }, + time: '2026-07-29T10:00:00-07:00', + }), + { status: 200 } + ) + const exchangeRateResponse = new Response( + JSON.stringify({ + ExchangeRate: { + SourceCurrencyCode: 'EUR', + TargetCurrencyCode: 'USD', + Rate: 1.15, + AsOfDate: '2026-07-29', + }, + time: '2026-07-29T10:00:00-07:00', + }), + { status: 200 } + ) + + expect( + (await quickBooksGetPreferencesTool.transformResponse?.(preferencesResponse))?.output + ).toEqual({ + record: { VendorAndPurchasesPrefs: { UsePurchaseOrder: true } }, + entity: 'Preferences', + time: '2026-07-29T10:00:00-07:00', + }) + + expect( + (await quickBooksGetExchangeRateTool.transformResponse?.(exchangeRateResponse))?.output + ).toEqual({ + record: { + SourceCurrencyCode: 'EUR', + TargetCurrencyCode: 'USD', + Rate: 1.15, + AsOfDate: '2026-07-29', + }, + entity: 'ExchangeRate', + time: '2026-07-29T10:00:00-07:00', + }) + + const preferencesBody = quickBooksUpdatePreferencesTool.request.body + if (!preferencesBody) throw new Error('Expected QuickBooks preferences request body') + expect( + preferencesBody({ + accessToken: 'token', + realmId: '123145', + payload: { VendorAndPurchasesPrefs: { UsePurchaseOrder: true } }, + }) + ).toEqual({ VendorAndPurchasesPrefs: { UsePurchaseOrder: true } }) + }) + + it('normalizes exchange-rate updates and transforms document and attachment responses', async () => { + const exchangeRateBody = quickBooksUpdateExchangeRateTool.request.body + if (!exchangeRateBody) throw new Error('Expected QuickBooks exchange rate request body') + expect( + exchangeRateBody({ + accessToken: 'token', + realmId: '123145', + payload: { + SourceCurrencyCode: 'EUR', + TargetCurrencyCode: 'USD', + Rate: 1.15, + AsOfDate: '2026-07-29', + SyncToken: '0', + }, + }) + ).toEqual({ + SourceCurrencyCode: 'EUR', + TargetCurrencyCode: 'USD', + Rate: 1.15, + AsOfDate: '2026-07-29', + SyncToken: '0', + }) + + const documentResult = await quickBooksDownloadDocumentTool.transformResponse?.( + new Response(new Uint8Array([37, 80, 68, 70, 45]), { + headers: { 'content-type': 'application/pdf' }, + status: 200, + }), + { + accessToken: 'token', + realmId: '123145', + entity: 'PurchaseOrder', + recordId: '42', + } + ) + expect(documentResult?.output).toEqual({ + file: { + name: 'PurchaseOrder-42.pdf', + mimeType: 'application/pdf', + data: 'JVBERi0=', + size: 5, + }, + entity: 'PurchaseOrder', + recordId: '42', + }) + + const sentDocumentResult = await quickBooksSendDocumentTool.transformResponse?.( + new Response(new Uint8Array([37, 80, 68, 70, 45]), { + headers: { 'content-type': 'application/octet-stream' }, + status: 200, + }), + { + accessToken: 'token', + realmId: '123145', + entity: 'PurchaseOrder', + recordId: '42', + sendTo: 'purchasing@example.com', + } + ) + expect(sentDocumentResult?.output).toEqual({ + file: { + name: 'PurchaseOrder-42.pdf', + mimeType: 'application/pdf', + data: 'JVBERi0=', + size: 5, + }, + entity: 'PurchaseOrder', + recordId: '42', + }) + + const attachmentResult = await quickBooksGetAttachmentUrlTool.transformResponse?.( + new Response('https://files.example.com/temporary-file', { status: 200 }), + { + accessToken: 'token', + realmId: '123145', + attachmentId: '7', + thumbnail: false, + } + ) + expect(attachmentResult?.output).toEqual({ + url: 'https://files.example.com/temporary-file', + attachmentId: '7', + thumbnail: false, + }) + }) + + it('rejects successful document responses that are not PDFs', async () => { + const params = { + accessToken: 'token', + realmId: '123145', + entity: 'PurchaseOrder' as const, + recordId: '42', + } + + await expect( + quickBooksDownloadDocumentTool.transformResponse?.( + new Response(JSON.stringify({ Fault: { Error: [{ Message: 'Unexpected response' }] } }), { + headers: { 'content-type': 'application/json' }, + status: 200, + }), + params + ) + ).rejects.toThrow('QuickBooks PDF download returned a non-PDF response (application/json)') + + await expect( + quickBooksSendDocumentTool.transformResponse?.( + new Response('', { + headers: { 'content-type': 'text/plain' }, + status: 200, + }), + params + ) + ).rejects.toThrow( + 'QuickBooks sent document returned a non-PDF response (text/plain): Empty response' + ) + }) + + it('transforms QuickBooks batch item responses without hiding item faults', async () => { + const response = new Response( + JSON.stringify({ + BatchItemResponse: [ + { bId: 'vendor-query', QueryResponse: { Vendor: [{ Id: '1' }] } }, + { bId: 'bad-update', Fault: { Error: [{ code: '5010' }] } }, + ], + time: '2026-01-20T10:00:00-08:00', + }), + { status: 200 } + ) + + const result = await quickBooksBatchTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + batch: { + BatchItemRequest: [ + { bId: 'vendor-query', Query: 'SELECT * FROM Vendor' }, + { bId: 'bad-update', Vendor: { Id: '1', SyncToken: '0' }, operation: 'update' }, + ], + }, + }) + + expect(result?.output).toEqual({ + batchItems: [ + { bId: 'vendor-query', QueryResponse: { Vendor: [{ Id: '1' }] } }, + { bId: 'bad-update', Fault: { Error: [{ code: '5010' }] } }, + ], + time: '2026-01-20T10:00:00-08:00', + }) + }) + + it('rejects successful QuickBooks batch responses with missing or incomplete items', async () => { + const params = { + accessToken: 'token', + realmId: '123145', + batch: { + BatchItemRequest: [ + { bId: 'vendor-query', Query: 'SELECT * FROM Vendor' }, + { bId: 'bill-query', Query: 'SELECT * FROM Bill' }, + ], + }, + } + + await expect( + quickBooksBatchTool.transformResponse?.( + new Response(JSON.stringify({ time: '2026-01-20T10:00:00-08:00' }), { status: 200 }), + params + ) + ).rejects.toThrow('QuickBooks batch response did not include any item responses') + + await expect( + quickBooksBatchTool.transformResponse?.( + new Response(JSON.stringify({ BatchItemResponse: [] }), { status: 200 }), + params + ) + ).rejects.toThrow('QuickBooks batch response did not include any item responses') + + await expect( + quickBooksBatchTool.transformResponse?.( + new Response( + JSON.stringify({ + BatchItemResponse: [{ bId: 'vendor-query', QueryResponse: { Vendor: [{ Id: '1' }] } }], + }), + { status: 200 } + ), + params + ) + ).rejects.toThrow('QuickBooks batch response returned 1 of 2 item responses') + }) +}) diff --git a/apps/sim/tools/quickbooks/get_attachment_url.ts b/apps/sim/tools/quickbooks/get_attachment_url.ts new file mode 100644 index 00000000000..9432b8bb968 --- /dev/null +++ b/apps/sim/tools/quickbooks/get_attachment_url.ts @@ -0,0 +1,97 @@ +import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' +import type { + QuickBooksAttachmentUrlParams, + QuickBooksAttachmentUrlResponse, +} from '@/tools/quickbooks/types' +import { buildQuickBooksAttachmentUrl, buildQuickBooksHeaders } from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +const QUICKBOOKS_MAX_ATTACHMENT_URL_BYTES = 64 * 1024 + +export const quickBooksGetAttachmentUrlTool: ToolConfig< + QuickBooksAttachmentUrlParams, + QuickBooksAttachmentUrlResponse +> = { + id: 'quickbooks_get_attachment_url', + name: 'QuickBooks Get Attachment URL', + description: 'Get a temporary download URL for a QuickBooks attachment or thumbnail', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + attachmentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks Attachable ID', + }, + thumbnail: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Return a thumbnail URL instead of the original attachment URL', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: (params) => buildQuickBooksAttachmentUrl(params).url, + method: 'GET', + headers: (params) => { + const { Authorization } = buildQuickBooksHeaders(params.accessToken) + return { Authorization, Accept: 'text/plain' } + }, + }, + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks attachment parameters are required') + const { thumbnail } = buildQuickBooksAttachmentUrl(params) + const url = ( + await readResponseTextWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_ATTACHMENT_URL_BYTES, + label: 'QuickBooks attachment URL response', + }) + ).trim() + if (!response.ok) { + throw new Error( + `QuickBooks API error (${response.status}): ${url || response.statusText || 'Request failed'}` + ) + } + if (!URL.canParse(url)) { + throw new Error('QuickBooks returned an invalid attachment URL') + } + return { + success: true, + output: { + url, + attachmentId: params.attachmentId.trim(), + thumbnail, + }, + } + }, + outputs: { + url: { type: 'string', description: 'Temporary QuickBooks attachment URL' }, + attachmentId: { type: 'string', description: 'QuickBooks Attachable ID' }, + thumbnail: { type: 'boolean', description: 'Whether the URL points to a thumbnail' }, + }, +} diff --git a/apps/sim/tools/quickbooks/get_changes.ts b/apps/sim/tools/quickbooks/get_changes.ts new file mode 100644 index 00000000000..6b28aa6b726 --- /dev/null +++ b/apps/sim/tools/quickbooks/get_changes.ts @@ -0,0 +1,122 @@ +import type { QuickBooksCdcParams, QuickBooksCdcResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksCdcUrl, + buildQuickBooksHeaders, + extractQuickBooksCdcChanges, + parseQuickBooksJson, + quickBooksCdcMayBeTruncated, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksGetChangesTool: ToolConfig = { + id: 'quickbooks_get_changes', + name: 'QuickBooks Get Changes', + description: 'Get QuickBooks Online records changed during the last 30 days using CDC', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entities: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Comma-separated QuickBooks entity names to track', + }, + changedSince: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'ISO date or date-time within the last 30 days', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => buildQuickBooksCdcUrl(params), + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks CDC parameters are required') + const data = await parseQuickBooksJson(response) + const changes = extractQuickBooksCdcChanges(data) + + return { + success: true, + output: { + changes, + changedSince: params.changedSince.trim(), + mayBeTruncated: quickBooksCdcMayBeTruncated(changes), + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + + outputs: { + changes: { + type: 'array', + description: 'Changed QuickBooks records grouped by entity', + items: { + type: 'object', + properties: { + entity: { type: 'string', description: 'QuickBooks entity name' }, + records: { + type: 'array', + description: 'Changed or deleted entity records', + items: { type: 'json', description: 'Entity-specific QuickBooks record' }, + }, + startPosition: { + type: 'number', + description: 'Start position returned by QuickBooks', + nullable: true, + }, + maxResults: { + type: 'number', + description: 'Maximum results returned by QuickBooks', + nullable: true, + }, + totalCount: { + type: 'number', + description: 'Total changes returned for the entity', + nullable: true, + }, + }, + }, + }, + changedSince: { type: 'string', description: 'Requested change look-back date' }, + mayBeTruncated: { + type: 'boolean', + description: 'Whether the 1,000-object CDC response limit may have been reached', + }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/get_exchange_rate.ts b/apps/sim/tools/quickbooks/get_exchange_rate.ts new file mode 100644 index 00000000000..511307d8e2d --- /dev/null +++ b/apps/sim/tools/quickbooks/get_exchange_rate.ts @@ -0,0 +1,88 @@ +import type { + QuickBooksExchangeRateParams, + QuickBooksRecordResponse, +} from '@/tools/quickbooks/types' +import { + buildQuickBooksExchangeRateUrl, + buildQuickBooksHeaders, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksGetExchangeRateTool: ToolConfig< + QuickBooksExchangeRateParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_get_exchange_rate', + name: 'QuickBooks Get Exchange Rate', + description: 'Get a QuickBooks exchange rate for an ISO currency code and optional date', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + sourceCurrencyCode: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Three-letter ISO 4217 source currency code', + }, + asOfDate: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Exchange rate effective date in YYYY-MM-DD format; defaults to today', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: (params) => buildQuickBooksExchangeRateUrl(params), + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + transformResponse: async (response) => { + const data = await parseQuickBooksJson(response) + return { + success: true, + output: { + record: extractQuickBooksRecord(data, 'ExchangeRate'), + entity: 'ExchangeRate', + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + outputs: { + record: { + type: 'json', + description: 'QuickBooks exchange rate', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/get_preferences.ts b/apps/sim/tools/quickbooks/get_preferences.ts new file mode 100644 index 00000000000..3bd2111b741 --- /dev/null +++ b/apps/sim/tools/quickbooks/get_preferences.ts @@ -0,0 +1,76 @@ +import type { + QuickBooksPreferencesParams, + QuickBooksRecordResponse, +} from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksPreferencesUrl, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksGetPreferencesTool: ToolConfig< + QuickBooksPreferencesParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_get_preferences', + name: 'QuickBooks Get Preferences', + description: 'Get company accounting, sales, purchasing, tax, and currency preferences', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: (params) => buildQuickBooksPreferencesUrl(params), + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + transformResponse: async (response) => { + const data = await parseQuickBooksJson(response) + return { + success: true, + output: { + record: extractQuickBooksRecord(data, 'Preferences'), + entity: 'Preferences', + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + outputs: { + record: { + type: 'json', + description: 'QuickBooks company preferences', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/get_record.ts b/apps/sim/tools/quickbooks/get_record.ts new file mode 100644 index 00000000000..e571d7e1039 --- /dev/null +++ b/apps/sim/tools/quickbooks/get_record.ts @@ -0,0 +1,97 @@ +import type { QuickBooksGetRecordParams, QuickBooksRecordResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksRecordUrl, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksGetRecordTool: ToolConfig< + QuickBooksGetRecordParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_get_record', + name: 'QuickBooks Get Record', + description: 'Get a QuickBooks Online accounting record by entity and ID', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Readable QuickBooks entity name', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks record ID', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => + buildQuickBooksRecordUrl({ + ...params, + operation: 'read', + }).url, + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks record parameters are required') + const { entity } = buildQuickBooksRecordUrl({ ...params, operation: 'read' }) + const data = await parseQuickBooksJson(response) + + return { + success: true, + output: { + record: extractQuickBooksRecord(data, entity), + entity, + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + + outputs: { + record: { + type: 'json', + description: 'Entity-specific QuickBooks record', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/index.ts b/apps/sim/tools/quickbooks/index.ts new file mode 100644 index 00000000000..e221461fe3d --- /dev/null +++ b/apps/sim/tools/quickbooks/index.ts @@ -0,0 +1,21 @@ +export { quickBooksBatchTool } from '@/tools/quickbooks/batch' +export { quickBooksCreateRecordTool } from '@/tools/quickbooks/create_record' +export { quickBooksDeleteRecordTool } from '@/tools/quickbooks/delete_record' +export { quickBooksDownloadDocumentTool } from '@/tools/quickbooks/download_document' +export { quickBooksGetAttachmentUrlTool } from '@/tools/quickbooks/get_attachment_url' +export { quickBooksGetChangesTool } from '@/tools/quickbooks/get_changes' +export { quickBooksGetExchangeRateTool } from '@/tools/quickbooks/get_exchange_rate' +export { quickBooksGetPreferencesTool } from '@/tools/quickbooks/get_preferences' +export { quickBooksGetRecordTool } from '@/tools/quickbooks/get_record' +export { quickBooksListBillsTool } from '@/tools/quickbooks/list_bills' +export { quickBooksListPurchaseOrdersTool } from '@/tools/quickbooks/list_purchase_orders' +export { quickBooksListRecordsTool } from '@/tools/quickbooks/list_records' +export { quickBooksListVendorsTool } from '@/tools/quickbooks/list_vendors' +export { quickBooksQueryTool } from '@/tools/quickbooks/query' +export { quickBooksRunReportTool } from '@/tools/quickbooks/run_report' +export { quickBooksSendDocumentTool } from '@/tools/quickbooks/send_document' +export type * from '@/tools/quickbooks/types' +export { quickBooksUpdateExchangeRateTool } from '@/tools/quickbooks/update_exchange_rate' +export { quickBooksUpdatePreferencesTool } from '@/tools/quickbooks/update_preferences' +export { quickBooksUpdateRecordTool } from '@/tools/quickbooks/update_record' +export { quickBooksUploadAttachmentTool } from '@/tools/quickbooks/upload_attachment' diff --git a/apps/sim/tools/quickbooks/list_bills.ts b/apps/sim/tools/quickbooks/list_bills.ts new file mode 100644 index 00000000000..0a1f5ed4d17 --- /dev/null +++ b/apps/sim/tools/quickbooks/list_bills.ts @@ -0,0 +1,123 @@ +import type { QuickBooksListParams, QuickBooksQueryResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksListQuery, + buildQuickBooksQueryUrl, + extractQuickBooksRecords, + getQuickBooksQueryMetadata, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksListBillsTool: ToolConfig = { + id: 'quickbooks_list_bills', + name: 'QuickBooks List Bills', + description: 'List bills from QuickBooks Online', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + startPosition: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'One-based start position for QuickBooks query pagination', + }, + maxResults: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of bills to return, up to 1000', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => + buildQuickBooksQueryUrl({ + realmId: params.realmId, + query: buildQuickBooksListQuery('Bill', params), + apiEnvironment: params.apiEnvironment, + minorVersion: params.minorVersion, + }), + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + const data = await parseQuickBooksJson(response) + const { entity, items } = extractQuickBooksRecords(data, 'Bill') + const metadata = getQuickBooksQueryMetadata(data) + + return { + success: true, + output: { + items, + entity, + totalCount: metadata.totalCount, + startPosition: metadata.startPosition, + maxResults: metadata.maxResults, + query: buildQuickBooksListQuery('Bill', params ?? { accessToken: '', realmId: '' }), + }, + } + }, + + outputs: { + items: { + type: 'array', + description: 'Bills returned by QuickBooks', + items: { + type: 'json', + properties: { + Id: { type: 'string', description: 'QuickBooks bill ID', optional: true }, + DocNumber: { type: 'string', description: 'Bill document number', optional: true }, + TxnDate: { type: 'string', description: 'Bill transaction date', optional: true }, + DueDate: { type: 'string', description: 'Bill due date', optional: true }, + TotalAmt: { type: 'number', description: 'Bill total amount', optional: true }, + Balance: { type: 'number', description: 'Open bill balance', optional: true }, + }, + }, + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + totalCount: { + type: 'number', + description: 'Total count returned by QuickBooks', + optional: true, + }, + startPosition: { + type: 'number', + description: 'Start position returned by QuickBooks', + optional: true, + }, + maxResults: { + type: 'number', + description: 'Maximum records returned by QuickBooks', + optional: true, + }, + query: { type: 'string', description: 'Query that was executed' }, + }, +} diff --git a/apps/sim/tools/quickbooks/list_purchase_orders.ts b/apps/sim/tools/quickbooks/list_purchase_orders.ts new file mode 100644 index 00000000000..d041f441cf9 --- /dev/null +++ b/apps/sim/tools/quickbooks/list_purchase_orders.ts @@ -0,0 +1,135 @@ +import type { QuickBooksListParams, QuickBooksQueryResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksListQuery, + buildQuickBooksQueryUrl, + extractQuickBooksRecords, + getQuickBooksQueryMetadata, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksListPurchaseOrdersTool: ToolConfig< + QuickBooksListParams, + QuickBooksQueryResponse +> = { + id: 'quickbooks_list_purchase_orders', + name: 'QuickBooks List Purchase Orders', + description: 'List purchase orders from QuickBooks Online', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + startPosition: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'One-based start position for QuickBooks query pagination', + }, + maxResults: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of purchase orders to return, up to 1000', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => + buildQuickBooksQueryUrl({ + realmId: params.realmId, + query: buildQuickBooksListQuery('PurchaseOrder', params), + apiEnvironment: params.apiEnvironment, + minorVersion: params.minorVersion, + }), + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + const data = await parseQuickBooksJson(response) + const { entity, items } = extractQuickBooksRecords(data, 'PurchaseOrder') + const metadata = getQuickBooksQueryMetadata(data) + + return { + success: true, + output: { + items, + entity, + totalCount: metadata.totalCount, + startPosition: metadata.startPosition, + maxResults: metadata.maxResults, + query: buildQuickBooksListQuery( + 'PurchaseOrder', + params ?? { accessToken: '', realmId: '' } + ), + }, + } + }, + + outputs: { + items: { + type: 'array', + description: 'Purchase orders returned by QuickBooks', + items: { + type: 'json', + properties: { + Id: { type: 'string', description: 'QuickBooks purchase order ID', optional: true }, + DocNumber: { + type: 'string', + description: 'Purchase order document number', + optional: true, + }, + TxnDate: { + type: 'string', + description: 'Purchase order transaction date', + optional: true, + }, + TotalAmt: { type: 'number', description: 'Purchase order total amount', optional: true }, + }, + }, + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + totalCount: { + type: 'number', + description: 'Total count returned by QuickBooks', + optional: true, + }, + startPosition: { + type: 'number', + description: 'Start position returned by QuickBooks', + optional: true, + }, + maxResults: { + type: 'number', + description: 'Maximum records returned by QuickBooks', + optional: true, + }, + query: { type: 'string', description: 'Query that was executed' }, + }, +} diff --git a/apps/sim/tools/quickbooks/list_records.ts b/apps/sim/tools/quickbooks/list_records.ts new file mode 100644 index 00000000000..601c20b06fa --- /dev/null +++ b/apps/sim/tools/quickbooks/list_records.ts @@ -0,0 +1,141 @@ +import type { QuickBooksListRecordsParams, QuickBooksQueryResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksListRecordsQuery, + buildQuickBooksQueryUrl, + extractQuickBooksRecords, + getQuickBooksQueryMetadata, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksListRecordsTool: ToolConfig< + QuickBooksListRecordsParams, + QuickBooksQueryResponse +> = { + id: 'quickbooks_list_records', + name: 'QuickBooks List Records', + description: 'List or filter records for a supported QuickBooks Online accounting entity', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Queryable QuickBooks entity name', + }, + whereClause: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks query WHERE clause without the WHERE keyword', + }, + orderBy: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks query ORDERBY clause without the ORDERBY keyword', + }, + startPosition: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'One-based start position for QuickBooks query pagination', + }, + maxResults: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of records to return, up to 1000', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => { + const { query } = buildQuickBooksListRecordsQuery(params) + return buildQuickBooksQueryUrl({ + realmId: params.realmId, + query, + apiEnvironment: params.apiEnvironment, + minorVersion: params.minorVersion, + }) + }, + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks list parameters are required') + const { entity, query } = buildQuickBooksListRecordsQuery(params) + const data = await parseQuickBooksJson(response) + const { items } = extractQuickBooksRecords(data, entity) + const metadata = getQuickBooksQueryMetadata(data) + + return { + success: true, + output: { + items, + entity, + totalCount: metadata.totalCount, + startPosition: metadata.startPosition, + maxResults: metadata.maxResults, + query, + }, + } + }, + + outputs: { + items: { + type: 'array', + description: 'QuickBooks records for the selected entity', + items: { + type: 'json', + description: 'Entity-specific QuickBooks record', + }, + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + totalCount: { + type: 'number', + description: 'Total count returned by QuickBooks', + optional: true, + }, + startPosition: { + type: 'number', + description: 'Start position returned by QuickBooks', + optional: true, + }, + maxResults: { + type: 'number', + description: 'Maximum records returned by QuickBooks', + optional: true, + }, + query: { type: 'string', description: 'Query that was executed' }, + }, +} diff --git a/apps/sim/tools/quickbooks/list_vendors.ts b/apps/sim/tools/quickbooks/list_vendors.ts new file mode 100644 index 00000000000..70b8ccce9c2 --- /dev/null +++ b/apps/sim/tools/quickbooks/list_vendors.ts @@ -0,0 +1,131 @@ +import type { QuickBooksListParams, QuickBooksQueryResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksListQuery, + buildQuickBooksQueryUrl, + extractQuickBooksRecords, + getQuickBooksQueryMetadata, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksListVendorsTool: ToolConfig = + { + id: 'quickbooks_list_vendors', + name: 'QuickBooks List Vendors', + description: 'List vendors from QuickBooks Online', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + activeOnly: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Only return active vendors', + }, + startPosition: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'One-based start position for QuickBooks query pagination', + }, + maxResults: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of vendors to return, up to 1000', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => + buildQuickBooksQueryUrl({ + realmId: params.realmId, + query: buildQuickBooksListQuery('Vendor', params), + apiEnvironment: params.apiEnvironment, + minorVersion: params.minorVersion, + }), + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + const data = await parseQuickBooksJson(response) + const { entity, items } = extractQuickBooksRecords(data, 'Vendor') + const metadata = getQuickBooksQueryMetadata(data) + + return { + success: true, + output: { + items, + entity, + totalCount: metadata.totalCount, + startPosition: metadata.startPosition, + maxResults: metadata.maxResults, + query: buildQuickBooksListQuery('Vendor', params ?? { accessToken: '', realmId: '' }), + }, + } + }, + + outputs: { + items: { + type: 'array', + description: 'Vendors returned by QuickBooks', + items: { + type: 'json', + properties: { + Id: { type: 'string', description: 'QuickBooks vendor ID', optional: true }, + DisplayName: { type: 'string', description: 'Vendor display name', optional: true }, + Active: { + type: 'boolean', + description: 'Whether the vendor is active', + optional: true, + }, + }, + }, + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + totalCount: { + type: 'number', + description: 'Total count returned by QuickBooks', + optional: true, + }, + startPosition: { + type: 'number', + description: 'Start position returned by QuickBooks', + optional: true, + }, + maxResults: { + type: 'number', + description: 'Maximum records returned by QuickBooks', + optional: true, + }, + query: { type: 'string', description: 'Query that was executed' }, + }, + } diff --git a/apps/sim/tools/quickbooks/query.ts b/apps/sim/tools/quickbooks/query.ts new file mode 100644 index 00000000000..8bd6c9c8360 --- /dev/null +++ b/apps/sim/tools/quickbooks/query.ts @@ -0,0 +1,109 @@ +import type { QuickBooksQueryParams, QuickBooksQueryResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksQueryEndpoint, + extractQuickBooksRecords, + getQuickBooksQueryMetadata, + normalizeQuickBooksQuery, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksQueryTool: ToolConfig = { + id: 'quickbooks_query', + name: 'QuickBooks Query', + description: 'Run a QuickBooks Online Accounting API query', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks SQL-like query, e.g. SELECT * FROM Vendor MAXRESULTS 10', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => buildQuickBooksQueryEndpoint(params), + method: 'POST', + headers: (params) => ({ + ...buildQuickBooksHeaders(params.accessToken), + 'Content-Type': 'application/text', + }), + body: (params) => normalizeQuickBooksQuery(params.query), + }, + + transformResponse: async (response, params) => { + const data = await parseQuickBooksJson(response) + const { entity, items } = extractQuickBooksRecords(data) + const metadata = getQuickBooksQueryMetadata(data) + + return { + success: true, + output: { + items, + entity, + totalCount: metadata.totalCount, + startPosition: metadata.startPosition, + maxResults: metadata.maxResults, + query: params?.query ?? '', + }, + } + }, + + outputs: { + items: { + type: 'array', + description: 'Records returned by the QuickBooks query', + items: { type: 'json', description: 'QuickBooks entity record' }, + }, + entity: { + type: 'string', + description: 'QuickBooks entity name returned by the query', + optional: true, + }, + totalCount: { + type: 'number', + description: 'Total count returned by QuickBooks for the query', + optional: true, + }, + startPosition: { + type: 'number', + description: 'Start position returned by QuickBooks', + optional: true, + }, + maxResults: { + type: 'number', + description: 'Maximum records returned by QuickBooks', + optional: true, + }, + query: { type: 'string', description: 'Query that was executed' }, + }, +} diff --git a/apps/sim/tools/quickbooks/quickbooks.test.ts b/apps/sim/tools/quickbooks/quickbooks.test.ts new file mode 100644 index 00000000000..d547238f0b1 --- /dev/null +++ b/apps/sim/tools/quickbooks/quickbooks.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' +import { quickBooksListVendorsTool } from '@/tools/quickbooks/list_vendors' +import { quickBooksQueryTool } from '@/tools/quickbooks/query' +import { + buildQuickBooksListQuery, + buildQuickBooksQueryEndpoint, + buildQuickBooksQueryUrl, +} from '@/tools/quickbooks/utils' + +describe('QuickBooks tools', () => { + it('builds encoded query URLs with realmId pagination and default minor version', () => { + const query = buildQuickBooksListQuery('Vendor', { + activeOnly: true, + startPosition: '5', + maxResults: '25', + }) + const url = buildQuickBooksQueryUrl({ realmId: '123145', query }) + + expect(url).toBe( + 'https://quickbooks.api.intuit.com/v3/company/123145/query?minorversion=75&query=SELECT+*+FROM+Vendor+WHERE+Active+%3D+true+STARTPOSITION+5+MAXRESULTS+25' + ) + }) + + it('builds custom query POST requests without putting SQL in the URL', () => { + const params = { + accessToken: 'token', + realmId: '123145', + query: ' SELECT * FROM Vendor MAXRESULTS 10 ', + } + + const requestUrl = quickBooksQueryTool.request.url + if (typeof requestUrl !== 'function') { + throw new Error('Expected QuickBooks query URL to be dynamic') + } + + const requestBody = quickBooksQueryTool.request.body + if (!requestBody) { + throw new Error('Expected QuickBooks query request body') + } + + expect(requestUrl(params)).toBe(buildQuickBooksQueryEndpoint(params)) + expect(quickBooksQueryTool.request.method).toBe('POST') + expect(quickBooksQueryTool.request.headers(params)['Content-Type']).toBe('application/text') + expect(requestBody(params)).toBe('SELECT * FROM Vendor MAXRESULTS 10') + }) + + it('targets the QuickBooks sandbox API host when requested', () => { + const query = buildQuickBooksListQuery('Bill', { maxResults: '10' }) + const url = buildQuickBooksQueryUrl({ + realmId: '123145', + query, + apiEnvironment: 'sandbox', + }) + + expect(url).toBe( + 'https://sandbox-quickbooks.api.intuit.com/v3/company/123145/query?minorversion=75&query=SELECT+*+FROM+Bill+MAXRESULTS+10' + ) + }) + + it('rejects unknown QuickBooks API environments', () => { + expect(() => + buildQuickBooksQueryEndpoint({ + realmId: '123145', + apiEnvironment: 'development', + }) + ).toThrow('QuickBooks environment must be production or sandbox') + }) + + it('rejects invalid pagination before calling QuickBooks', () => { + expect(() => + buildQuickBooksListQuery('Bill', { + startPosition: '0', + }) + ).toThrow('Start position must be a positive integer') + }) + + it('rejects maxResults above QuickBooks query limit', () => { + expect(() => + buildQuickBooksListQuery('Bill', { + maxResults: '1001', + }) + ).toThrow('Max results must be 1000 or less') + }) + + it('transforms entity list QueryResponse arrays', async () => { + const response = new Response( + JSON.stringify({ + QueryResponse: { + Vendor: [{ Id: '1', DisplayName: 'Acme Supplies', Active: true }], + startPosition: 1, + maxResults: 1, + totalCount: 1, + }, + }), + { status: 200 } + ) + + const result = await quickBooksListVendorsTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + maxResults: '1', + }) + + expect(result?.output).toEqual({ + items: [{ Id: '1', DisplayName: 'Acme Supplies', Active: true }], + entity: 'Vendor', + totalCount: 1, + startPosition: 1, + maxResults: 1, + query: 'SELECT * FROM Vendor MAXRESULTS 1', + }) + }) + + it('transforms empty custom query responses without guessing an entity', async () => { + const response = new Response( + JSON.stringify({ + QueryResponse: { + totalCount: 0, + }, + }), + { status: 200 } + ) + + const result = await quickBooksQueryTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + query: 'SELECT * FROM PurchaseOrder MAXRESULTS 10', + }) + + expect(result?.output).toEqual({ + items: [], + entity: null, + totalCount: 0, + startPosition: null, + maxResults: null, + query: 'SELECT * FROM PurchaseOrder MAXRESULTS 10', + }) + }) + + it('rejects nested QueryResponse faults even when QuickBooks returns HTTP 200', async () => { + const response = new Response( + JSON.stringify({ + QueryResponse: { + Fault: { + Error: [ + { + Message: 'Invalid query', + Detail: 'QueryValidationError: Property Name not found', + code: '4001', + }, + ], + }, + }, + }), + { status: 200 } + ) + + await expect( + quickBooksQueryTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + query: 'SELECT Name FROM Vendor', + }) + ).rejects.toThrow('QuickBooks API error (200): QueryValidationError: Property Name not found') + }) + + it('includes non-JSON QuickBooks error responses in thrown errors', async () => { + const response = new Response('Service unavailable', { + status: 503, + statusText: 'Service Unavailable', + }) + + await expect( + quickBooksQueryTool.transformResponse?.(response, { + accessToken: 'token', + realmId: '123145', + query: 'SELECT * FROM Vendor', + }) + ).rejects.toThrow('QuickBooks API error (503): Service unavailable') + }) +}) diff --git a/apps/sim/tools/quickbooks/run_report.ts b/apps/sim/tools/quickbooks/run_report.ts new file mode 100644 index 00000000000..3a0cd9e944c --- /dev/null +++ b/apps/sim/tools/quickbooks/run_report.ts @@ -0,0 +1,116 @@ +import type { QuickBooksReportParams, QuickBooksReportResponse } from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksReportUrl, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksRunReportTool: ToolConfig = + { + id: 'quickbooks_run_report', + name: 'QuickBooks Run Report', + description: 'Run a supported QuickBooks Online financial or accounting report', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + report: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks Reports API endpoint name', + }, + reportParams: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: 'Report query parameters such as start_date, end_date, and accounting_method', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => buildQuickBooksReportUrl(params).url, + method: 'GET', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks report parameters are required') + const { report } = buildQuickBooksReportUrl(params) + const data = await parseQuickBooksJson(response) + if ( + !isQuickBooksReportSection(data.Header) || + !isQuickBooksReportSection(data.Columns) || + !isQuickBooksReportSection(data.Rows) + ) { + throw new Error('QuickBooks report response did not include Header, Columns, and Rows') + } + + return { + success: true, + output: { + report, + header: data.Header, + columns: data.Columns, + rows: data.Rows, + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + + outputs: { + report: { type: 'string', description: 'QuickBooks report endpoint name' }, + header: { + type: 'json', + description: 'Report header metadata, including period, basis, and currency when returned', + }, + columns: { + type: 'json', + description: 'QuickBooks report column definitions', + }, + rows: { + type: 'json', + description: 'QuickBooks report rows, including nested sections and summaries', + }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, + } + +function isQuickBooksReportSection(value: unknown): value is Record { + return ( + value != null && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length > 0 + ) +} diff --git a/apps/sim/tools/quickbooks/send_document.ts b/apps/sim/tools/quickbooks/send_document.ts new file mode 100644 index 00000000000..72d319aae13 --- /dev/null +++ b/apps/sim/tools/quickbooks/send_document.ts @@ -0,0 +1,110 @@ +import { truncate } from '@sim/utils/string' +import { readResponseToBufferWithLimit } from '@/lib/core/utils/stream-limits' +import type { QuickBooksFileResponse, QuickBooksSendDocumentParams } from '@/tools/quickbooks/types' +import { + assertQuickBooksPdfResponse, + buildQuickBooksHeaders, + buildQuickBooksSendDocumentUrl, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +const QUICKBOOKS_MAX_SENT_DOCUMENT_BYTES = 25 * 1024 * 1024 + +export const quickBooksSendDocumentTool: ToolConfig< + QuickBooksSendDocumentParams, + QuickBooksFileResponse +> = { + id: 'quickbooks_send_document', + name: 'QuickBooks Send Document', + description: 'Email a supported QuickBooks transaction and return its rendered PDF', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks transaction type that supports email delivery', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks transaction ID', + }, + sendTo: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Recipient email address; defaults to the address stored on the transaction', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: (params) => buildQuickBooksSendDocumentUrl(params).url, + method: 'POST', + headers: (params) => { + const { Authorization } = buildQuickBooksHeaders(params.accessToken) + return { Authorization, Accept: 'application/octet-stream' } + }, + }, + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks document parameters are required') + const { entity } = buildQuickBooksSendDocumentUrl(params) + const buffer = await readResponseToBufferWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_SENT_DOCUMENT_BYTES, + label: 'QuickBooks sent document response', + }) + if (!response.ok) { + throw new Error( + `QuickBooks API error (${response.status}): ${truncate(buffer.toString('utf8'), 500) || response.statusText || 'Request failed'}` + ) + } + assertQuickBooksPdfResponse(response, buffer, 'sent document') + return { + success: true, + output: { + file: { + name: `${entity}-${params.recordId.trim()}.pdf`, + mimeType: 'application/pdf', + data: buffer.toString('base64'), + size: buffer.length, + }, + entity, + recordId: params.recordId.trim(), + }, + } + }, + outputs: { + file: { + type: 'file', + description: 'QuickBooks PDF returned after email delivery', + fileConfig: { mimeType: 'application/pdf', extension: 'pdf' }, + }, + entity: { type: 'string', description: 'QuickBooks transaction type' }, + recordId: { type: 'string', description: 'QuickBooks transaction ID' }, + }, +} diff --git a/apps/sim/tools/quickbooks/types.ts b/apps/sim/tools/quickbooks/types.ts new file mode 100644 index 00000000000..b837669f848 --- /dev/null +++ b/apps/sim/tools/quickbooks/types.ts @@ -0,0 +1,435 @@ +import type { ToolFileData, ToolResponse } from '@/tools/types' + +export const QUICKBOOKS_QUERYABLE_ENTITIES = [ + 'Account', + 'Attachable', + 'Bill', + 'BillPayment', + 'Budget', + 'Class', + 'CompanyCurrency', + 'CreditCardPayment', + 'CreditMemo', + 'Customer', + 'Department', + 'Deposit', + 'Employee', + 'Estimate', + 'Invoice', + 'InventoryAdjustment', + 'Item', + 'JournalCode', + 'JournalEntry', + 'Payment', + 'PaymentMethod', + 'Purchase', + 'PurchaseOrder', + 'RecurringTransaction', + 'RefundReceipt', + 'SalesReceipt', + 'TaxAgency', + 'TaxCode', + 'TaxPayment', + 'TaxRate', + 'Term', + 'TimeActivity', + 'Transfer', + 'Vendor', + 'VendorCredit', +] as const + +export const QUICKBOOKS_READABLE_ENTITIES = [ + 'Account', + 'Attachable', + 'Bill', + 'BillPayment', + 'Budget', + 'Class', + 'CompanyCurrency', + 'CompanyInfo', + 'CreditCardPayment', + 'CreditMemo', + 'Customer', + 'Department', + 'Deposit', + 'Employee', + 'Estimate', + 'Invoice', + 'InventoryAdjustment', + 'Item', + 'JournalCode', + 'JournalEntry', + 'Payment', + 'PaymentMethod', + 'Purchase', + 'PurchaseOrder', + 'RecurringTransaction', + 'RefundReceipt', + 'SalesReceipt', + 'TaxAgency', + 'TaxCode', + 'TaxPayment', + 'TaxRate', + 'Term', + 'TimeActivity', + 'Transfer', + 'Vendor', + 'VendorCredit', +] as const + +export const QUICKBOOKS_CREATABLE_ENTITIES = [ + 'Account', + 'Attachable', + 'Bill', + 'BillPayment', + 'Class', + 'CompanyCurrency', + 'CreditCardPayment', + 'CreditMemo', + 'Customer', + 'Department', + 'Deposit', + 'Employee', + 'Estimate', + 'Invoice', + 'InventoryAdjustment', + 'Item', + 'JournalCode', + 'JournalEntry', + 'Payment', + 'PaymentMethod', + 'Purchase', + 'PurchaseOrder', + 'RecurringTransaction', + 'RefundReceipt', + 'SalesReceipt', + 'Term', + 'TimeActivity', + 'Transfer', + 'Vendor', + 'VendorCredit', +] as const + +export const QUICKBOOKS_UPDATABLE_ENTITIES = [ + ...QUICKBOOKS_CREATABLE_ENTITIES.filter((entity) => entity !== 'RecurringTransaction'), + 'CompanyInfo', +] as const + +export const QUICKBOOKS_SIMPLIFIED_DELETE_ENTITIES = [ + 'Bill', + 'BillPayment', + 'CreditMemo', + 'Estimate', + 'Invoice', + 'JournalEntry', + 'Payment', + 'Purchase', + 'PurchaseOrder', + 'RefundReceipt', + 'SalesReceipt', + 'TimeActivity', + 'VendorCredit', +] as const + +export const QUICKBOOKS_FULL_DELETE_ENTITIES = [ + 'Attachable', + 'CreditCardPayment', + 'Deposit', + 'InventoryAdjustment', + 'RecurringTransaction', + 'Transfer', +] as const + +export const QUICKBOOKS_DELETABLE_ENTITIES = [ + ...QUICKBOOKS_SIMPLIFIED_DELETE_ENTITIES, + ...QUICKBOOKS_FULL_DELETE_ENTITIES, +] as const + +export const QUICKBOOKS_ATTACHMENT_ENTITIES = [ + 'Bill', + 'BillPayment', + 'CreditCardPayment', + 'CreditMemo', + 'Deposit', + 'Estimate', + 'InventoryAdjustment', + 'Invoice', + 'Item', + 'JournalEntry', + 'Payment', + 'Purchase', + 'PurchaseOrder', + 'RefundReceipt', + 'SalesReceipt', + 'TaxPayment', + 'TimeActivity', + 'Transfer', + 'VendorCredit', +] as const + +export const QUICKBOOKS_CDC_ENTITIES = [...QUICKBOOKS_QUERYABLE_ENTITIES, 'CompanyInfo'].filter( + (entity) => !['JournalCode', 'TaxAgency', 'TaxCode', 'TaxRate', 'TimeActivity'].includes(entity) +) as Exclude< + (typeof QUICKBOOKS_QUERYABLE_ENTITIES)[number] | 'CompanyInfo', + 'JournalCode' | 'TaxAgency' | 'TaxCode' | 'TaxRate' | 'TimeActivity' +>[] + +export const QUICKBOOKS_REPORTS = [ + 'AccountListDetail', + 'AgedPayableDetail', + 'AgedPayables', + 'AgedReceivableDetail', + 'AgedReceivables', + 'BalanceSheet', + 'CashFlow', + 'ClassSales', + 'CustomerBalance', + 'CustomerBalanceDetail', + 'CustomerIncome', + 'CustomerSales', + 'DepartmentSales', + 'GeneralLedgerDetail', + 'InventoryValuationDetail', + 'InventoryValuationSummary', + 'ItemSales', + 'ProfitAndLoss', + 'ProfitAndLossDetail', + 'TaxSummary', + 'TrialBalance', + 'VendorBalance', + 'VendorBalanceDetail', + 'VendorExpenses', +] as const + +export const QUICKBOOKS_PDF_ENTITIES = [ + 'CreditMemo', + 'Estimate', + 'Invoice', + 'Payment', + 'PurchaseOrder', + 'RefundReceipt', + 'SalesReceipt', +] as const + +export const QUICKBOOKS_SENDABLE_ENTITIES = QUICKBOOKS_PDF_ENTITIES + +export type QuickBooksEntityName = (typeof QUICKBOOKS_QUERYABLE_ENTITIES)[number] | 'CompanyInfo' +export type QuickBooksAttachmentEntityName = (typeof QUICKBOOKS_ATTACHMENT_ENTITIES)[number] +export type QuickBooksReportName = (typeof QUICKBOOKS_REPORTS)[number] +export type QuickBooksPdfEntityName = (typeof QUICKBOOKS_PDF_ENTITIES)[number] +export type QuickBooksSendableEntityName = (typeof QUICKBOOKS_SENDABLE_ENTITIES)[number] +export type QuickBooksEnvironment = 'production' | 'sandbox' + +export interface QuickBooksBaseParams { + accessToken: string + realmId: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +} + +export interface QuickBooksListParams extends QuickBooksBaseParams { + startPosition?: string + maxResults?: string + activeOnly?: boolean | string +} + +export interface QuickBooksListRecordsParams extends QuickBooksListParams { + entity: QuickBooksEntityName | string + whereClause?: string + orderBy?: string +} + +export interface QuickBooksQueryParams extends QuickBooksBaseParams { + query: string +} + +export interface QuickBooksGetRecordParams extends QuickBooksBaseParams { + entity: QuickBooksEntityName | string + recordId: string +} + +export interface QuickBooksCreateRecordParams extends QuickBooksBaseParams { + entity: QuickBooksEntityName | string + payload: QuickBooksRecord | string +} + +export interface QuickBooksUpdateRecordParams extends QuickBooksCreateRecordParams { + recordId: string + syncToken: string + sparse?: boolean | string +} + +export interface QuickBooksDeleteRecordParams extends QuickBooksBaseParams { + entity: QuickBooksEntityName | string + recordId: string + syncToken: string + payload?: QuickBooksRecord | string +} + +export interface QuickBooksReportParams extends QuickBooksBaseParams { + report: QuickBooksReportName | string + reportParams?: QuickBooksRecord | string +} + +export interface QuickBooksCdcParams extends QuickBooksBaseParams { + entities: QuickBooksEntityName[] | string + changedSince: string +} + +export interface QuickBooksBatchParams extends QuickBooksBaseParams { + batch: QuickBooksRecord | string +} + +export interface QuickBooksPreferencesParams extends QuickBooksBaseParams {} + +export interface QuickBooksUpdatePreferencesParams extends QuickBooksBaseParams { + payload: QuickBooksRecord | string +} + +export interface QuickBooksExchangeRateParams extends QuickBooksBaseParams { + sourceCurrencyCode?: string + asOfDate?: string + payload?: QuickBooksRecord | string +} + +export interface QuickBooksDownloadDocumentParams extends QuickBooksBaseParams { + entity: QuickBooksPdfEntityName | string + recordId: string +} + +export interface QuickBooksSendDocumentParams extends QuickBooksBaseParams { + entity: QuickBooksSendableEntityName | string + recordId: string + sendTo?: string +} + +export interface QuickBooksAttachmentUrlParams extends QuickBooksBaseParams { + attachmentId: string + thumbnail?: boolean | string +} + +export interface QuickBooksUploadAttachmentParams extends QuickBooksBaseParams { + file: unknown + entity: string + entityId: string + note?: string + includeOnSend?: boolean | string +} + +export type QuickBooksRecord = Record + +export interface QuickBooksFault { + Error?: Array<{ + Message?: string + Detail?: string + code?: string + }> +} + +export interface QuickBooksApiEnvelope extends QuickBooksRecord { + AttachableResponse?: Array<{ + Attachable?: QuickBooksRecord + Fault?: QuickBooksFault + }> + QueryResponse?: Record & { + Fault?: QuickBooksFault + } + BatchItemResponse?: QuickBooksRecord[] + CDCResponse?: Array<{ + QueryResponse?: QuickBooksRecord[] + }> + Header?: QuickBooksRecord + Columns?: QuickBooksRecord + Rows?: QuickBooksRecord + Preferences?: QuickBooksRecord + ExchangeRate?: QuickBooksRecord + Fault?: QuickBooksFault + time?: string +} + +export interface QuickBooksQueryOutput { + items: QuickBooksRecord[] + entity: string | null + totalCount: number | null + startPosition: number | null + maxResults: number | null + query: string +} + +export interface QuickBooksQueryResponse extends ToolResponse { + output: QuickBooksQueryOutput +} + +export interface QuickBooksRecordOutput { + record: QuickBooksRecord + entity: string + time: string | null +} + +export interface QuickBooksRecordResponse extends ToolResponse { + output: QuickBooksRecordOutput +} + +export interface QuickBooksReportOutput { + report: string + header: QuickBooksRecord + columns: QuickBooksRecord + rows: QuickBooksRecord + time: string | null +} + +export interface QuickBooksReportResponse extends ToolResponse { + output: QuickBooksReportOutput +} + +export interface QuickBooksCdcOutput { + changes: QuickBooksRecord[] + changedSince: string + mayBeTruncated: boolean + time: string | null +} + +export interface QuickBooksCdcResponse extends ToolResponse { + output: QuickBooksCdcOutput +} + +export interface QuickBooksBatchOutput { + batchItems: QuickBooksRecord[] + time: string | null +} + +export interface QuickBooksBatchResponse extends ToolResponse { + output: QuickBooksBatchOutput +} + +export interface QuickBooksAttachmentUrlResponse extends ToolResponse { + output: { + url: string + attachmentId: string + thumbnail: boolean + } +} + +export interface QuickBooksFileResponse extends ToolResponse { + output: { + file: ToolFileData + entity: string + recordId: string + } +} + +export interface QuickBooksUploadAttachmentResponse extends ToolResponse { + output: { + result: QuickBooksRecord + } +} + +export type QuickBooksResponse = + | QuickBooksQueryResponse + | QuickBooksRecordResponse + | QuickBooksReportResponse + | QuickBooksCdcResponse + | QuickBooksBatchResponse + | QuickBooksAttachmentUrlResponse + | QuickBooksFileResponse + | QuickBooksUploadAttachmentResponse diff --git a/apps/sim/tools/quickbooks/update_exchange_rate.ts b/apps/sim/tools/quickbooks/update_exchange_rate.ts new file mode 100644 index 00000000000..16e52c9c9c9 --- /dev/null +++ b/apps/sim/tools/quickbooks/update_exchange_rate.ts @@ -0,0 +1,85 @@ +import type { + QuickBooksExchangeRateParams, + QuickBooksRecordResponse, +} from '@/tools/quickbooks/types' +import { + buildQuickBooksExchangeRateBody, + buildQuickBooksExchangeRateUpdateUrl, + buildQuickBooksHeaders, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksUpdateExchangeRateTool: ToolConfig< + QuickBooksExchangeRateParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_update_exchange_rate', + name: 'QuickBooks Update Exchange Rate', + description: 'Create or update a dated QuickBooks exchange rate', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + payload: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'ExchangeRate payload with SourceCurrencyCode, TargetCurrencyCode, Rate, AsOfDate, and SyncToken', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: (params) => buildQuickBooksExchangeRateUpdateUrl(params), + method: 'POST', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + body: (params) => buildQuickBooksExchangeRateBody(params.payload), + }, + transformResponse: async (response) => { + const data = await parseQuickBooksJson(response) + return { + success: true, + output: { + record: extractQuickBooksRecord(data, 'ExchangeRate'), + entity: 'ExchangeRate', + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + outputs: { + record: { + type: 'json', + description: 'Updated QuickBooks exchange rate', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/update_preferences.ts b/apps/sim/tools/quickbooks/update_preferences.ts new file mode 100644 index 00000000000..9283553ff4d --- /dev/null +++ b/apps/sim/tools/quickbooks/update_preferences.ts @@ -0,0 +1,84 @@ +import type { + QuickBooksRecordResponse, + QuickBooksUpdatePreferencesParams, +} from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksPreferencesBody, + buildQuickBooksPreferencesUrl, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksUpdatePreferencesTool: ToolConfig< + QuickBooksUpdatePreferencesParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_update_preferences', + name: 'QuickBooks Update Preferences', + description: 'Update supported QuickBooks company accounting and workflow preferences', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + payload: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks Preferences payload containing the supported settings to update', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: (params) => buildQuickBooksPreferencesUrl(params), + method: 'POST', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + body: (params) => buildQuickBooksPreferencesBody(params.payload), + }, + transformResponse: async (response) => { + const data = await parseQuickBooksJson(response) + return { + success: true, + output: { + record: extractQuickBooksRecord(data, 'Preferences'), + entity: 'Preferences', + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + outputs: { + record: { + type: 'json', + description: 'Updated QuickBooks company preferences', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/update_record.ts b/apps/sim/tools/quickbooks/update_record.ts new file mode 100644 index 00000000000..8d4358497ca --- /dev/null +++ b/apps/sim/tools/quickbooks/update_record.ts @@ -0,0 +1,120 @@ +import type { + QuickBooksRecordResponse, + QuickBooksUpdateRecordParams, +} from '@/tools/quickbooks/types' +import { + buildQuickBooksHeaders, + buildQuickBooksRecordUrl, + buildQuickBooksUpdateBody, + extractQuickBooksRecord, + parseQuickBooksJson, +} from '@/tools/quickbooks/utils' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksUpdateRecordTool: ToolConfig< + QuickBooksUpdateRecordParams, + QuickBooksRecordResponse +> = { + id: 'quickbooks_update_record', + name: 'QuickBooks Update Record', + description: 'Update a supported QuickBooks Online accounting record', + version: '1.0.0', + + oauth: { required: true, provider: 'quickbooks' }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Updatable QuickBooks entity name', + }, + recordId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks record ID', + }, + syncToken: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Latest QuickBooks SyncToken for the record', + }, + payload: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Fields to update using the selected entity API schema', + }, + sparse: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Use a sparse update so omitted fields are preserved. Defaults to true.', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + + request: { + url: (params) => + buildQuickBooksRecordUrl({ + ...params, + operation: 'update', + }).url, + method: 'POST', + headers: (params) => buildQuickBooksHeaders(params.accessToken), + body: (params) => buildQuickBooksUpdateBody(params), + }, + + transformResponse: async (response, params) => { + if (!params) throw new Error('QuickBooks record parameters are required') + const { entity } = buildQuickBooksRecordUrl({ ...params, operation: 'update' }) + const data = await parseQuickBooksJson(response) + + return { + success: true, + output: { + record: extractQuickBooksRecord(data, entity), + entity, + time: typeof data.time === 'string' ? data.time : null, + }, + } + }, + + outputs: { + record: { + type: 'json', + description: 'Updated entity-specific QuickBooks record', + }, + entity: { type: 'string', description: 'QuickBooks entity name' }, + time: { + type: 'string', + description: 'QuickBooks response timestamp', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/quickbooks/upload_attachment.ts b/apps/sim/tools/quickbooks/upload_attachment.ts new file mode 100644 index 00000000000..d076d257305 --- /dev/null +++ b/apps/sim/tools/quickbooks/upload_attachment.ts @@ -0,0 +1,104 @@ +import type { + QuickBooksUploadAttachmentParams, + QuickBooksUploadAttachmentResponse, +} from '@/tools/quickbooks/types' +import type { ToolConfig } from '@/tools/types' + +export const quickBooksUploadAttachmentTool: ToolConfig< + QuickBooksUploadAttachmentParams, + QuickBooksUploadAttachmentResponse +> = { + id: 'quickbooks_upload_attachment', + name: 'QuickBooks Upload Attachment', + description: 'Upload and link a supported file up to 25 MB to a QuickBooks transaction or item', + version: '1.0.0', + oauth: { required: true, provider: 'quickbooks' }, + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for QuickBooks Online', + }, + realmId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'QuickBooks company ID returned by Intuit as realmId during OAuth', + }, + file: { + type: 'file', + required: true, + visibility: 'user-only', + description: 'Supported attachment file up to 25 MB to upload and link in QuickBooks', + }, + entity: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks entity type to link the attachment to', + }, + entityId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'QuickBooks entity ID to link the attachment to', + }, + note: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional note stored with the attachment', + }, + includeOnSend: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Include the attachment when QuickBooks sends the linked transaction', + }, + apiEnvironment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks API environment: production or sandbox. Defaults to production.', + }, + minorVersion: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'QuickBooks Accounting API minor version. Defaults to 75.', + }, + }, + request: { + url: '/api/tools/quickbooks/upload-attachment', + method: 'POST', + headers: () => ({ 'Content-Type': 'application/json' }), + body: (params) => ({ + accessToken: params.accessToken, + realmId: params.realmId, + file: params.file, + entity: params.entity, + entityId: params.entityId, + note: params.note, + includeOnSend: + typeof params.includeOnSend === 'boolean' + ? params.includeOnSend + : params.includeOnSend === 'true', + apiEnvironment: params.apiEnvironment, + minorVersion: params.minorVersion, + }), + }, + transformResponse: async (response) => { + const data = await response.json() + if (!response.ok || data.success !== true) { + throw new Error(data.error || 'Failed to upload QuickBooks attachment') + } + return { success: true, output: { result: data.output.result } } + }, + outputs: { + result: { + type: 'json', + description: 'QuickBooks attachment upload response', + }, + }, +} diff --git a/apps/sim/tools/quickbooks/utils.ts b/apps/sim/tools/quickbooks/utils.ts new file mode 100644 index 00000000000..6b833751d9c --- /dev/null +++ b/apps/sim/tools/quickbooks/utils.ts @@ -0,0 +1,781 @@ +import { truncate } from '@sim/utils/string' +import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' +import { normalizeQuickBooksAccessToken } from '@/lib/oauth/quickbooks-token' +import type { + QuickBooksApiEnvelope, + QuickBooksAttachmentEntityName, + QuickBooksEntityName, + QuickBooksEnvironment, + QuickBooksFault, + QuickBooksPdfEntityName, + QuickBooksRecord, + QuickBooksReportName, + QuickBooksSendableEntityName, +} from '@/tools/quickbooks/types' +import { + QUICKBOOKS_ATTACHMENT_ENTITIES, + QUICKBOOKS_CDC_ENTITIES, + QUICKBOOKS_CREATABLE_ENTITIES, + QUICKBOOKS_DELETABLE_ENTITIES, + QUICKBOOKS_PDF_ENTITIES, + QUICKBOOKS_QUERYABLE_ENTITIES, + QUICKBOOKS_READABLE_ENTITIES, + QUICKBOOKS_REPORTS, + QUICKBOOKS_SENDABLE_ENTITIES, + QUICKBOOKS_SIMPLIFIED_DELETE_ENTITIES, + QUICKBOOKS_UPDATABLE_ENTITIES, +} from '@/tools/quickbooks/types' + +export const QUICKBOOKS_API_BASES: Record = { + production: 'https://quickbooks.api.intuit.com', + sandbox: 'https://sandbox-quickbooks.api.intuit.com', +} +export const QUICKBOOKS_MINOR_VERSION = '75' + +const QUICKBOOKS_MAX_RESULTS_LIMIT = 1000 +const QUICKBOOKS_MAX_BATCH_ITEMS = 10 +const QUICKBOOKS_MAX_CDC_RESULTS = 1000 +const QUICKBOOKS_MAX_RESPONSE_BYTES = 10 * 1024 * 1024 +const QUICKBOOKS_CDC_LOOKBACK_MS = 30 * 24 * 60 * 60 * 1000 +const QUICKBOOKS_CLOCK_SKEW_MS = 5 * 60 * 1000 +const QUICKBOOKS_ISO_DATE_PATTERN = + /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2}))?$/ +const QUICKBOOKS_METADATA_KEYS = new Set(['startPosition', 'maxResults', 'totalCount']) +const QUICKBOOKS_RESOURCE_NAMES: Record = { + Account: 'account', + Attachable: 'attachable', + Bill: 'bill', + BillPayment: 'billpayment', + Budget: 'budget', + Class: 'class', + CompanyCurrency: 'companycurrency', + CompanyInfo: 'companyinfo', + CreditCardPayment: 'creditcardpayment', + CreditMemo: 'creditmemo', + Customer: 'customer', + Department: 'department', + Deposit: 'deposit', + Employee: 'employee', + Estimate: 'estimate', + Invoice: 'invoice', + InventoryAdjustment: 'inventoryadjustment', + Item: 'item', + JournalCode: 'journalcode', + JournalEntry: 'journalentry', + Payment: 'payment', + PaymentMethod: 'paymentmethod', + Purchase: 'purchase', + PurchaseOrder: 'purchaseorder', + RecurringTransaction: 'recurringtransaction', + RefundReceipt: 'refundreceipt', + SalesReceipt: 'salesreceipt', + TaxAgency: 'taxagency', + TaxCode: 'taxcode', + TaxPayment: 'taxpayment', + TaxRate: 'taxrate', + Term: 'term', + TimeActivity: 'timeactivity', + Transfer: 'transfer', + Vendor: 'vendor', + VendorCredit: 'vendorcredit', +} + +export function buildQuickBooksHeaders(accessToken: string): Record { + return { + Authorization: `Bearer ${normalizeQuickBooksAccessToken(accessToken)}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + } +} + +export function assertQuickBooksPdfResponse( + response: Response, + buffer: Buffer, + label: string +): void { + const header = buffer.subarray(0, Math.min(buffer.length, 1024)) + if (header.indexOf(Buffer.from('%PDF-')) >= 0) return + + const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim() + const detail = truncate(buffer.toString('utf8').trim(), 500) + const contentTypeDetail = contentType ? ` (${contentType})` : '' + throw new Error( + `QuickBooks ${label} returned a non-PDF response${contentTypeDetail}: ${detail || 'Empty response'}` + ) +} + +export function buildQuickBooksQueryEndpoint(params: { + realmId: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + const realmId = params.realmId.trim() + if (!realmId) { + throw new Error('QuickBooks Company ID is required') + } + + const url = new URL( + `${getQuickBooksApiBase(params.apiEnvironment)}/v3/company/${encodeURIComponent(realmId)}/query` + ) + url.searchParams.set('minorversion', normalizeMinorVersion(params.minorVersion)) + return url.toString() +} + +export function buildQuickBooksQueryUrl(params: { + realmId: string + query: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + const url = new URL(buildQuickBooksQueryEndpoint(params)) + url.searchParams.set('query', normalizeQuickBooksQuery(params.query)) + return url.toString() +} + +export function buildQuickBooksListRecordsQuery(params: { + entity: QuickBooksEntityName | string + whereClause?: string + orderBy?: string + startPosition?: string + maxResults?: string +}): { entity: QuickBooksEntityName; query: string } { + const entity = normalizeQuickBooksEntity(params.entity, QUICKBOOKS_QUERYABLE_ENTITIES, 'queried') + const clauses = [`SELECT * FROM ${entity}`] + const whereClause = params.whereClause?.trim() + if (whereClause) clauses.push(`WHERE ${whereClause}`) + const orderBy = params.orderBy?.trim() + if (orderBy) clauses.push(`ORDERBY ${orderBy}`) + appendPagination(clauses, params) + return { entity, query: clauses.join(' ') } +} + +export function buildQuickBooksRecordUrl(params: { + realmId: string + entity: QuickBooksEntityName | string + operation: 'read' | 'create' | 'update' | 'delete' + recordId?: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): { entity: QuickBooksEntityName; url: string } { + const supportedEntities = + params.operation === 'read' + ? QUICKBOOKS_READABLE_ENTITIES + : params.operation === 'create' + ? QUICKBOOKS_CREATABLE_ENTITIES + : params.operation === 'update' + ? QUICKBOOKS_UPDATABLE_ENTITIES + : QUICKBOOKS_DELETABLE_ENTITIES + const operationLabel = { + read: 'read', + create: 'created', + update: 'updated', + delete: 'deleted', + }[params.operation] + const entity = normalizeQuickBooksEntity(params.entity, supportedEntities, operationLabel) + const resource = QUICKBOOKS_RESOURCE_NAMES[entity] + const url = buildQuickBooksCompanyUrl(params.realmId, resource, params) + + if (params.operation === 'read') { + const recordId = normalizeRequiredString(params.recordId, 'QuickBooks record ID') + url.pathname = `${url.pathname}/${encodeURIComponent(recordId)}` + } + if (params.operation === 'delete') { + url.searchParams.set('operation', 'delete') + } + + return { entity, url: url.toString() } +} + +export function buildQuickBooksCreateBody(payload: QuickBooksRecord | string): QuickBooksRecord { + return normalizeQuickBooksPayload(payload, 'QuickBooks record payload') +} + +export function buildQuickBooksUpdateBody(params: { + entity: QuickBooksEntityName | string + payload: QuickBooksRecord | string + recordId: string + syncToken: string + sparse?: boolean | string +}): QuickBooksRecord { + const entity = normalizeQuickBooksEntity(params.entity, QUICKBOOKS_UPDATABLE_ENTITIES, 'updated') + return { + ...normalizeQuickBooksPayload(params.payload, 'QuickBooks record payload'), + Id: normalizeRequiredString(params.recordId, 'QuickBooks record ID'), + SyncToken: normalizeRequiredString(params.syncToken, 'QuickBooks sync token'), + sparse: entity === 'InventoryAdjustment' ? true : toBoolean(params.sparse, true), + } +} + +export function buildQuickBooksDeleteBody(params: { + entity: QuickBooksEntityName | string + recordId: string + syncToken: string + payload?: QuickBooksRecord | string +}): QuickBooksRecord { + const entity = normalizeQuickBooksEntity(params.entity, QUICKBOOKS_DELETABLE_ENTITIES, 'deleted') + if (!QUICKBOOKS_SIMPLIFIED_DELETE_ENTITIES.some((candidate) => candidate === entity)) { + const payload = normalizeOptionalQuickBooksPayload(params.payload, 'QuickBooks delete payload') + if (Object.keys(payload).length === 0) { + throw new Error(`QuickBooks ${entity} deletion requires the full entity payload`) + } + return { + ...payload, + Id: normalizeRequiredString(params.recordId, 'QuickBooks record ID'), + SyncToken: normalizeRequiredString(params.syncToken, 'QuickBooks sync token'), + } + } + return { + Id: normalizeRequiredString(params.recordId, 'QuickBooks record ID'), + SyncToken: normalizeRequiredString(params.syncToken, 'QuickBooks sync token'), + } +} + +export function buildQuickBooksPreferencesUrl(params: { + realmId: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + return buildQuickBooksCompanyUrl(params.realmId, 'preferences', params).toString() +} + +export function buildQuickBooksPreferencesBody( + payload: QuickBooksRecord | string +): QuickBooksRecord { + return normalizeQuickBooksPayload(payload, 'QuickBooks preferences payload') +} + +export function buildQuickBooksExchangeRateUrl(params: { + realmId: string + sourceCurrencyCode?: string + asOfDate?: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + const url = buildQuickBooksCompanyUrl(params.realmId, 'exchangerate', params) + const sourceCurrencyCode = normalizeRequiredString( + params.sourceCurrencyCode, + 'Source currency code' + ).toUpperCase() + if (!/^[A-Z]{3}$/.test(sourceCurrencyCode)) { + throw new Error('Source currency code must be a three-letter ISO 4217 code') + } + url.searchParams.set('sourcecurrencycode', sourceCurrencyCode) + if (params.asOfDate) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(params.asOfDate)) { + throw new Error('Exchange rate date must use YYYY-MM-DD format') + } + url.searchParams.set('asofdate', params.asOfDate) + } + return url.toString() +} + +export function buildQuickBooksExchangeRateUpdateUrl(params: { + realmId: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + return buildQuickBooksCompanyUrl(params.realmId, 'exchangerate', params).toString() +} + +export function buildQuickBooksExchangeRateBody( + payload: QuickBooksRecord | string | undefined +): QuickBooksRecord { + return normalizeQuickBooksPayload(payload ?? '', 'QuickBooks exchange rate payload') +} + +export function buildQuickBooksDocumentUrl(params: { + realmId: string + entity: QuickBooksPdfEntityName | string + recordId: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): { entity: QuickBooksPdfEntityName; url: string } { + const entity = normalizeQuickBooksPdfEntity(params.entity) + const recordId = normalizeRequiredString(params.recordId, 'QuickBooks record ID') + const resource = QUICKBOOKS_RESOURCE_NAMES[entity] + const url = buildQuickBooksCompanyUrl( + params.realmId, + `${resource}/${encodeURIComponent(recordId)}/pdf`, + params + ) + return { entity, url: url.toString() } +} + +export function buildQuickBooksSendDocumentUrl(params: { + realmId: string + entity: QuickBooksSendableEntityName | string + recordId: string + sendTo?: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): { entity: QuickBooksSendableEntityName; url: string } { + const entity = normalizeQuickBooksSendableEntity(params.entity) + const recordId = normalizeRequiredString(params.recordId, 'QuickBooks record ID') + const resource = QUICKBOOKS_RESOURCE_NAMES[entity] + const url = buildQuickBooksCompanyUrl( + params.realmId, + `${resource}/${encodeURIComponent(recordId)}/send`, + params + ) + const sendTo = params.sendTo?.trim() + if (sendTo) { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(sendTo)) { + throw new Error('QuickBooks recipient must be a valid email address') + } + url.searchParams.set('sendTo', sendTo) + } + return { entity, url: url.toString() } +} + +export function buildQuickBooksAttachmentUrl(params: { + realmId: string + attachmentId: string + thumbnail?: boolean | string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): { thumbnail: boolean; url: string } { + const attachmentId = normalizeRequiredString(params.attachmentId, 'QuickBooks attachment ID') + const thumbnail = toBoolean(params.thumbnail) + const resource = thumbnail ? 'attachable-thumbnail' : 'download' + return { + thumbnail, + url: buildQuickBooksCompanyUrl( + params.realmId, + `${resource}/${encodeURIComponent(attachmentId)}`, + params + ).toString(), + } +} + +export function buildQuickBooksUploadUrl(params: { + realmId: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + return buildQuickBooksCompanyUrl(params.realmId, 'upload', params).toString() +} + +export function buildQuickBooksReportUrl(params: { + realmId: string + report: QuickBooksReportName | string + reportParams?: QuickBooksRecord | string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): { report: QuickBooksReportName; url: string } { + const report = normalizeQuickBooksReport(params.report) + const url = buildQuickBooksCompanyUrl(params.realmId, `reports/${report}`, params) + const reportParams = normalizeOptionalQuickBooksPayload( + params.reportParams, + 'QuickBooks report parameters' + ) + + for (const [key, value] of Object.entries(reportParams)) { + if (value == null || value === '') continue + if (!['string', 'number', 'boolean'].includes(typeof value)) { + throw new Error(`QuickBooks report parameter "${key}" must be a scalar value`) + } + url.searchParams.set(key, String(value)) + } + + return { report, url: url.toString() } +} + +export function buildQuickBooksCdcUrl(params: { + realmId: string + entities: QuickBooksEntityName[] | string + changedSince: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + const rawEntities = Array.isArray(params.entities) ? params.entities : params.entities.split(',') + const entities = rawEntities + .map((entity) => entity.trim()) + .filter(Boolean) + .map((entity) => normalizeQuickBooksEntity(entity, QUICKBOOKS_CDC_ENTITIES, 'tracked by CDC')) + if (entities.length === 0) { + throw new Error('At least one QuickBooks CDC entity is required') + } + const changedSince = normalizeRequiredString(params.changedSince, 'Changed since') + validateQuickBooksCdcDate(changedSince) + + const url = buildQuickBooksCompanyUrl(params.realmId, 'cdc', params) + url.searchParams.set('entities', [...new Set(entities)].join(',')) + url.searchParams.set('changedSince', changedSince) + return url.toString() +} + +export function buildQuickBooksBatchBody(batch: QuickBooksRecord | string): QuickBooksRecord { + const payload = normalizeQuickBooksPayload(batch, 'QuickBooks batch payload') + const items = payload.BatchItemRequest + if (!Array.isArray(items) || items.length === 0) { + throw new Error('QuickBooks batch payload must contain a non-empty BatchItemRequest array') + } + if (items.length > QUICKBOOKS_MAX_BATCH_ITEMS) { + throw new Error( + `QuickBooks batch payload must contain ${QUICKBOOKS_MAX_BATCH_ITEMS} items or less` + ) + } + return payload +} + +export function buildQuickBooksBatchUrl(params: { + realmId: string + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string +}): string { + return buildQuickBooksCompanyUrl(params.realmId, 'batch', params).toString() +} + +export function getQuickBooksApiBase(environment?: QuickBooksEnvironment | string): string { + const normalized = environment?.trim() || 'production' + if (normalized === 'production' || normalized === 'sandbox') { + return QUICKBOOKS_API_BASES[normalized] + } + throw new Error('QuickBooks environment must be production or sandbox') +} + +export function normalizeQuickBooksQuery(query: string): string { + const trimmed = query.trim() + if (!trimmed) { + throw new Error('QuickBooks query is required') + } + return trimmed +} + +export function buildQuickBooksListQuery( + entity: QuickBooksEntityName, + params: { startPosition?: string; maxResults?: string; activeOnly?: boolean | string } +): string { + const clauses = [`SELECT * FROM ${entity}`] + + if (entity === 'Vendor' && toBoolean(params.activeOnly)) { + clauses.push('WHERE Active = true') + } + + appendPagination(clauses, params) + + return clauses.join(' ') +} + +export async function parseQuickBooksJson(response: Response): Promise { + const text = await readResponseTextWithLimit(response, { + maxBytes: QUICKBOOKS_MAX_RESPONSE_BYTES, + label: 'QuickBooks API response', + }) + let data: QuickBooksApiEnvelope = {} + + if (text.trim()) { + try { + data = JSON.parse(text) as QuickBooksApiEnvelope + } catch { + throw new Error( + `QuickBooks API error (${response.status}): ${truncate(text.trim(), 500) || 'Invalid JSON response'}` + ) + } + } + + const faultMessage = extractQuickBooksError(data) + if (!response.ok || faultMessage) { + throw new Error( + `QuickBooks API error (${response.status}): ${ + faultMessage || response.statusText || 'Request failed' + }` + ) + } + + return data +} + +export function assertQuickBooksAttachmentUploadResponse( + data: QuickBooksApiEnvelope +): QuickBooksApiEnvelope { + const uploaded = data.AttachableResponse?.some((item) => hasQuickBooksRecordId(item.Attachable)) + if (!uploaded) { + throw new Error('QuickBooks attachment upload returned no attachment') + } + return data +} + +export function normalizeQuickBooksAttachmentEntity(value: string): QuickBooksAttachmentEntityName { + return normalizeQuickBooksEntity(value, QUICKBOOKS_ATTACHMENT_ENTITIES, 'linked to an attachment') +} + +export function extractQuickBooksRecords( + data: QuickBooksApiEnvelope, + preferredEntity?: QuickBooksEntityName +): { entity: string | null; items: QuickBooksRecord[] } { + const queryResponse = data.QueryResponse ?? {} + + if (preferredEntity) { + const records = queryResponse[preferredEntity] + return { + entity: preferredEntity, + items: Array.isArray(records) ? normalizeRecords(records) : [], + } + } + + const entity = Object.keys(queryResponse).find((key) => { + return !QUICKBOOKS_METADATA_KEYS.has(key) && Array.isArray(queryResponse[key]) + }) + + if (!entity) { + return { entity: null, items: [] } + } + + return { entity, items: normalizeRecords(queryResponse[entity]) } +} + +export function getQuickBooksQueryMetadata(data: QuickBooksApiEnvelope): { + totalCount: number | null + startPosition: number | null + maxResults: number | null +} { + const queryResponse = data.QueryResponse ?? {} + return { + totalCount: typeof queryResponse.totalCount === 'number' ? queryResponse.totalCount : null, + startPosition: + typeof queryResponse.startPosition === 'number' ? queryResponse.startPosition : null, + maxResults: typeof queryResponse.maxResults === 'number' ? queryResponse.maxResults : null, + } +} + +export function extractQuickBooksRecord( + data: QuickBooksApiEnvelope, + entity: string +): QuickBooksRecord { + const record = data[entity] + const requiresId = entity !== 'Preferences' && entity !== 'ExchangeRate' + if (!isNonEmptyQuickBooksRecord(record) || (requiresId && !hasQuickBooksRecordId(record))) { + throw new Error(`QuickBooks API response did not include ${entity}`) + } + return record +} + +export function extractQuickBooksCdcChanges(data: QuickBooksApiEnvelope): QuickBooksRecord[] { + const changes: QuickBooksRecord[] = [] + for (const cdcResponse of data.CDCResponse ?? []) { + for (const queryResponse of cdcResponse.QueryResponse ?? []) { + for (const [entity, records] of Object.entries(queryResponse)) { + if (QUICKBOOKS_METADATA_KEYS.has(entity) || !Array.isArray(records)) continue + changes.push({ + entity, + records: normalizeRecords(records), + startPosition: + typeof queryResponse.startPosition === 'number' ? queryResponse.startPosition : null, + maxResults: + typeof queryResponse.maxResults === 'number' ? queryResponse.maxResults : null, + totalCount: + typeof queryResponse.totalCount === 'number' ? queryResponse.totalCount : null, + }) + } + } + } + return changes +} + +export function quickBooksCdcMayBeTruncated(changes: QuickBooksRecord[]): boolean { + return ( + changes.reduce((count, change) => { + return count + (Array.isArray(change.records) ? change.records.length : 0) + }, 0) >= QUICKBOOKS_MAX_CDC_RESULTS + ) +} + +function normalizeMinorVersion(value?: string): string { + const trimmed = value?.trim() + return trimmed || QUICKBOOKS_MINOR_VERSION +} + +function buildQuickBooksCompanyUrl( + realmIdValue: string, + resourcePath: string, + params: { + apiEnvironment?: QuickBooksEnvironment | string + minorVersion?: string + } +): URL { + const realmId = normalizeRequiredString(realmIdValue, 'QuickBooks Company ID') + const url = new URL( + `${getQuickBooksApiBase(params.apiEnvironment)}/v3/company/${encodeURIComponent(realmId)}/${resourcePath}` + ) + url.searchParams.set('minorversion', normalizeMinorVersion(params.minorVersion)) + return url +} + +function normalizeQuickBooksEntity( + value: string, + supportedEntities: readonly T[], + operation: string +): T { + const normalized = value.trim().toLowerCase() + const entity = supportedEntities.find((candidate) => { + return ( + candidate.toLowerCase() === normalized || + QUICKBOOKS_RESOURCE_NAMES[candidate].toLowerCase() === normalized + ) + }) + if (!entity) { + throw new Error(`QuickBooks entity "${value.trim()}" cannot be ${operation}`) + } + return entity +} + +function normalizeQuickBooksReport(value: string): QuickBooksReportName { + const normalized = value.trim().toLowerCase() + const report = QUICKBOOKS_REPORTS.find((candidate) => candidate.toLowerCase() === normalized) + if (!report) { + throw new Error(`Unsupported QuickBooks report "${value.trim()}"`) + } + return report +} + +function normalizeQuickBooksPdfEntity(value: string): QuickBooksPdfEntityName { + const normalized = value.trim().toLowerCase() + const entity = QUICKBOOKS_PDF_ENTITIES.find((candidate) => candidate.toLowerCase() === normalized) + if (!entity) { + throw new Error(`QuickBooks entity "${value.trim()}" does not support PDF download`) + } + return entity +} + +function normalizeQuickBooksSendableEntity(value: string): QuickBooksSendableEntityName { + const normalized = value.trim().toLowerCase() + const entity = QUICKBOOKS_SENDABLE_ENTITIES.find( + (candidate) => candidate.toLowerCase() === normalized + ) + if (!entity) { + throw new Error(`QuickBooks entity "${value.trim()}" does not support email delivery`) + } + return entity +} + +function normalizeRequiredString(value: string | undefined, fieldLabel: string): string { + const trimmed = value?.trim() + if (!trimmed) { + throw new Error(`${fieldLabel} is required`) + } + return trimmed +} + +function validateQuickBooksCdcDate(value: string): void { + if (!QUICKBOOKS_ISO_DATE_PATTERN.test(value)) { + throw new Error('Changed since must be an ISO date or date-time') + } + const timestamp = Date.parse(value) + if (Number.isNaN(timestamp)) { + throw new Error('Changed since must be an ISO date or date-time') + } + const now = Date.now() + if (timestamp > now + QUICKBOOKS_CLOCK_SKEW_MS) { + throw new Error('Changed since cannot be in the future') + } + if (timestamp < now - QUICKBOOKS_CDC_LOOKBACK_MS) { + throw new Error('Changed since must be within the last 30 days') + } +} + +function normalizeQuickBooksPayload( + value: QuickBooksRecord | string, + fieldLabel: string +): QuickBooksRecord { + const payload = + typeof value === 'string' ? parseQuickBooksPayloadString(value, fieldLabel) : value + if (!isQuickBooksRecord(payload)) { + throw new Error(`${fieldLabel} must be a JSON object`) + } + return payload +} + +function normalizeOptionalQuickBooksPayload( + value: QuickBooksRecord | string | undefined, + fieldLabel: string +): QuickBooksRecord { + if (value == null || (typeof value === 'string' && value.trim() === '')) return {} + return normalizeQuickBooksPayload(value, fieldLabel) +} + +function parseQuickBooksPayloadString(value: string, fieldLabel: string): unknown { + try { + return JSON.parse(value) + } catch { + throw new Error(`${fieldLabel} must be valid JSON`) + } +} + +function appendPagination( + clauses: string[], + params: { startPosition?: string; maxResults?: string } +): void { + const startPosition = normalizePositiveInteger(params.startPosition, 'Start position') + if (startPosition) clauses.push(`STARTPOSITION ${startPosition}`) + + const maxResults = normalizePositiveInteger( + params.maxResults, + 'Max results', + QUICKBOOKS_MAX_RESULTS_LIMIT + ) + if (maxResults) clauses.push(`MAXRESULTS ${maxResults}`) +} + +function normalizePositiveInteger( + value: string | undefined, + fieldLabel: string, + max?: number +): string | null { + if (value == null || value.trim() === '') return null + + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${fieldLabel} must be a positive integer`) + } + + if (max !== undefined && parsed > max) { + throw new Error(`${fieldLabel} must be ${max} or less`) + } + + return String(parsed) +} + +function toBoolean(value: boolean | string | undefined, defaultValue = false): boolean { + if (typeof value === 'boolean') return value + if (!value) return defaultValue + return value.toLowerCase() === 'true' +} + +function normalizeRecords(value: unknown): QuickBooksRecord[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is QuickBooksRecord => { + return item != null && typeof item === 'object' && !Array.isArray(item) + }) +} + +function isQuickBooksRecord(value: unknown): value is QuickBooksRecord { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function isNonEmptyQuickBooksRecord(value: unknown): value is QuickBooksRecord { + return isQuickBooksRecord(value) && Object.keys(value).length > 0 +} + +function hasQuickBooksRecordId(value: unknown): value is QuickBooksRecord { + return isQuickBooksRecord(value) && typeof value.Id === 'string' && value.Id.trim().length > 0 +} + +function extractQuickBooksError(data: QuickBooksApiEnvelope): string | null { + const errors = [ + ...extractFaultErrors(data.Fault), + ...extractFaultErrors(data.QueryResponse?.Fault), + ...(data.AttachableResponse ?? []).flatMap((item) => extractFaultErrors(item.Fault)), + ] + const message = errors + .map((error) => error.Detail || error.Message || error.code) + .filter(Boolean) + .join('; ') + return message || null +} + +function extractFaultErrors( + fault: QuickBooksFault | undefined +): NonNullable { + return Array.isArray(fault?.Error) ? fault.Error : [] +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 06fdf3eb441..5daf2a014e2 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2981,6 +2981,28 @@ import { quartrListSlideDecksTool, quartrListTranscriptsTool, } from '@/tools/quartr' +import { + quickBooksBatchTool, + quickBooksCreateRecordTool, + quickBooksDeleteRecordTool, + quickBooksDownloadDocumentTool, + quickBooksGetAttachmentUrlTool, + quickBooksGetChangesTool, + quickBooksGetExchangeRateTool, + quickBooksGetPreferencesTool, + quickBooksGetRecordTool, + quickBooksListBillsTool, + quickBooksListPurchaseOrdersTool, + quickBooksListRecordsTool, + quickBooksListVendorsTool, + quickBooksQueryTool, + quickBooksRunReportTool, + quickBooksSendDocumentTool, + quickBooksUpdateExchangeRateTool, + quickBooksUpdatePreferencesTool, + quickBooksUpdateRecordTool, + quickBooksUploadAttachmentTool, +} from '@/tools/quickbooks' import { quiverImageToSvgTool, quiverListModelsTool, quiverTextToSvgTool } from '@/tools/quiver' import { railwayCreateEnvironmentTool, @@ -6021,6 +6043,26 @@ export const tools: Record = { postgresql_delete: postgresDeleteTool, postgresql_execute: postgresExecuteTool, postgresql_introspect: postgresIntrospectTool, + quickbooks_batch: quickBooksBatchTool, + quickbooks_create_record: quickBooksCreateRecordTool, + quickbooks_delete_record: quickBooksDeleteRecordTool, + quickbooks_download_document: quickBooksDownloadDocumentTool, + quickbooks_get_attachment_url: quickBooksGetAttachmentUrlTool, + quickbooks_get_changes: quickBooksGetChangesTool, + quickbooks_get_exchange_rate: quickBooksGetExchangeRateTool, + quickbooks_get_preferences: quickBooksGetPreferencesTool, + quickbooks_get_record: quickBooksGetRecordTool, + quickbooks_list_bills: quickBooksListBillsTool, + quickbooks_list_purchase_orders: quickBooksListPurchaseOrdersTool, + quickbooks_list_records: quickBooksListRecordsTool, + quickbooks_list_vendors: quickBooksListVendorsTool, + quickbooks_query: quickBooksQueryTool, + quickbooks_run_report: quickBooksRunReportTool, + quickbooks_send_document: quickBooksSendDocumentTool, + quickbooks_update_exchange_rate: quickBooksUpdateExchangeRateTool, + quickbooks_update_preferences: quickBooksUpdatePreferencesTool, + quickbooks_update_record: quickBooksUpdateRecordTool, + quickbooks_upload_attachment: quickBooksUploadAttachmentTool, rds_query: rdsQueryTool, rds_insert: rdsInsertTool, rds_update: rdsUpdateTool, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 8df1ad2a511..55353b1ed4c 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: 997, - zodRoutes: 997, + totalRoutes: 998, + zodRoutes: 998, nonZodRoutes: 0, } as const