diff --git a/apps/docs/content/docs/integrations/quickbooks.mdx b/apps/docs/content/docs/integrations/quickbooks.mdx index 942579aab7d..a9ac02c97a1 100644 --- a/apps/docs/content/docs/integrations/quickbooks.mdx +++ b/apps/docs/content/docs/integrations/quickbooks.mdx @@ -85,7 +85,7 @@ List or read one account, class, customer, department, employee, item, or vendor | `readMode` | string | Yes | Whether to list records or read one record by ID | | `recordId` | string | No | QuickBooks record ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `activeStatus` | string | No | List records using the QuickBooks default, active, or inactive status | #### Output @@ -364,7 +364,7 @@ Read, merge, and full-update a non-payroll employee profile | --------- | ---- | -------- | ----------- | | `employeeId` | string | Yes | ID of the employee to update | | `syncToken` | string | Yes | Current employee sync token | -| `displayName` | string | No | Replacement employee display name | +| `displayName` | string | No | Replacement employee display name. Read-only when QuickBooks Payroll is enabled, where QuickBooks derives it from the name components | | `givenName` | string | No | Replacement employee given name | | `familyName` | string | No | Replacement employee family name | | `primaryEmail` | string | No | Replacement employee primary email address | @@ -510,7 +510,7 @@ Create a Service or Non-inventory item in QuickBooks Online | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `name` | string | Yes | Unique item name | +| `name` | string | Yes | Unique item name, up to 100 characters, without tabs, new lines, or colons | | `itemType` | string | Yes | Writable item type: service or non_inventory | | `incomeAccountId` | string | No | Sales of Product Income account ID recording proceeds from the sale. Intuit requires it for Service items except in France locales | | `description` | string | No | Sales description | @@ -562,7 +562,7 @@ Create a Service or Non-inventory item in QuickBooks Online ### QuickBooks Update Item -Read, merge, and full-update an item without changing its type +Read, merge, and full-update a Service or Non-inventory item without changing its type #### Input @@ -570,7 +570,7 @@ Read, merge, and full-update an item without changing its type | --------- | ---- | -------- | ----------- | | `itemId` | string | Yes | ID of the item to update | | `syncToken` | string | Yes | Current item sync token | -| `name` | string | No | Replacement item name | +| `name` | string | No | Replacement item name, up to 100 characters, without tabs, new lines, or colons | | `incomeAccountId` | string | No | Replacement income account ID | | `description` | string | No | Replacement sales description | | `unitPrice` | number | No | Replacement sales price per unit | @@ -578,7 +578,7 @@ Read, merge, and full-update an item without changing its type | `purchaseCost` | number | No | Replacement purchase cost per unit | | `expenseAccountId` | string | No | Replacement expense account ID | | `taxable` | boolean | No | Whether the item is taxable | -| `activeStatus` | string | No | Item status change: unchanged, active, or inactive | +| `activeStatus` | string | No | Item status change: unchanged, active, or inactive. Not valid for Category item types | #### Output @@ -631,7 +631,7 @@ List or read one estimate, invoice, sales receipt, payment, credit memo, or refu | `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | | `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | | `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | | `customerId` | string | No | List transactions for one QuickBooks customer ID | @@ -1008,7 +1008,7 @@ Create a sales receipt for a completed customer sale | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `customerId` | string | Yes | Customer for the sales receipt | +| `customerId` | string | No | Customer for the sales receipt, omitted for an anonymous sale | | `lines` | json | Yes | Bounded item and description lines | | `transactionDate` | string | No | Sales receipt date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional sales receipt number | @@ -1121,6 +1121,60 @@ Sparse-update a sales receipt using its current sync token | ↳ `CreateTime` | string | Entity creation timestamp | | ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +### QuickBooks Void Sales Receipt + +Void a sales receipt after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | Sales receipt ID to void | +| `syncToken` | string | Yes | Current sales receipt sync token | +| `confirmVoid` | boolean | Yes | Explicit confirmation that the sales receipt should be voided | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `voided` | boolean | Whether QuickBooks voided the transaction | +| `record` | json | Voided native QuickBooks SalesReceipt | +| ↳ `Id` | string | QuickBooks sales transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Invoice due date | +| ↳ `ExpirationDate` | string | Estimate expiration date | +| ↳ `CustomerRef` | json | Customer reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `CustomerMemo` | json | Customer-facing memo | +| ↳ `DepositToAccountRef` | json | Deposit account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentMethodRef` | json | Payment method reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `PaymentRefNum` | string | Customer payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks transaction lines | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `UnappliedAmt` | number | Unapplied payment amount | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `TxnStatus` | string | Transaction status | +| ↳ `TxnTaxDetail` | json | Calculated tax details | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + ### QuickBooks Create Customer Payment Record a customer payment with optional bounded invoice allocations @@ -1418,7 +1472,7 @@ Create a customer refund receipt against a required deposit account | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `customerId` | string | Yes | Customer receiving the refund | +| `customerId` | string | No | Customer receiving the refund, omitted for an anonymous refund | | `lines` | json | Yes | Bounded item and description lines | | `depositAccountId` | string | Yes | QuickBooks bank account funding the refund | | `transactionDate` | string | No | Refund receipt date in YYYY-MM-DD format | @@ -1472,7 +1526,7 @@ Create a customer refund receipt against a required deposit account ### QuickBooks Update Refund Receipt -Read, merge, and full-update a refund receipt using its current sync token +Sparse-update a refund receipt using its current sync token #### Input @@ -1543,7 +1597,7 @@ List or read one purchase order, bill, bill payment, vendor credit, or purchase | `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | | `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | | `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | | `vendorId` | string | No | List transactions for one supported QuickBooks vendor ID | @@ -1558,7 +1612,8 @@ List or read one purchase order, bill, bill payment, vendor credit, or purchase | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1607,7 +1662,8 @@ List or read one purchase order, bill, bill payment, vendor credit, or purchase | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1672,6 +1728,9 @@ Create a purchase order with bounded expense lines | `transactionDate` | string | No | Purchase-order date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional purchase-order number | | `privateNote` | string | No | Internal purchase-order note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | +| `dueDate` | string | No | Date the payment is due in YYYY-MM-DD format | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -1687,7 +1746,8 @@ Create a purchase order with bounded expense lines | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1745,6 +1805,7 @@ Read, merge, and full-update purchase-order header fields | `vendorId` | string | No | Replacement vendor ID | | `apAccountId` | string | No | Replacement accounts-payable account ID | | `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | +| `dueDate` | string | No | Replacement due date in YYYY-MM-DD format | | `documentNumber` | string | No | Replacement purchase-order number | | `privateNote` | string | No | Replacement internal note | @@ -1761,7 +1822,8 @@ Read, merge, and full-update purchase-order header fields | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1821,6 +1883,8 @@ Create a vendor bill with optional Purchase Order line links without paying it | `dueDate` | string | No | Bill due date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional bill number | | `privateNote` | string | No | Internal bill note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -1846,7 +1910,8 @@ Create a vendor bill with optional Purchase Order line links without paying it | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1921,7 +1986,8 @@ Read, merge, and full-update bill header fields using its current sync token | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -1981,6 +2047,9 @@ Record a check or credit-card payment allocated to one or more bills | `billAllocations` | json | No | Optional bounded Bill-only allocations; any unallocated amount becomes vendor credit | | `transactionDate` | string | No | Payment date in YYYY-MM-DD format | | `privateNote` | string | No | Internal payment note | +| `apAccountId` | string | No | Optional accounts-payable account the payment is credited to | +| `documentNumber` | string | No | Optional reference number for the payment | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -1996,7 +2065,8 @@ Record a check or credit-card payment allocated to one or more bills | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2068,7 +2138,80 @@ Read, merge, and full-update a BillPayment without changing allocations | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | +| ↳ `VendorRef` | json | Vendor reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `APAccountRef` | json | Accounts-payable account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `AccountRef` | json | Payment account reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `EntityRef` | json | Purchase payee reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `type` | string | Referenced entity type | +| ↳ `PaymentType` | string | Purchase payment type | +| ↳ `PayType` | string | Bill-payment type | +| ↳ `CheckPayment` | json | Check payment account details | +| ↳ `CreditCardPayment` | json | Credit-card payment account details | +| ↳ `PaymentRefNum` | string | Payment reference number | +| ↳ `CurrencyRef` | json | Transaction currency reference | +| ↳ `value` | string | QuickBooks entity ID | +| ↳ `name` | string | QuickBooks entity display name | +| ↳ `Line` | array | Native QuickBooks expense or allocation lines | +| ↳ `Id` | string | QuickBooks transaction line ID | +| ↳ `LineNum` | number | QuickBooks transaction line number | +| ↳ `Description` | string | Transaction line description | +| ↳ `Amount` | number | Transaction line amount | +| ↳ `DetailType` | string | QuickBooks line detail type | +| ↳ `LinkedTxn` | array | Transactions linked to this QuickBooks line | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `AccountBasedExpenseLineDetail` | json | Native QuickBooks account-based expense details | +| ↳ `ItemBasedExpenseLineDetail` | json | Native QuickBooks item-based expense details | +| ↳ `LinkedTxn` | array | Transactions linked by QuickBooks | +| ↳ `TxnId` | string | Linked QuickBooks transaction ID | +| ↳ `TxnType` | string | Linked QuickBooks transaction type | +| ↳ `TxnLineId` | string | Linked QuickBooks transaction line ID | +| ↳ `TotalAmt` | number | Transaction total amount | +| ↳ `Balance` | number | Remaining transaction balance | +| ↳ `PrivateNote` | string | Internal transaction note | +| ↳ `MetaData` | json | Transaction creation and update timestamps | +| ↳ `CreateTime` | string | Entity creation timestamp | +| ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | + +### QuickBooks Void Bill Payment + +Void a bill payment after explicit confirmation + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `transactionId` | string | Yes | BillPayment ID to void | +| `syncToken` | string | Yes | Current BillPayment sync token | +| `confirmVoid` | boolean | Yes | Explicit confirmation that the bill payment should be voided | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `recordId` | string | ID of the created or updated QuickBooks entity | +| `syncToken` | string | Native QuickBooks SyncToken returned by the mutation | +| `recordVersion` | string | Latest QuickBooks record version required for a subsequent update; this is the native SyncToken under a display-safe name | +| `time` | string | QuickBooks response timestamp | +| `voided` | boolean | Whether QuickBooks voided the transaction | +| `record` | json | Voided native QuickBooks BillPayment | +| ↳ `Id` | string | QuickBooks purchasing transaction ID | +| ↳ `SyncToken` | string | Current transaction sync token | +| ↳ `DocNumber` | string | Transaction document number | +| ↳ `TxnDate` | string | Transaction date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2127,6 +2270,8 @@ Create a vendor credit without applying it to a bill | `transactionDate` | string | No | Credit date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional vendor-credit number | | `privateNote` | string | No | Internal vendor-credit note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2142,7 +2287,8 @@ Create a vendor credit without applying it to a bill | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2216,7 +2362,8 @@ Read, merge, and full-update vendor-credit header fields | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2276,6 +2423,8 @@ Record a cash, check, or credit-card purchase with bounded expense lines | `transactionDate` | string | No | Purchase date in YYYY-MM-DD format | | `paymentReference` | string | No | Optional transaction reference number, such as a check number, sent as the purchase DocNumber | | `privateNote` | string | No | Internal purchase note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2291,7 +2440,8 @@ Record a cash, check, or credit-card purchase with bounded expense lines | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2364,7 +2514,8 @@ Read, merge, and full-update purchase header fields without changing lines | ↳ `SyncToken` | string | Current transaction sync token | | ↳ `DocNumber` | string | Transaction document number | | ↳ `TxnDate` | string | Transaction date | -| ↳ `DueDate` | string | Bill due date | +| ↳ `DueDate` | string | Bill or purchase-order due date | +| ↳ `POStatus` | string | Purchase order status: Open or Closed | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2421,7 +2572,7 @@ List or read one journal entry, deposit, or transfer | `readMode` | string | Yes | Whether to list transactions or read one transaction by ID | | `transactionId` | string | No | QuickBooks transaction ID, required for by-ID reads | | `startPosition` | number | No | One-based position of the first list record to return | -| `maxResults` | number | No | Number of list records to request \(1–100\) | +| `maxResults` | number | No | Number of list records to request \(1–1000\) | | `startDate` | string | No | List transactions on or after this date in YYYY-MM-DD format | | `endDate` | string | No | List transactions on or before this date in YYYY-MM-DD format | @@ -2494,6 +2645,8 @@ Post a balanced journal entry after explicit confirmation | `transactionDate` | string | No | Journal-entry date in YYYY-MM-DD format | | `documentNumber` | string | No | Optional journal-entry number | | `privateNote` | string | No | Internal journal-entry note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded or TaxInclusive | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2585,6 +2738,8 @@ Create a deposit with bounded account lines | `lines` | json | Yes | One to 100 account-based deposit lines | | `transactionDate` | string | No | Deposit date in YYYY-MM-DD format | | `privateNote` | string | No | Internal deposit note | +| `currencyCode` | string | No | Three-letter ISO 4217 currency code, required when multicurrency is enabled for the company | +| `globalTaxCalculation` | string | No | Tax treatment required for non-US companies: TaxExcluded, TaxInclusive, or NotApplicable | | `requestId` | string | No | Optional Intuit idempotency request ID, up to 50 characters | #### Output @@ -2628,7 +2783,7 @@ Sparse-update deposit header fields using the current sync token and destination | --------- | ---- | -------- | ----------- | | `depositId` | string | Yes | Deposit ID to update | | `syncToken` | string | Yes | Current deposit sync token | -| `depositAccountId` | string | Yes | Current QuickBooks account receiving the deposit | +| `depositAccountId` | string | No | Replacement QuickBooks account receiving the deposit | | `transactionDate` | string | No | Replacement date in YYYY-MM-DD format | | `privateNote` | string | No | Replacement internal note | @@ -2674,11 +2829,14 @@ Run a fixed QuickBooks financial report with verified accountant-focused filters | `reportType` | string | Yes | Fixed QuickBooks financial report to run | | `startDate` | string | No | Report start date in YYYY-MM-DD format; Intuit recommends periods of six months or less for performance | | `endDate` | string | No | Report end or as-of date in YYYY-MM-DD format | +| `dateMacro` | string | No | Predefined QuickBooks report date range, such as this_fiscal_year_to_date; cannot be combined with startDate or endDate | | `accountingMethod` | string | No | Use the QuickBooks default, cash basis, or accrual basis | | `summarizeBy` | string | No | Time period or business dimension used to summarize report columns | +| `quickZoomUrl` | boolean | No | Ask QuickBooks to generate quick-zoom drill-down links, returned as the href on report row values | | `customerId` | string | No | Single QuickBooks customer ID filter | | `vendorId` | string | No | Single QuickBooks vendor ID filter | | `accountId` | string | No | Single QuickBooks account ID filter | +| `employeeId` | string | No | Single QuickBooks employee ID filter, supported by Profit and Loss Detail | | `itemId` | string | No | Single QuickBooks item ID filter | | `classId` | string | No | Single QuickBooks class ID filter | | `departmentId` | string | No | Single QuickBooks department ID filter | @@ -2709,6 +2867,7 @@ Run a fixed QuickBooks financial report with verified accountant-focused filters | ↳ `Customer` | string | Applied customer filter | | ↳ `Vendor` | string | Applied vendor filter | | ↳ `Account` | string | Applied account filter | +| ↳ `Employee` | string | Applied employee filter | | ↳ `Item` | string | Applied item filter | | ↳ `Class` | string | Applied class filter | | ↳ `Department` | string | Applied department filter | @@ -2796,6 +2955,7 @@ Send a supported QuickBooks transaction by email. This causes an external email | ↳ `MetaData` | json | Transaction creation and update timestamps | | ↳ `CreateTime` | string | Entity creation timestamp | | ↳ `LastUpdatedTime` | string | Entity last-updated timestamp | +| ↳ `POStatus` | string | Purchase order status | | ↳ `VendorRef` | json | Vendor reference | | ↳ `value` | string | QuickBooks entity ID | | ↳ `name` | string | QuickBooks entity display name | @@ -2813,7 +2973,6 @@ Send a supported QuickBooks transaction by email. This causes an external email | ↳ `PayType` | string | Bill-payment type | | ↳ `CheckPayment` | json | Check payment account details | | ↳ `CreditCardPayment` | json | Credit-card payment account details | -| ↳ `POStatus` | string | Purchase order status | | `time` | string | QuickBooks response timestamp | ### QuickBooks Download Transaction PDF diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts index a2be1cb8ae0..79b13a9336c 100644 --- a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts +++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.test.ts @@ -20,7 +20,7 @@ vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: vi.fn(() => ({ release: mockRelease })), })) vi.mock('@/lib/webhooks/quickbooks-credentials', () => ({ - getQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens, + streamQuickBooksWebhookVerifierTokensByAppKey: mockVerifierTokens, })) vi.mock('@/lib/core/utils/with-route-handler', () => ({ withRouteHandler: @@ -68,10 +68,16 @@ function callPost(webhookRequest: NextRequest, appKey = APP_KEY): Promise { + yield* tokens + }) +} + describe('QuickBooks webhook ingress route', () => { beforeEach(() => { vi.clearAllMocks() - mockVerifierTokens.mockResolvedValue(['verifier']) + mockTokens('verifier') requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('request-1') mockEnqueue.mockResolvedValue('job-1') }) @@ -97,19 +103,33 @@ describe('QuickBooks webhook ingress route', () => { }) it('accepts any verifier token configured by a connection for the same Intuit app', async () => { - mockVerifierTokens.mockResolvedValue(['stale-verifier', 'current-verifier']) + mockTokens('stale-verifier', 'current-verifier') expect((await callPost(signedRequest([validEvent], 'current-verifier'))).status).toBe(200) }) it('fails closed for unknown app keys and missing signatures', async () => { expect((await callPost(signedRequest([validEvent]), 'invalid')).status).toBe(404) - mockVerifierTokens.mockResolvedValueOnce([]) - expect((await callPost(signedRequest([validEvent]))).status).toBe(404) + mockTokens() + expect((await callPost(signedRequest([validEvent]))).status).toBe(401) + mockTokens('verifier') expect((await callPost(request(JSON.stringify([validEvent])))).status).toBe(401) expect(mockEnqueue).not.toHaveBeenCalled() }) + it('acknowledges a batch that carries an unmodelled event instead of stalling the app queue', async () => { + const unmodelledEvent = { ...validEvent, id: 'event-2', type: undefined } + const response = await callPost(signedRequest([validEvent, unmodelledEvent])) + + expect(response.status).toBe(200) + expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ events: [validEvent] })) + }) + + it('acknowledges a batch whose events are all unmodelled without enqueueing', async () => { + expect((await callPost(signedRequest([{ id: 'event-1' }]))).status).toBe(200) + expect(mockEnqueue).not.toHaveBeenCalled() + }) + it('rejects malformed signed payloads and batches over the event bound', async () => { expect((await callPost(signedRequest({ invalid: true }))).status).toBe(400) const events = Array.from({ length: 1001 }, (_, index) => ({ diff --git a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts index 3865dcecfce..518bde8b878 100644 --- a/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts +++ b/apps/sim/app/api/webhooks/quickbooks/[appKey]/route.ts @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { - quickBooksWebhookEventsSchema, + QUICKBOOKS_WEBHOOK_MAX_EVENTS, + type QuickBooksWebhookEvent, + quickBooksWebhookEventSchema, quickBooksWebhookParamsSchema, } from '@/lib/api/contracts/webhooks' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' @@ -14,8 +16,8 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants' -import { verifyQuickBooksSignatureAgainstVerifierTokens } from '@/lib/webhooks/providers/quickbooks' -import { getQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials' +import { verifyQuickBooksSignatureAgainstVerifierTokenStream } from '@/lib/webhooks/providers/quickbooks' +import { streamQuickBooksWebhookVerifierTokensByAppKey } from '@/lib/webhooks/quickbooks-credentials' import { enqueueQuickBooksWebhookIngress, type QuickBooksWebhookIngressPayload, @@ -62,14 +64,10 @@ export const POST = withRouteHandler( throw error } - const verifierTokens = await getQuickBooksWebhookVerifierTokensByAppKey(appKey) - if (verifierTokens.length === 0) { - return NextResponse.json({ error: 'Webhook not found' }, { status: 404 }) - } - const authError = verifyQuickBooksSignatureAgainstVerifierTokens( + const authError = await verifyQuickBooksSignatureAgainstVerifierTokenStream( rawBody, request.headers.get('intuit-signature'), - verifierTokens, + streamQuickBooksWebhookVerifierTokensByAppKey(appKey), requestId ) if (authError) return authError @@ -80,17 +78,33 @@ export const POST = withRouteHandler( } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) } - const parsed = quickBooksWebhookEventsSchema.safeParse(json) - if (!parsed.success) { - logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`, { - issues: parsed.error.issues, - }) + if ( + !Array.isArray(json) || + json.length === 0 || + json.length > QUICKBOOKS_WEBHOOK_MAX_EVENTS + ) { + logger.warn(`[${requestId}] Invalid QuickBooks webhook envelope`) return NextResponse.json({ error: 'Invalid webhook envelope' }, { status: 400 }) } + const events: QuickBooksWebhookEvent[] = [] + let droppedCount = 0 + for (const entry of json) { + const parsedEvent = quickBooksWebhookEventSchema.safeParse(entry) + if (parsedEvent.success) events.push(parsedEvent.data) + else droppedCount += 1 + } + if (droppedCount > 0) { + logger.warn(`[${requestId}] Dropped unmodelled QuickBooks webhook events`, { + droppedCount, + eventCount: json.length, + }) + } + if (events.length === 0) return NextResponse.json({ ok: true }) + const payload: QuickBooksWebhookIngressPayload = { appKey, - events: parsed.data, + events, headers: { 'content-type': request.headers.get('content-type') ?? 'application/json', }, @@ -99,7 +113,7 @@ export const POST = withRouteHandler( } const jobId = await enqueueQuickBooksWebhookIngress(payload) logger.info(`[${requestId}] Accepted QuickBooks webhook delivery`, { - eventCount: parsed.data.length, + eventCount: events.length, jobId, }) return NextResponse.json({ ok: true }) diff --git a/apps/sim/background/quickbooks-webhook-ingress.test.ts b/apps/sim/background/quickbooks-webhook-ingress.test.ts index 8fc7cfd7520..f0b1d113e87 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.test.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.test.ts @@ -125,6 +125,27 @@ describe('QuickBooks webhook ingress job', () => { expect(mockEnqueue).toHaveBeenCalledOnce() }) + it('ignores an event whose company identity can never be routed', async () => { + mockFindWebhooks.mockResolvedValue([]) + const unroutablePayload: QuickBooksWebhookIngressPayload = { + ...payload, + events: [{ ...event, intuitaccountid: 'not-a-realm' }, payload.events[1]], + } + + await expect(executeQuickBooksWebhookIngress(unroutablePayload)).resolves.toEqual({ + failed: 0, + ignored: 1, + processed: 0, + targetCount: 0, + }) + expect(mockFindWebhooks).toHaveBeenCalledOnce() + expect(mockFindWebhooks).toHaveBeenCalledWith( + `${payload.appKey}:789`, + 'request-1', + 'quickbooks' + ) + }) + it('continues later events when targets cannot be resolved', async () => { mockFindWebhooks .mockRejectedValueOnce(new Error('database unavailable')) diff --git a/apps/sim/background/quickbooks-webhook-ingress.ts b/apps/sim/background/quickbooks-webhook-ingress.ts index ae697aa7c1d..3ab7ed54623 100644 --- a/apps/sim/background/quickbooks-webhook-ingress.ts +++ b/apps/sim/background/quickbooks-webhook-ingress.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { task } from '@trigger.dev/sdk' import { NextRequest } from 'next/server' import type { QuickBooksWebhookEvent } from '@/lib/api/contracts/webhooks' @@ -37,6 +38,19 @@ export async function executeQuickBooksWebhookIngress( let targetCount = 0 for (const [eventIndex, event] of payload.events.entries()) { + let routingKey: string + try { + routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid) + } catch (error) { + ignored += 1 + logger.warn(`[${payload.requestId}] QuickBooks webhook event is not routable`, { + error: getErrorMessage(error, 'Unknown error'), + eventId: event.id, + eventIndex, + }) + continue + } + const request = new NextRequest( `http://internal/api/webhooks/quickbooks/${encodeURIComponent(payload.appKey)}`, { @@ -47,7 +61,6 @@ export async function executeQuickBooksWebhookIngress( ) try { - const routingKey = buildQuickBooksWebhookRoutingKey(payload.appKey, event.intuitaccountid) const targets = await findWebhooksByRoutingKey(routingKey, payload.requestId, 'quickbooks') targetCount += targets.length diff --git a/apps/sim/blocks/blocks/quickbooks.ts b/apps/sim/blocks/blocks/quickbooks.ts index e5594175141..762b570386a 100644 --- a/apps/sim/blocks/blocks/quickbooks.ts +++ b/apps/sim/blocks/blocks/quickbooks.ts @@ -5,13 +5,10 @@ import { AuthMode, IntegrationType } from '@/blocks/types' import { normalizeFileInput } from '@/blocks/utils' import { getQuickBooksReportTypesSupporting, - QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES, - QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES, - QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES, - QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES, type QuickBooksReportControl, } from '@/tools/quickbooks/report-metadata' import type { QuickBooksReportType, QuickBooksResponse } from '@/tools/quickbooks/types' +import { QUICKBOOKS_MAX_RESULTS } from '@/tools/quickbooks/values' import { getTrigger } from '@/triggers' const MASTER_DATA_OPERATION = 'quickbooks_read_master_data' @@ -60,6 +57,17 @@ const SALES_CREATE_OPERATIONS = [ ...SALES_DOCUMENT_CREATE_OPERATIONS, 'quickbooks_create_customer_payment', ] as const +/** + * Intuit lists `CustomerRef` in `invoicerequest`, `estimaterequest`, `creditmemorequest`, and + * `paymentrequest`, but not in `salesreceiptrequest` or `refundreceiptrequest`, so those two + * creates leave the customer optional. + */ +const SALES_CUSTOMER_REQUIRED_CREATE_OPERATIONS = [ + 'quickbooks_create_estimate', + 'quickbooks_create_invoice', + 'quickbooks_create_credit_memo', + 'quickbooks_create_customer_payment', +] as const const PURCHASING_CREATE_OPERATIONS = [ 'quickbooks_create_purchase_order', 'quickbooks_create_bill', @@ -84,7 +92,10 @@ const SALES_UPDATE_OPERATIONS = [ const SALES_VOID_OPERATIONS = [ 'quickbooks_void_invoice', 'quickbooks_void_customer_payment', + 'quickbooks_void_sales_receipt', ] as const +const PURCHASING_VOID_OPERATIONS = ['quickbooks_void_bill_payment'] as const +const VOID_OPERATIONS = [...SALES_VOID_OPERATIONS, ...PURCHASING_VOID_OPERATIONS] as const const MASTER_DATA_UPDATE_OPERATIONS = [ 'quickbooks_update_customer', 'quickbooks_update_employee', @@ -120,6 +131,7 @@ const UPDATE_OPERATIONS = [ ...SALES_UPDATE_OPERATIONS, ...SALES_VOID_OPERATIONS, ...PURCHASING_UPDATE_OPERATIONS, + ...PURCHASING_VOID_OPERATIONS, ...ACCOUNTING_UPDATE_OPERATIONS, ] as const const MUTATION_OPERATIONS = [ @@ -129,8 +141,34 @@ const MUTATION_OPERATIONS = [ ...VENDOR_OPERATIONS, ...SALES_MUTATION_OPERATIONS, ...PURCHASING_MUTATION_OPERATIONS, + ...PURCHASING_VOID_OPERATIONS, ...ACCOUNTING_MUTATION_OPERATIONS, ] as const +/** + * `CurrencyRef` is conditionally required on every one of these request models once multicurrency + * is enabled for the company, so each create must be able to send it. + */ +const CURRENCY_CODE_OPERATIONS = [ + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_bill_payment', + 'quickbooks_create_vendor_credit', + 'quickbooks_create_purchase', + 'quickbooks_create_journal_entry', + 'quickbooks_create_deposit', +] as const +/** + * `GlobalTaxCalculation` is documented on the same entities except BillPayment, whose + * `billpaymentresponse` model does not carry it. + */ +const GLOBAL_TAX_CALCULATION_OPERATIONS = [ + 'quickbooks_create_purchase_order', + 'quickbooks_create_bill', + 'quickbooks_create_vendor_credit', + 'quickbooks_create_purchase', + 'quickbooks_create_journal_entry', + 'quickbooks_create_deposit', +] as const const PAGINATED_OPERATIONS = [ MASTER_DATA_OPERATION, SALES_READ_OPERATION, @@ -222,7 +260,7 @@ function getQuickBooksTriggerSubBlocks(): SubBlockConfig[] { ) } -const REPORT_TIME_SUMMARY_OPTIONS = [ +const REPORT_SUMMARY_OPTIONS = [ { label: 'QuickBooks Default', id: 'default' }, { label: 'Total', id: 'total' }, { label: 'Day', id: 'day' }, @@ -230,6 +268,56 @@ const REPORT_TIME_SUMMARY_OPTIONS = [ { label: 'Month', id: 'month' }, { label: 'Quarter', id: 'quarter' }, { label: 'Year', id: 'year' }, + { label: 'Customer', id: 'customer' }, + { label: 'Vendor', id: 'vendor' }, + { label: 'Employee', id: 'employee' }, + { label: 'Product/Service', id: 'item' }, + { label: 'Class', id: 'class' }, + { label: 'Department', id: 'department' }, +] as const + +const REPORT_DATE_MACRO_OPTIONS = [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Today', id: 'today' }, + { label: 'Yesterday', id: 'yesterday' }, + { label: 'This Week', id: 'this_week' }, + { label: 'Last Week', id: 'last_week' }, + { label: 'This Week-to-date', id: 'this_week_to_date' }, + { label: 'Last Week-to-date', id: 'last_week_to_date' }, + { label: 'Next Week', id: 'next_week' }, + { label: 'Next 4 Weeks', id: 'next_4_weeks' }, + { label: 'This Month', id: 'this_month' }, + { label: 'Last Month', id: 'last_month' }, + { label: 'This Month-to-date', id: 'this_month_to_date' }, + { label: 'Last Month-to-date', id: 'last_month_to_date' }, + { label: 'Next Month', id: 'next_month' }, + { label: 'This Fiscal Quarter', id: 'this_fiscal_quarter' }, + { label: 'Last Fiscal Quarter', id: 'last_fiscal_quarter' }, + { label: 'This Fiscal Quarter-to-date', id: 'this_fiscal_quarter_to_date' }, + { label: 'Last Fiscal Quarter-to-date', id: 'last_fiscal_quarter_to_date' }, + { label: 'Next Fiscal Quarter', id: 'next_fiscal_quarter' }, + { label: 'This Fiscal Year', id: 'this_fiscal_year' }, + { label: 'Last Fiscal Year', id: 'last_fiscal_year' }, + { label: 'This Fiscal Year-to-date', id: 'this_fiscal_year_to_date' }, + { label: 'Last Fiscal Year-to-date', id: 'last_fiscal_year_to_date' }, + { label: 'Next Fiscal Year', id: 'next_fiscal_year' }, +] as const + +/** + * Intuit documents `TaxExcluded`, `TaxInclusive`, and `NotApplicable` on every entity carrying + * `GlobalTaxCalculation` except JournalEntry, whose model documents only the first two. + */ +const GLOBAL_TAX_CALCULATION_OPTIONS = [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Tax Excluded', id: 'TaxExcluded' }, + { label: 'Tax Inclusive', id: 'TaxInclusive' }, + { label: 'Not Applicable', id: 'NotApplicable' }, +] as const + +const JOURNAL_ENTRY_GLOBAL_TAX_OPTIONS = [ + { label: 'QuickBooks Default', id: 'default' }, + { label: 'Tax Excluded', id: 'TaxExcluded' }, + { label: 'Tax Inclusive', id: 'TaxInclusive' }, ] as const function parseJsonInput(value: unknown, fieldName: string): unknown { @@ -289,38 +377,6 @@ function reportSupports(reportType: unknown, control: QuickBooksReportControl): return getQuickBooksReportTypesSupporting(control).includes(reportType as QuickBooksReportType) } -function reportSummarizeValue(params: Record, reportType: unknown): unknown { - if ( - QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES)[number] - ) - ) { - return params.reportSummarizeBy ?? 'default' - } - if ( - QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES)[number] - ) - ) { - return params.reportCustomerSalesSummarizeBy ?? 'default' - } - if ( - QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES)[number] - ) - ) { - return params.reportVendorExpenseSummarizeBy ?? 'default' - } - if ( - QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES.includes( - reportType as (typeof QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES)[number] - ) - ) { - return params.reportTimeSummarizeBy ?? 'default' - } - return undefined -} - function parseOptionalPositiveInteger(value: unknown, fieldName: string): number | undefined { if (value == null || (typeof value === 'string' && value.trim() === '')) return undefined const parsed = typeof value === 'number' ? value : Number(value) @@ -341,8 +397,8 @@ function parsePaginationInteger( if (fieldName === 'startPosition' && parsed < 1) { throw new Error('startPosition must be a positive integer') } - if (fieldName === 'maxResults' && (parsed < 1 || parsed > 100)) { - throw new Error('maxResults must be an integer from 1 through 100') + if (fieldName === 'maxResults' && (parsed < 1 || parsed > QUICKBOOKS_MAX_RESULTS)) { + throw new Error(`maxResults must be an integer from 1 through ${QUICKBOOKS_MAX_RESULTS}`) } return parsed } @@ -354,6 +410,18 @@ function parseOptionalNumber(value: unknown, fieldName: string): number | undefi return parsed } +/** + * Coerces a switch value to the boolean `applyQuickBooksReportParams` demands. Lives here in + * `tools.config.params`, which runs after variable resolution, so a `` reference + * survives serialization. + */ +function parseOptionalBoolean(value: unknown, fieldName: string): boolean | undefined { + if (value == null || value === '') return undefined + if (value === true || value === 'true') return true + if (value === false || value === 'false') return false + throw new Error(`${fieldName} must be true or false`) +} + function parseTriStateBoolean(value: unknown, fieldName: string): boolean | undefined { if (value == null || value === '' || value === 'not_specified') return undefined if (value === true || value === 'yes') return true @@ -361,6 +429,11 @@ function parseTriStateBoolean(value: unknown, fieldName: string): boolean | unde throw new Error(`${fieldName} must be not specified, yes, or no`) } +/** Drops the QuickBooks-default sentinel so the create omits `GlobalTaxCalculation` entirely. */ +function selectedGlobalTaxCalculation(value: unknown): unknown { + return value == null || value === '' || value === 'default' ? undefined : value +} + function optionalValue(value: unknown): unknown { if (value == null) return undefined return typeof value === 'string' && value.trim() === '' ? undefined : value @@ -403,43 +476,56 @@ function paginationCondition(values?: Record) { return { field: 'operation', value: [] } } -function salesTransactionIdCondition(values?: Record) { +/** + * Operations whose `transactionId` names the entity a mutation rewrites or voids. + */ +const TRANSACTION_MUTATION_OPERATIONS = [ + ...SALES_UPDATE_OPERATIONS, + ...SALES_VOID_OPERATIONS, + ...PURCHASING_UPDATE_OPERATIONS, + ...PURCHASING_VOID_OPERATIONS, + ...ACCOUNTING_UPDATE_OPERATIONS, +] as const + +/** + * The by-ID read target, kept apart from the mutation `transactionId`. + * + * Subblock values are keyed by ID and are never cleared when the operation + * changes, so one shared control let a bill ID entered under Read Purchasing + * Transactions survive a switch to Update Purchase Order and silently address + * the wrong entity while the block still validated. + */ +function readTransactionIdCondition(values?: Record) { if (!values) { return { field: 'operation', - value: [ - SALES_READ_OPERATION, - PURCHASING_READ_OPERATION, - ACCOUNTING_READ_OPERATION, - ...SALES_UPDATE_OPERATIONS, - ...SALES_VOID_OPERATIONS, - ...PURCHASING_UPDATE_OPERATIONS, - ...ACCOUNTING_UPDATE_OPERATIONS, - ], + value: [SALES_READ_OPERATION, PURCHASING_READ_OPERATION, ACCOUNTING_READ_OPERATION], } } if ( - values?.operation === SALES_READ_OPERATION || - values?.operation === PURCHASING_READ_OPERATION || - values?.operation === ACCOUNTING_READ_OPERATION + values.operation === SALES_READ_OPERATION || + values.operation === PURCHASING_READ_OPERATION || + values.operation === ACCOUNTING_READ_OPERATION ) { return { field: 'readMode', value: 'by_id' } } - return { - field: 'operation', - value: [ - ...SALES_UPDATE_OPERATIONS, - ...SALES_VOID_OPERATIONS, - ...PURCHASING_UPDATE_OPERATIONS, - ...ACCOUNTING_UPDATE_OPERATIONS, - ], - } + return { field: 'operation', value: [] } } function parseConfirmation(value: unknown, fieldName: string): boolean { - if (value === true || value === 'yes') return true - if (value === false || value === 'no' || value == null || value === '') return false - throw new Error(`${fieldName} must be yes or no`) + switch (value) { + case true: + case 'yes': + return true + case false: + case 'no': + case null: + case undefined: + case '': + return false + default: + throw new Error(`${fieldName} must be yes or no`) + } } function attachmentTargetCondition(values?: Record) { @@ -552,6 +638,9 @@ export const QuickBooksBlock: BlockConfig = { quickbooks_update_sales_receipt: [ { text: 'Update sales receipt', field: 'transactionId', core: true }, ], + quickbooks_void_sales_receipt: [ + { text: 'Void sales receipt', field: 'transactionId', core: true }, + ], quickbooks_create_customer_payment: [ { text: 'Record payment from customer', @@ -620,6 +709,9 @@ export const QuickBooksBlock: BlockConfig = { quickbooks_update_bill_payment: [ { text: 'Update bill payment', field: 'transactionId', core: true }, ], + quickbooks_void_bill_payment: [ + { text: 'Void bill payment', field: 'transactionId', core: true }, + ], quickbooks_create_vendor_credit: [ { text: 'Create a credit for vendor', field: 'vendorId', core: true }, ], @@ -718,6 +810,10 @@ export const QuickBooksBlock: BlockConfig = { label: 'Update Sales Receipt', id: 'quickbooks_update_sales_receipt', }, + { + label: 'Void Sales Receipt', + id: 'quickbooks_void_sales_receipt', + }, { label: 'Create Customer Payment', id: 'quickbooks_create_customer_payment', @@ -756,6 +852,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Update Bill', id: 'quickbooks_update_bill' }, { label: 'Create Bill Payment', id: 'quickbooks_create_bill_payment' }, { label: 'Update Bill Payment', id: 'quickbooks_update_bill_payment' }, + { label: 'Void Bill Payment', id: 'quickbooks_void_bill_payment' }, { label: 'Create Vendor Credit', id: 'quickbooks_create_vendor_credit', @@ -1004,10 +1101,22 @@ export const QuickBooksBlock: BlockConfig = { id: 'attachmentFileName', title: 'File Name', type: 'short-input', - placeholder: 'Optional safe filename override', + placeholder: 'Optional uploaded filename override', condition: { field: 'operation', - value: [ADD_ATTACHMENT_OPERATION, DOWNLOAD_ATTACHMENT_OPERATION], + value: ADD_ATTACHMENT_OPERATION, + and: { field: 'attachmentKind', value: 'file' }, + }, + mode: 'advanced', + }, + { + id: 'downloadAttachmentFileName', + title: 'File Name', + type: 'short-input', + placeholder: 'Optional saved filename override', + condition: { + field: 'operation', + value: DOWNLOAD_ATTACHMENT_OPERATION, }, mode: 'advanced', }, @@ -1218,33 +1327,52 @@ export const QuickBooksBlock: BlockConfig = { required: { field: 'operation', value: ACCOUNTING_READ_OPERATION }, value: () => 'journal_entry', }, + { + id: 'readTransactionId', + title: 'Transaction ID', + type: 'short-input', + placeholder: 'QuickBooks transaction ID', + condition: readTransactionIdCondition, + required: readTransactionIdCondition, + }, { id: 'transactionId', title: 'Transaction ID', type: 'short-input', placeholder: 'QuickBooks transaction ID', - condition: salesTransactionIdCondition, - required: salesTransactionIdCondition, + condition: { field: 'operation', value: [...TRANSACTION_MUTATION_OPERATIONS] }, + required: { field: 'operation', value: [...TRANSACTION_MUTATION_OPERATIONS] }, }, { id: 'reportType', title: 'Report Type', type: 'dropdown', options: [ + { label: 'Account List Detail', id: 'account_list_detail' }, { label: 'Balance Sheet', id: 'balance_sheet' }, { label: 'Profit and Loss', id: 'profit_and_loss' }, { label: 'Profit and Loss Detail', id: 'profit_and_loss_detail' }, { label: 'Trial Balance', id: 'trial_balance' }, + { label: 'Trial Balance (France locale)', id: 'trial_balance_fr' }, { label: 'Statement of Cash Flows', id: 'cash_flow' }, + { label: 'General Ledger Detail', id: 'general_ledger_detail' }, { label: 'A/P Aging Summary', id: 'ap_aging_summary' }, { label: 'A/P Aging Detail', id: 'ap_aging_detail' }, { label: 'A/R Aging Summary', id: 'ar_aging_summary' }, { label: 'A/R Aging Detail', id: 'ar_aging_detail' }, { label: 'Vendor Balance Summary', id: 'vendor_balance' }, + { label: 'Vendor Balance Detail', id: 'vendor_balance_detail' }, { label: 'Customer Balance Summary', id: 'customer_balance' }, + { label: 'Customer Balance Detail', id: 'customer_balance_detail' }, + { label: 'Income by Customer Summary', id: 'customer_income' }, { label: 'Sales by Customer Summary', id: 'sales_by_customer' }, { label: 'Sales by Product/Service Summary', id: 'sales_by_item' }, + { label: 'Sales by Class Summary', id: 'sales_by_class' }, + { label: 'Sales by Department Summary', id: 'sales_by_department' }, { label: 'Expenses by Vendor', id: 'expenses_by_vendor' }, + { label: 'Inventory Valuation Summary', id: 'inventory_valuation_summary' }, + { label: 'Inventory Valuation Detail', id: 'inventory_valuation_detail' }, + { label: 'Tax Summary (non-US locale)', id: 'tax_summary' }, { label: 'Transaction List', id: 'transaction_list' }, ], condition: { field: 'operation', value: REPORT_OPERATION }, @@ -1286,87 +1414,32 @@ export const QuickBooksBlock: BlockConfig = { value: () => 'default', }, { - id: 'reportSummarizeBy', - title: 'Summarize Columns By', + id: 'reportDateMacro', + title: 'Date Range', type: 'dropdown', - options: [ - ...REPORT_TIME_SUMMARY_OPTIONS, - { label: 'Customer', id: 'customer' }, - { label: 'Vendor', id: 'vendor' }, - { label: 'Product/Service', id: 'item' }, - { label: 'Class', id: 'class' }, - { label: 'Department', id: 'department' }, - ], - mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_ALL_SUMMARIES], - }, - }, - value: () => 'default', - }, - { - id: 'reportCustomerSalesSummarizeBy', - title: 'Summarize Columns By', - type: 'dropdown', - options: [ - ...REPORT_TIME_SUMMARY_OPTIONS, - { label: 'Customer', id: 'customer' }, - { label: 'Product/Service', id: 'item' }, - { label: 'Class', id: 'class' }, - { label: 'Department', id: 'department' }, - ], + options: [...REPORT_DATE_MACRO_OPTIONS], + description: + 'Predefined report range. Cannot be combined with an explicit start or end date.', mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_CUSTOMER_SALES_SUMMARIES], - }, - }, + condition: reportControlCondition('dateMacro'), value: () => 'default', }, { - id: 'reportVendorExpenseSummarizeBy', + id: 'reportSummarizeBy', title: 'Summarize Columns By', type: 'dropdown', - options: [ - ...REPORT_TIME_SUMMARY_OPTIONS, - { label: 'Customer', id: 'customer' }, - { label: 'Vendor', id: 'vendor' }, - { label: 'Class', id: 'class' }, - { label: 'Department', id: 'department' }, - ], + options: [...REPORT_SUMMARY_OPTIONS], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_VENDOR_EXPENSE_SUMMARIES], - }, - }, + condition: reportControlCondition('summarizeBy'), value: () => 'default', }, { - id: 'reportTimeSummarizeBy', - title: 'Summarize Columns By', - type: 'dropdown', - options: [...REPORT_TIME_SUMMARY_OPTIONS], + id: 'reportQuickZoomUrl', + title: 'Include Quick Zoom Links', + type: 'switch', + description: 'Adds the QuickBooks drill-down href to each report row that supports one.', mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { - field: 'reportType', - value: [...QUICKBOOKS_REPORT_TYPES_WITH_TIME_SUMMARIES], - }, - }, - value: () => 'default', + condition: reportControlCondition('quickZoomUrl'), }, { id: 'reportCustomerId', @@ -1400,6 +1473,14 @@ export const QuickBooksBlock: BlockConfig = { mode: 'advanced', condition: reportControlCondition('itemId'), }, + { + id: 'reportEmployeeId', + title: 'Employee ID', + type: 'short-input', + placeholder: 'Use Read Master Data to find an employee ID', + mode: 'advanced', + condition: reportControlCondition('employeeId'), + }, { id: 'reportClassId', title: 'Class ID', @@ -1491,11 +1572,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Year', id: 'year' }, ], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { field: 'reportType', value: 'transaction_list' }, - }, + condition: reportControlCondition('groupBy'), value: () => 'default', }, { @@ -1509,11 +1586,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Unpaid', id: 'unpaid' }, ], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { field: 'reportType', value: 'transaction_list' }, - }, + condition: reportControlCondition('accountsPayablePaid'), value: () => 'default', }, { @@ -1527,11 +1600,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Unpaid', id: 'unpaid' }, ], mode: 'advanced', - condition: { - field: 'operation', - value: REPORT_OPERATION, - and: { field: 'reportType', value: 'transaction_list' }, - }, + condition: reportControlCondition('accountsReceivablePaid'), value: () => 'default', }, { @@ -1625,7 +1694,7 @@ export const QuickBooksBlock: BlockConfig = { }, required: { field: 'operation', - value: ['quickbooks_update_customer', ...SALES_CREATE_OPERATIONS], + value: ['quickbooks_update_customer', ...SALES_CUSTOMER_REQUIRED_CREATE_OPERATIONS], }, }, { @@ -1877,7 +1946,9 @@ export const QuickBooksBlock: BlockConfig = { id: 'incomeAccountId', title: 'Income Account ID', type: 'short-input', - placeholder: 'QuickBooks income account ID', + placeholder: 'Required for Service items outside France locales', + description: + 'QuickBooks requires an income account for Service items, except for companies on a France locale.', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, }, { @@ -1900,7 +1971,6 @@ export const QuickBooksBlock: BlockConfig = { type: 'long-input', placeholder: 'Item purchase description', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, - mode: 'advanced', }, { id: 'purchaseCost', @@ -1908,13 +1978,14 @@ export const QuickBooksBlock: BlockConfig = { type: 'short-input', placeholder: '0.00', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, - mode: 'advanced', }, { id: 'expenseAccountId', title: 'Expense Account ID', type: 'short-input', - placeholder: 'QuickBooks expense account ID', + placeholder: 'Required for Service and Non-inventory items outside France locales', + description: + 'QuickBooks requires an expense account for Service and Non-inventory items, except for companies on a France locale.', condition: { field: 'operation', value: [...ITEM_OPERATIONS] }, }, { @@ -1942,6 +2013,7 @@ export const QuickBooksBlock: BlockConfig = { { label: 'Active', id: 'active' }, { label: 'Inactive', id: 'inactive' }, ], + mode: 'advanced', condition: { field: 'operation', value: [...MASTER_DATA_UPDATE_OPERATIONS], @@ -2060,6 +2132,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_purchase_order', 'quickbooks_create_bill', 'quickbooks_update_bill', + 'quickbooks_create_bill_payment', 'quickbooks_create_vendor_credit', 'quickbooks_update_vendor_credit', ], @@ -2157,6 +2230,8 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_invoice', 'quickbooks_create_bill', 'quickbooks_update_bill', + 'quickbooks_create_purchase_order', + 'quickbooks_update_purchase_order', ], }, mode: 'advanced', @@ -2187,6 +2262,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_purchase_order', 'quickbooks_create_bill', 'quickbooks_update_bill', + 'quickbooks_create_bill_payment', 'quickbooks_create_vendor_credit', 'quickbooks_update_vendor_credit', 'quickbooks_create_journal_entry', @@ -2195,6 +2271,30 @@ export const QuickBooksBlock: BlockConfig = { }, mode: 'advanced', }, + { + id: 'currencyCode', + title: 'Currency Code', + type: 'short-input', + placeholder: 'USD', + description: + 'Three-letter ISO 4217 code. QuickBooks requires it once multicurrency is enabled for the company.', + condition: { field: 'operation', value: [...CURRENCY_CODE_OPERATIONS] }, + mode: 'advanced', + }, + { + id: 'globalTaxCalculation', + title: 'Tax Treatment', + type: 'dropdown', + options: ({ values } = { values: {} }) => + values?.operation === 'quickbooks_create_journal_entry' + ? [...JOURNAL_ENTRY_GLOBAL_TAX_OPTIONS] + : [...GLOBAL_TAX_CALCULATION_OPTIONS], + description: + 'How QuickBooks applies tax. Not applicable to US companies; required for non-US companies.', + condition: { field: 'operation', value: [...GLOBAL_TAX_CALCULATION_OPERATIONS] }, + mode: 'advanced', + value: () => 'default', + }, { id: 'privateNote', title: 'Private Note', @@ -2286,7 +2386,6 @@ export const QuickBooksBlock: BlockConfig = { language: 'json', placeholder: '[{"invoiceId":"42","amount":75}]', condition: { field: 'operation', value: [...PAYMENT_OPERATIONS] }, - mode: 'advanced', wandConfig: { enabled: true, placeholder: 'Describe how the payment should be allocated across invoices', @@ -2338,8 +2437,8 @@ export const QuickBooksBlock: BlockConfig = { { label: 'No', id: 'no' }, { label: 'Yes', id: 'yes' }, ], - condition: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, - required: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, + condition: { field: 'operation', value: [...VOID_OPERATIONS] }, + required: { field: 'operation', value: [...VOID_OPERATIONS] }, value: () => 'no', }, { @@ -2386,6 +2485,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_void_invoice', 'quickbooks_create_sales_receipt', 'quickbooks_update_sales_receipt', + 'quickbooks_void_sales_receipt', 'quickbooks_create_customer_payment', 'quickbooks_update_customer_payment', 'quickbooks_void_customer_payment', @@ -2400,6 +2500,7 @@ export const QuickBooksBlock: BlockConfig = { 'quickbooks_update_bill', 'quickbooks_create_bill_payment', 'quickbooks_update_bill_payment', + 'quickbooks_void_bill_payment', 'quickbooks_create_vendor_credit', 'quickbooks_update_vendor_credit', 'quickbooks_create_purchase', @@ -2486,7 +2587,7 @@ export const QuickBooksBlock: BlockConfig = { return { credential: oauthCredentialValue, attachmentId: optionalValue(params.attachmentId), - fileName: optionalValue(params.attachmentFileName), + fileName: optionalValue(params.downloadAttachmentFileName), } } @@ -2514,7 +2615,7 @@ export const QuickBooksBlock: BlockConfig = { credential: oauthCredentialValue, transactionType: params.transactionType, readMode: params.readMode, - transactionId: optionalValue(params.transactionId), + transactionId: optionalValue(params.readTransactionId), } } return { @@ -2534,7 +2635,7 @@ export const QuickBooksBlock: BlockConfig = { credential: oauthCredentialValue, transactionType: params.purchasingTransactionType, readMode: params.readMode, - transactionId: optionalValue(params.transactionId), + transactionId: optionalValue(params.readTransactionId), } } return { @@ -2557,7 +2658,7 @@ export const QuickBooksBlock: BlockConfig = { credential: oauthCredentialValue, transactionType: params.accountingTransactionType, readMode: params.readMode, - transactionId: optionalValue(params.transactionId), + transactionId: optionalValue(params.readTransactionId), } } return { @@ -2579,11 +2680,17 @@ export const QuickBooksBlock: BlockConfig = { ? optionalValue(params.reportStartDate) : undefined, endDate: optionalValue(params.reportEndDate), + dateMacro: reportSupports(reportType, 'dateMacro') + ? (params.reportDateMacro ?? 'default') + : undefined, accountingMethod: reportSupports(reportType, 'accountingMethod') ? (params.reportAccountingMethod ?? 'default') : undefined, summarizeBy: reportSupports(reportType, 'summarizeBy') - ? reportSummarizeValue(params, reportType) + ? (params.reportSummarizeBy ?? 'default') + : undefined, + quickZoomUrl: reportSupports(reportType, 'quickZoomUrl') + ? parseOptionalBoolean(params.reportQuickZoomUrl, 'quickZoomUrl') : undefined, customerId: reportSupports(reportType, 'customerId') ? optionalValue(params.reportCustomerId) @@ -2594,6 +2701,9 @@ export const QuickBooksBlock: BlockConfig = { accountId: reportSupports(reportType, 'accountId') ? optionalValue(params.reportAccountId) : undefined, + employeeId: reportSupports(reportType, 'employeeId') + ? optionalValue(params.reportEmployeeId) + : undefined, itemId: reportSupports(reportType, 'itemId') ? optionalValue(params.reportItemId) : undefined, @@ -2614,15 +2724,17 @@ export const QuickBooksBlock: BlockConfig = { ? params.reportTransactionType : undefined, groupBy: - reportType === 'transaction_list' && params.reportGroupBy !== 'default' + reportSupports(reportType, 'groupBy') && params.reportGroupBy !== 'default' ? params.reportGroupBy : undefined, accountsPayablePaid: - reportType === 'transaction_list' && params.reportAccountsPayablePaid !== 'default' + reportSupports(reportType, 'accountsPayablePaid') && + params.reportAccountsPayablePaid !== 'default' ? params.reportAccountsPayablePaid : undefined, accountsReceivablePaid: - reportType === 'transaction_list' && params.reportAccountsReceivablePaid !== 'default' + reportSupports(reportType, 'accountsReceivablePaid') && + params.reportAccountsReceivablePaid !== 'default' ? params.reportAccountsReceivablePaid : undefined, clearedStatus: @@ -2639,7 +2751,7 @@ export const QuickBooksBlock: BlockConfig = { : undefined, } } - if (SALES_VOID_OPERATIONS.includes(operation as (typeof SALES_VOID_OPERATIONS)[number])) { + if (VOID_OPERATIONS.includes(operation as (typeof VOID_OPERATIONS)[number])) { return { credential: oauthCredentialValue, transactionId: optionalValue(params.transactionId), @@ -2741,7 +2853,7 @@ export const QuickBooksBlock: BlockConfig = { syncToken: isCreate ? undefined : optionalValue(params.syncToken), vendorId: optionalValue(params.vendorId), apAccountId: - isPurchaseOrder || isBill || isVendorCredit + isPurchaseOrder || isBill || isVendorCredit || (isCreate && isBillPayment) ? optionalValue(params.apAccountId) : undefined, lines: @@ -2767,11 +2879,16 @@ export const QuickBooksBlock: BlockConfig = { ? parseJsonArrayInput(params.billAllocations, 'billAllocations') : undefined, transactionDate: optionalValue(params.transactionDate), - dueDate: isBill ? optionalValue(params.dueDate) : undefined, + dueDate: isBill || isPurchaseOrder ? optionalValue(params.dueDate) : undefined, documentNumber: - isPurchaseOrder || isBill || isVendorCredit + isPurchaseOrder || isBill || isVendorCredit || (isCreate && isBillPayment) ? optionalValue(params.documentNumber) : undefined, + currencyCode: isCreate ? optionalValue(params.currencyCode) : undefined, + globalTaxCalculation: + isCreate && !isBillPayment + ? selectedGlobalTaxCalculation(params.globalTaxCalculation) + : undefined, paymentReference: isPurchase ? optionalValue(params.paymentReference) : undefined, privateNote: optionalValue(params.privateNote), requestId: isCreate ? optionalValue(params.requestId) : undefined, @@ -2807,6 +2924,10 @@ export const QuickBooksBlock: BlockConfig = { depositAccountId: !isJournalEntry ? optionalValue(params.depositAccountId) : undefined, transactionDate: optionalValue(params.transactionDate), documentNumber: isJournalEntry ? optionalValue(params.documentNumber) : undefined, + currencyCode: isCreate ? optionalValue(params.currencyCode) : undefined, + globalTaxCalculation: isCreate + ? selectedGlobalTaxCalculation(params.globalTaxCalculation) + : undefined, privateNote: optionalValue(params.privateNote), requestId: isCreate ? optionalValue(params.requestId) : undefined, } @@ -2928,21 +3049,17 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Cash or accrual report basis', }, - reportSummarizeBy: { + reportDateMacro: { type: 'string', - description: 'Report column summarization', + description: 'Predefined report date range', }, - reportCustomerSalesSummarizeBy: { - type: 'string', - description: 'Sales report column summarization', - }, - reportVendorExpenseSummarizeBy: { + reportSummarizeBy: { type: 'string', - description: 'Vendor expense report column summarization', + description: 'Report column summarization', }, - reportTimeSummarizeBy: { - type: 'string', - description: 'Time-based report column summarization', + reportQuickZoomUrl: { + type: 'boolean', + description: 'Whether to request quick-zoom drill-down links', }, reportCustomerId: { type: 'string', @@ -2953,6 +3070,10 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Account report filter ID', }, + reportEmployeeId: { + type: 'string', + description: 'Employee report filter ID', + }, reportItemId: { type: 'string', description: 'Product or service report filter ID', @@ -2974,14 +3095,14 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Transaction List type filter', }, - reportGroupBy: { type: 'string', description: 'Transaction List grouping' }, + reportGroupBy: { type: 'string', description: 'Report row grouping' }, reportAccountsPayablePaid: { type: 'string', - description: 'Transaction List A/P status', + description: 'Report payables paid status', }, reportAccountsReceivablePaid: { type: 'string', - description: 'Transaction List A/R status', + description: 'Report receivables paid status', }, reportClearedStatus: { type: 'string', @@ -3012,14 +3133,21 @@ export const QuickBooksBlock: BlockConfig = { type: 'string', description: 'Purchasing list vendor filter', }, - transactionId: { type: 'string', description: 'QuickBooks transaction ID' }, + readTransactionId: { + type: 'string', + description: 'QuickBooks transaction ID to read by ID', + }, + transactionId: { + type: 'string', + description: 'QuickBooks transaction ID to update or void', + }, startPosition: { type: 'number', description: 'One-based position of the first list item to request', }, maxResults: { type: 'number', - description: 'Number of list items to request, from 1 through 100', + description: `Number of list items to request, from 1 through ${QUICKBOOKS_MAX_RESULTS}`, }, customerId: { type: 'string', description: 'QuickBooks customer ID' }, vendorId: { type: 'string', description: 'QuickBooks vendor ID' }, @@ -3135,7 +3263,15 @@ export const QuickBooksBlock: BlockConfig = { }, dueDate: { type: 'string', - description: 'Invoice due date in YYYY-MM-DD format', + description: 'Invoice, bill, or purchase-order due date in YYYY-MM-DD format', + }, + currencyCode: { + type: 'string', + description: 'Three-letter ISO 4217 transaction currency code', + }, + globalTaxCalculation: { + type: 'string', + description: 'Tax treatment applied to the transaction', }, expirationDate: { type: 'string', @@ -3230,7 +3366,11 @@ export const QuickBooksBlock: BlockConfig = { }, attachmentFileName: { type: 'string', - description: 'Optional attachment filename override', + description: 'Optional uploaded attachment filename override', + }, + downloadAttachmentFileName: { + type: 'string', + description: 'Optional downloaded attachment filename override', }, attachmentContentType: { type: 'string', @@ -3373,7 +3513,7 @@ export const QuickBooksBlock: BlockConfig = { voided: { type: 'boolean', description: 'True when QuickBooks successfully voided the transaction', - condition: { field: 'operation', value: [...SALES_VOID_OPERATIONS] }, + condition: { field: 'operation', value: [...VOID_OPERATIONS] }, }, linkingRequested: { type: 'boolean', diff --git a/apps/sim/lib/api/contracts/tools/quickbooks.ts b/apps/sim/lib/api/contracts/tools/quickbooks.ts index 394b14d42b6..1690471d2db 100644 --- a/apps/sim/lib/api/contracts/tools/quickbooks.ts +++ b/apps/sim/lib/api/contracts/tools/quickbooks.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { userFileSchema } from '@/lib/api/contracts/primitives' +import type { ContractBody, ContractJsonResponse } from '@/lib/api/contracts/types' import { defineRouteContract } from '@/lib/api/contracts/types' import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas' @@ -198,3 +199,356 @@ export const quickBooksAddAttachmentContract = defineRouteContract({ ]), }, }) + +const QUICKBOOKS_MAX_LINES = 100 +const QUICKBOOKS_MAX_ALLOCATIONS = 100 + +function requiredQuickBooksId(label: string) { + return z.string().min(1, `${label} is required`).max(256, `${label} is too long`) +} + +function optionalQuickBooksId(label: string) { + return z.string().max(256, `${label} is too long`).optional() +} + +function optionalQuickBooksText(label: string, max: number) { + return z.string().max(max, `${label} is too long`).optional() +} + +/** + * QuickBooks dates are `YYYY-MM-DD`, but the format check stays in + * `validateQuickBooksDate` so an empty value keeps meaning "not supplied". + */ +function optionalQuickBooksDate(label: string) { + return z.string().max(32, `${label} is too long`).optional() +} + +const quickBooksActiveStatusSchema = z + .enum(['unchanged', 'active', 'inactive'], { + error: 'activeStatus must be unchanged, active, or inactive', + }) + .optional() + +/** + * Address input reaches this boundary already parsed into an object on every + * caller path. Key names and their QuickBooks mapping stay in + * `parseQuickBooksAddress`. + */ +const quickBooksAddressInputSchema = z.record(z.string(), z.string()) + +const quickBooksSalesLineInputSchema = z.strictObject({ + lineType: z.enum(['item', 'description'], { + error: 'lines[].lineType must be item or description', + }), + amount: z.number().optional(), + itemId: optionalQuickBooksId('lines[].itemId'), + description: optionalQuickBooksText('lines[].description', 4000), + quantity: z.number().optional(), + unitPrice: z.number().optional(), + serviceDate: optionalQuickBooksDate('lines[].serviceDate'), +}) + +const quickBooksSalesLinesSchema = z + .array(quickBooksSalesLineInputSchema) + .min(1, 'lines must contain at least one line') + .max(QUICKBOOKS_MAX_LINES, `lines cannot contain more than ${QUICKBOOKS_MAX_LINES} lines`) + +const quickBooksInvoiceAllocationsSchema = z + .array( + z.strictObject({ + invoiceId: requiredQuickBooksId('invoiceAllocations[].invoiceId'), + amount: z.number(), + }) + ) + .min(1, 'invoiceAllocations must contain at least one allocation') + .max( + QUICKBOOKS_MAX_ALLOCATIONS, + `invoiceAllocations cannot contain more than ${QUICKBOOKS_MAX_ALLOCATIONS} allocations` + ) + +const quickBooksBillAllocationsSchema = z + .array( + z.strictObject({ + billId: requiredQuickBooksId('billAllocations[].billId'), + amount: z.number(), + }) + ) + .min(1, 'billAllocations must contain at least one allocation') + .max( + QUICKBOOKS_MAX_ALLOCATIONS, + `billAllocations cannot contain more than ${QUICKBOOKS_MAX_ALLOCATIONS} allocations` + ) + +/** Every QuickBooks create/update operation answers with the same mutation envelope. */ +const quickBooksMutationResponseSchema = z.object({ + success: z.literal(true), + output: z.object({ + record: z + .object({ + Id: z.string().min(1), + SyncToken: z.string().optional(), + }) + .passthrough(), + recordId: boundedId, + syncToken: z.string().min(1), + recordVersion: z.string().min(1), + time: z.string().nullable(), + }), +}) + +const quickBooksMutationResponse = { + mode: 'json', + schema: quickBooksMutationResponseSchema, +} as const + +export const quickBooksCreateBillPaymentBodySchema = quickBooksAuthSchema.extend({ + vendorId: requiredQuickBooksId('vendorId'), + totalAmount: z.number(), + paymentType: z.enum(['check', 'credit_card'], { + error: 'paymentType must be check or credit_card', + }), + paymentAccountId: requiredQuickBooksId('paymentAccountId'), + billAllocations: quickBooksBillAllocationsSchema.optional(), + transactionDate: optionalQuickBooksDate('transactionDate'), + apAccountId: optionalQuickBooksId('apAccountId'), + currencyCode: optionalQuickBooksText('currencyCode', 8), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), + requestId: optionalQuickBooksText('requestId', 256), +}) + +export const quickBooksUpdateBillBodySchema = quickBooksAuthSchema.extend({ + billId: requiredQuickBooksId('billId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + apAccountId: optionalQuickBooksId('apAccountId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + dueDate: optionalQuickBooksDate('dueDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksUpdateBillPaymentBodySchema = quickBooksAuthSchema.extend({ + billPaymentId: requiredQuickBooksId('billPaymentId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +/** Credit memos and refund receipts share Intuit's sales-document update shape. */ +export const quickBooksUpdateSalesDocumentBodySchema = quickBooksAuthSchema.extend({ + transactionId: requiredQuickBooksId('transactionId'), + syncToken: requiredQuickBooksId('syncToken'), + customerId: optionalQuickBooksId('customerId'), + lines: quickBooksSalesLinesSchema.optional(), + transactionDate: optionalQuickBooksDate('transactionDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), + customerMemo: optionalQuickBooksText('customerMemo', 4000), + dueDate: optionalQuickBooksDate('dueDate'), + expirationDate: optionalQuickBooksDate('expirationDate'), + paymentMethodId: optionalQuickBooksId('paymentMethodId'), + paymentReferenceNumber: optionalQuickBooksText('paymentReferenceNumber', 256), + depositAccountId: optionalQuickBooksId('depositAccountId'), +}) + +export const quickBooksUpdateCustomerPaymentBodySchema = quickBooksAuthSchema.extend({ + paymentId: requiredQuickBooksId('paymentId'), + syncToken: requiredQuickBooksId('syncToken'), + customerId: optionalQuickBooksId('customerId'), + totalAmount: z.number().optional(), + transactionDate: optionalQuickBooksDate('transactionDate'), + privateNote: optionalQuickBooksText('privateNote', 4000), + paymentReferenceNumber: optionalQuickBooksText('paymentReferenceNumber', 256), + paymentMethodId: optionalQuickBooksId('paymentMethodId'), + depositAccountId: optionalQuickBooksId('depositAccountId'), + invoiceAllocations: quickBooksInvoiceAllocationsSchema.optional(), + unapplyOmittedInvoices: z.boolean().optional(), +}) + +export const quickBooksUpdateEmployeeBodySchema = quickBooksAuthSchema.extend({ + employeeId: requiredQuickBooksId('employeeId'), + syncToken: requiredQuickBooksId('syncToken'), + displayName: optionalQuickBooksText('displayName', 1000), + givenName: optionalQuickBooksText('givenName', 1000), + familyName: optionalQuickBooksText('familyName', 1000), + primaryEmail: optionalQuickBooksText('primaryEmail', 320), + primaryPhone: optionalQuickBooksText('primaryPhone', 100), + primaryAddress: quickBooksAddressInputSchema.optional(), + printOnCheckName: optionalQuickBooksText('printOnCheckName', 1000), + billableTime: z.boolean().optional(), + activeStatus: quickBooksActiveStatusSchema, +}) + +export const quickBooksUpdateItemBodySchema = quickBooksAuthSchema.extend({ + itemId: requiredQuickBooksId('itemId'), + syncToken: requiredQuickBooksId('syncToken'), + name: optionalQuickBooksText('name', 1000), + incomeAccountId: optionalQuickBooksId('incomeAccountId'), + description: optionalQuickBooksText('description', 4000), + unitPrice: z.number().optional(), + purchaseDescription: optionalQuickBooksText('purchaseDescription', 4000), + purchaseCost: z.number().optional(), + expenseAccountId: optionalQuickBooksId('expenseAccountId'), + taxable: z.boolean().optional(), + activeStatus: quickBooksActiveStatusSchema, +}) + +export const quickBooksUpdatePurchaseBodySchema = quickBooksAuthSchema.extend({ + purchaseId: requiredQuickBooksId('purchaseId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + paymentReference: optionalQuickBooksText('paymentReference', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksUpdatePurchaseOrderBodySchema = quickBooksAuthSchema.extend({ + purchaseOrderId: requiredQuickBooksId('purchaseOrderId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + apAccountId: optionalQuickBooksId('apAccountId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + dueDate: optionalQuickBooksDate('dueDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksUpdateVendorBodySchema = quickBooksAuthSchema.extend({ + vendorId: requiredQuickBooksId('vendorId'), + syncToken: requiredQuickBooksId('syncToken'), + displayName: optionalQuickBooksText('displayName', 1000), + companyName: optionalQuickBooksText('companyName', 1000), + givenName: optionalQuickBooksText('givenName', 1000), + familyName: optionalQuickBooksText('familyName', 1000), + primaryEmail: optionalQuickBooksText('primaryEmail', 320), + primaryPhone: optionalQuickBooksText('primaryPhone', 100), + billingAddress: quickBooksAddressInputSchema.optional(), + printOnCheckName: optionalQuickBooksText('printOnCheckName', 1000), + accountNumber: optionalQuickBooksText('accountNumber', 256), + vendor1099: z.boolean().optional(), + activeStatus: quickBooksActiveStatusSchema, +}) + +export const quickBooksUpdateVendorCreditBodySchema = quickBooksAuthSchema.extend({ + vendorCreditId: requiredQuickBooksId('vendorCreditId'), + syncToken: requiredQuickBooksId('syncToken'), + vendorId: optionalQuickBooksId('vendorId'), + apAccountId: optionalQuickBooksId('apAccountId'), + transactionDate: optionalQuickBooksDate('transactionDate'), + documentNumber: optionalQuickBooksText('documentNumber', 256), + privateNote: optionalQuickBooksText('privateNote', 4000), +}) + +export const quickBooksCreateBillPaymentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/create-bill-payment', + body: quickBooksCreateBillPaymentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateBillContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-bill', + body: quickBooksUpdateBillBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateBillPaymentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-bill-payment', + body: quickBooksUpdateBillPaymentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateCreditMemoContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-credit-memo', + body: quickBooksUpdateSalesDocumentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateCustomerPaymentContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-customer-payment', + body: quickBooksUpdateCustomerPaymentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateEmployeeContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-employee', + body: quickBooksUpdateEmployeeBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateItemContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-item', + body: quickBooksUpdateItemBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdatePurchaseContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-purchase', + body: quickBooksUpdatePurchaseBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdatePurchaseOrderContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-purchase-order', + body: quickBooksUpdatePurchaseOrderBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateRefundReceiptContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-refund-receipt', + body: quickBooksUpdateSalesDocumentBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateVendorContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-vendor', + body: quickBooksUpdateVendorBodySchema, + response: quickBooksMutationResponse, +}) + +export const quickBooksUpdateVendorCreditContract = defineRouteContract({ + method: 'POST', + path: '/api/tools/quickbooks/update-vendor-credit', + body: quickBooksUpdateVendorCreditBodySchema, + response: quickBooksMutationResponse, +}) + +export type QuickBooksCreateBillPaymentBody = ContractBody< + typeof quickBooksCreateBillPaymentContract +> +export type QuickBooksUpdateBillBody = ContractBody +export type QuickBooksUpdateBillPaymentBody = ContractBody< + typeof quickBooksUpdateBillPaymentContract +> +export type QuickBooksUpdateCreditMemoBody = ContractBody +export type QuickBooksUpdateCustomerPaymentBody = ContractBody< + typeof quickBooksUpdateCustomerPaymentContract +> +export type QuickBooksUpdateEmployeeBody = ContractBody +export type QuickBooksUpdateItemBody = ContractBody +export type QuickBooksUpdatePurchaseBody = ContractBody +export type QuickBooksUpdatePurchaseOrderBody = ContractBody< + typeof quickBooksUpdatePurchaseOrderContract +> +export type QuickBooksUpdateRefundReceiptBody = ContractBody< + typeof quickBooksUpdateRefundReceiptContract +> +export type QuickBooksUpdateVendorBody = ContractBody +export type QuickBooksUpdateVendorCreditBody = ContractBody< + typeof quickBooksUpdateVendorCreditContract +> +export type QuickBooksMutationOperationResponse = ContractJsonResponse< + typeof quickBooksUpdateVendorContract +> diff --git a/apps/sim/lib/api/contracts/webhooks.ts b/apps/sim/lib/api/contracts/webhooks.ts index b2c0b04ba79..25597f9281f 100644 --- a/apps/sim/lib/api/contracts/webhooks.ts +++ b/apps/sim/lib/api/contracts/webhooks.ts @@ -359,7 +359,13 @@ export const quickBooksWebhookEventSchema = z.object({ data: z.unknown().optional(), }) -export const quickBooksWebhookEventsSchema = z.array(quickBooksWebhookEventSchema).min(1).max(1000) +/** Maximum CloudEvents Intuit batches into a single webhook delivery. */ +export const QUICKBOOKS_WEBHOOK_MAX_EVENTS = 1000 + +export const quickBooksWebhookEventsSchema = z + .array(quickBooksWebhookEventSchema) + .min(1) + .max(QUICKBOOKS_WEBHOOK_MAX_EVENTS) export type QuickBooksWebhookEvent = z.input diff --git a/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts b/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts index 13b3761f8a9..63809f28004 100644 --- a/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts +++ b/apps/sim/lib/credentials/application/complete-quickbooks-connection.test.ts @@ -159,6 +159,30 @@ describe('completeQuickBooksConnection', () => { }) }) + it('never persists the Intuit identity token', async () => { + queueTableRows(account, []) + mocks.exchangeAuthorizationCode.mockResolvedValue({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + idToken: 'intuit-oidc-identity-jwt', + accessTokenExpiresIn: 3600, + refreshTokenExpiresIn: 8_726_400, + scope: '', + }) + + await completeQuickBooksConnection.execute({ + principal, + input: { + draftId: 'draft-1', + code: 'authorization-code', + realmId: '1234567890', + redirectUri: 'https://sim.test/api/auth/oauth2/callback/quickbooks', + }, + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith(expect.objectContaining({ idToken: null })) + }) + it('fails before token exchange when the draft does not carry encrypted app credentials', async () => { mocks.getActiveDraft.mockResolvedValueOnce({ id: 'draft-1', diff --git a/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts b/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts index f433b85aeb7..501bfd578e9 100644 --- a/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts +++ b/apps/sim/lib/credentials/application/complete-quickbooks-connection.ts @@ -98,7 +98,13 @@ export const completeQuickBooksConnection = defineAuthorizedWorkspaceUseCase({ const accountValues = { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, - idToken: tokens.idToken ?? null, + /** + * Intuit's OIDC identity JWT is only meaningful at connection time, where + * `profile.accountId` is already derived from it. Persisting it would project + * the token into the credential payload of every QuickBooks tool call, none of + * which read it. + */ + idToken: null, accessTokenExpiresAt, refreshTokenExpiresAt, scope: tokens.scope || getCanonicalScopesForProvider('quickbooks').join(' '), diff --git a/apps/sim/lib/internal/quickbooks/contract-param-parity.test.ts b/apps/sim/lib/internal/quickbooks/contract-param-parity.test.ts new file mode 100644 index 00000000000..74f31d70952 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/contract-param-parity.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + quickBooksAddAttachmentContract, + quickBooksCreateBillPaymentContract, + quickBooksDownloadDocumentContract, + quickBooksUpdateBillContract, + quickBooksUpdateBillPaymentContract, + quickBooksUpdateCreditMemoContract, + quickBooksUpdateCustomerPaymentContract, + quickBooksUpdateEmployeeContract, + quickBooksUpdateItemContract, + quickBooksUpdatePurchaseContract, + quickBooksUpdatePurchaseOrderContract, + quickBooksUpdateRefundReceiptContract, + quickBooksUpdateVendorContract, + quickBooksUpdateVendorCreditContract, +} from '@/lib/api/contracts/tools/quickbooks' +import { quickbooksAddAttachmentTool } from '@/tools/quickbooks/add_attachment' +import { quickbooksCreateBillPaymentTool } from '@/tools/quickbooks/create_bill_payment' +import { quickbooksDownloadAttachmentTool } from '@/tools/quickbooks/download_attachment' +import { quickbooksDownloadTransactionPdfTool } from '@/tools/quickbooks/download_transaction_pdf' +import { quickbooksUpdateBillTool } from '@/tools/quickbooks/update_bill' +import { quickbooksUpdateBillPaymentTool } from '@/tools/quickbooks/update_bill_payment' +import { quickbooksUpdateCreditMemoTool } from '@/tools/quickbooks/update_credit_memo' +import { quickbooksUpdateCustomerPaymentTool } from '@/tools/quickbooks/update_customer_payment' +import { quickbooksUpdateEmployeeTool } from '@/tools/quickbooks/update_employee' +import { quickbooksUpdateItemTool } from '@/tools/quickbooks/update_item' +import { quickbooksUpdatePurchaseTool } from '@/tools/quickbooks/update_purchase' +import { quickbooksUpdatePurchaseOrderTool } from '@/tools/quickbooks/update_purchase_order' +import { quickbooksUpdateRefundReceiptTool } from '@/tools/quickbooks/update_refund_receipt' +import { quickbooksUpdateVendorTool } from '@/tools/quickbooks/update_vendor' +import { quickbooksUpdateVendorCreditTool } from '@/tools/quickbooks/update_vendor_credit' + +/** + * A contract body is a Zod object, so any key it does not declare is STRIPPED + * before the provider operation runs — silently, with no validation error. A + * tool param that the contract omits is therefore dead: the user fills it in, + * the block forwards it, and it never reaches Intuit. + */ +const CONTRACT_BOUND_OPERATIONS = [ + ['create_bill_payment', quickbooksCreateBillPaymentTool, quickBooksCreateBillPaymentContract], + ['update_bill', quickbooksUpdateBillTool, quickBooksUpdateBillContract], + ['update_bill_payment', quickbooksUpdateBillPaymentTool, quickBooksUpdateBillPaymentContract], + ['update_credit_memo', quickbooksUpdateCreditMemoTool, quickBooksUpdateCreditMemoContract], + [ + 'update_customer_payment', + quickbooksUpdateCustomerPaymentTool, + quickBooksUpdateCustomerPaymentContract, + ], + ['update_employee', quickbooksUpdateEmployeeTool, quickBooksUpdateEmployeeContract], + ['update_item', quickbooksUpdateItemTool, quickBooksUpdateItemContract], + ['update_purchase', quickbooksUpdatePurchaseTool, quickBooksUpdatePurchaseContract], + [ + 'update_purchase_order', + quickbooksUpdatePurchaseOrderTool, + quickBooksUpdatePurchaseOrderContract, + ], + [ + 'update_refund_receipt', + quickbooksUpdateRefundReceiptTool, + quickBooksUpdateRefundReceiptContract, + ], + ['update_vendor', quickbooksUpdateVendorTool, quickBooksUpdateVendorContract], + ['update_vendor_credit', quickbooksUpdateVendorCreditTool, quickBooksUpdateVendorCreditContract], +] as const + +/** + * The file operations do not expose a flat `shape`: the download body is a + * discriminated union (one option per `documentKind`) and the add-attachment + * body carries a `superRefine`. Their declared keys are still introspectable, + * so they are held to the same parity rule as the JSON operations. + */ +const FILE_OPERATIONS = [ + [ + 'download_attachment', + quickbooksDownloadAttachmentTool, + unionOptionKeys(quickBooksDownloadDocumentContract.body, 'attachment'), + ], + [ + 'download_transaction_pdf', + quickbooksDownloadTransactionPdfTool, + unionOptionKeys(quickBooksDownloadDocumentContract.body, 'transaction_pdf'), + ], + [ + 'add_attachment', + quickbooksAddAttachmentTool, + new Set( + Object.keys( + (quickBooksAddAttachmentContract.body as unknown as { shape: Record }) + .shape + ) + ), + ], +] as const + +/** Keys declared by the union option whose `documentKind` literal matches. */ +function unionOptionKeys(body: unknown, documentKind: string): Set { + const options = (body as { options: Array<{ shape: Record }> }) + .options + const option = options.find((candidate) => candidate.shape.documentKind?.value === documentKind) + if (!option) throw new Error(`No download contract option for documentKind ${documentKind}`) + return new Set(Object.keys(option.shape)) +} + +describe('QuickBooks contract/tool param parity', () => { + it.each(CONTRACT_BOUND_OPERATIONS)( + '%s declares every tool param in its contract body', + (_name, tool, contract) => { + const bodyShape = (contract.body as unknown as { shape: Record }).shape + const declared = new Set(Object.keys(bodyShape)) + const dropped = Object.keys(tool.params).filter((param) => !declared.has(param)) + expect(dropped).toEqual([]) + } + ) + + it.each(FILE_OPERATIONS)( + '%s declares every tool param in its contract body', + (_n, tool, declared) => { + const dropped = Object.keys(tool.params).filter((param) => !declared.has(param)) + expect(dropped).toEqual([]) + } + ) +}) diff --git a/apps/sim/lib/internal/quickbooks/execute-tool.test.ts b/apps/sim/lib/internal/quickbooks/execute-tool.test.ts index 5cdd77e98e6..a0ccb2bae3a 100644 --- a/apps/sim/lib/internal/quickbooks/execute-tool.test.ts +++ b/apps/sim/lib/internal/quickbooks/execute-tool.test.ts @@ -73,6 +73,94 @@ function request(overrides: Partial = {}): InternalTo } } +const AUTH_INPUT = { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', +} as const + +const PROVIDER_OPERATIONS: ReadonlyArray< + [string, ReturnType, Record, Record] +> = [ + [ + 'quickbooks_create_bill_payment', + mocks.createBillPayment, + { + vendorId: 'vendor-1', + totalAmount: 25, + paymentType: 'check', + paymentAccountId: 'account-1', + }, + { totalAmount: '25' }, + ], + [ + 'quickbooks_update_bill', + mocks.updateBill, + { billId: 'bill-1', syncToken: '3' }, + { billId: '' }, + ], + [ + 'quickbooks_update_bill_payment', + mocks.updateBillPayment, + { billPaymentId: 'bill-payment-1', syncToken: '3' }, + { billPaymentId: '' }, + ], + [ + 'quickbooks_update_credit_memo', + mocks.updateCreditMemo, + { transactionId: 'credit-memo-1', syncToken: '3' }, + { transactionId: '' }, + ], + [ + 'quickbooks_update_customer_payment', + mocks.updateCustomerPayment, + { paymentId: 'payment-1', syncToken: '3' }, + { paymentId: '' }, + ], + [ + 'quickbooks_update_employee', + mocks.updateEmployee, + { employeeId: 'employee-1', syncToken: '3' }, + { employeeId: '' }, + ], + [ + 'quickbooks_update_item', + mocks.updateItem, + { itemId: 'item-1', syncToken: '3' }, + { unitPrice: 'free' }, + ], + [ + 'quickbooks_update_purchase', + mocks.updatePurchase, + { purchaseId: 'purchase-1', syncToken: '3' }, + { purchaseId: '' }, + ], + [ + 'quickbooks_update_purchase_order', + mocks.updatePurchaseOrder, + { purchaseOrderId: 'purchase-order-1', syncToken: '3' }, + { purchaseOrderId: '' }, + ], + [ + 'quickbooks_update_refund_receipt', + mocks.updateRefundReceipt, + { transactionId: 'refund-receipt-1', syncToken: '3' }, + { transactionId: '' }, + ], + [ + 'quickbooks_update_vendor', + mocks.updateVendor, + { vendorId: 'vendor-1', syncToken: '3' }, + { syncToken: '' }, + ], + [ + 'quickbooks_update_vendor_credit', + mocks.updateVendorCredit, + { vendorCreditId: 'vendor-credit-1', syncToken: '3' }, + { vendorCreditId: '' }, + ], +] + describe('executeQuickBooksTool', () => { beforeEach(() => { vi.clearAllMocks() @@ -99,46 +187,87 @@ describe('executeQuickBooksTool', () => { } }) - it.each([ - ['quickbooks_create_bill_payment', mocks.createBillPayment], - ['quickbooks_update_bill', mocks.updateBill], - ['quickbooks_update_bill_payment', mocks.updateBillPayment], - ['quickbooks_update_credit_memo', mocks.updateCreditMemo], - ['quickbooks_update_customer_payment', mocks.updateCustomerPayment], - ['quickbooks_update_employee', mocks.updateEmployee], - ['quickbooks_update_item', mocks.updateItem], - ['quickbooks_update_purchase', mocks.updatePurchase], - ['quickbooks_update_purchase_order', mocks.updatePurchaseOrder], - ['quickbooks_update_refund_receipt', mocks.updateRefundReceipt], - ['quickbooks_update_vendor', mocks.updateVendor], - ['quickbooks_update_vendor_credit', mocks.updateVendorCredit], - ])('dispatches %s through its internal provider operation', async (toolId, operation) => { - const controller = new AbortController() - const operationRequest = request({ - toolId, - input: { - accessToken: 'token', - realmId: '123', - quickBooksEnvironment: 'sandbox', - entityId: 'entity-1', - }, - signal: controller.signal, - }) + it.each(PROVIDER_OPERATIONS)( + 'dispatches %s through its internal provider operation', + async (toolId, operation, operationInput) => { + const controller = new AbortController() + const operationRequest = request({ + toolId, + input: { ...AUTH_INPUT, ...operationInput }, + signal: controller.signal, + }) + + const response = await executeQuickBooksTool(operationRequest) - const response = await executeQuickBooksTool(operationRequest) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + success: true, + output: { id: 'entity-1' }, + }) + expect(operation).toHaveBeenCalledWith( + { ...AUTH_INPUT, ...operationInput }, + controller.signal + ) + } + ) + + it.each(PROVIDER_OPERATIONS)( + 'rejects %s input the contract refuses', + async (toolId, operation, operationInput, invalidOverride) => { + const response = await executeQuickBooksTool( + request({ toolId, input: { ...AUTH_INPUT, ...operationInput, ...invalidOverride } }) + ) + + expect(response.status).toBe(400) + expect(operation).not.toHaveBeenCalled() + } + ) + + it('drops keys no provider operation contract declares', async () => { + const response = await executeQuickBooksTool( + request({ + toolId: 'quickbooks_update_vendor', + input: { ...AUTH_INPUT, vendorId: 'vendor-1', syncToken: '3', credential: 'credential-1' }, + }) + ) expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - success: true, - output: { id: 'entity-1' }, - }) - expect(operation).toHaveBeenCalledWith( - operationRequest.input, - controller.signal, - operationRequest.context + expect(mocks.updateVendor).toHaveBeenCalledWith( + { ...AUTH_INPUT, vendorId: 'vendor-1', syncToken: '3' }, + undefined ) }) + it('rejects provider operations without trusted user identity', async () => { + const response = await executeQuickBooksTool( + request({ + toolId: 'quickbooks_update_vendor', + input: { ...AUTH_INPUT, vendorId: 'vendor-1', syncToken: '3' }, + context: { workflowId: 'workflow-1' }, + }) + ) + + expect(response.status).toBe(401) + expect(mocks.updateVendor).not.toHaveBeenCalled() + }) + + it('rejects oversized provider operation input before dispatch', async () => { + const response = await executeQuickBooksTool( + request({ + toolId: 'quickbooks_update_vendor', + input: { + ...AUTH_INPUT, + vendorId: 'vendor-1', + syncToken: '3', + extra: 'x'.repeat(1024 * 1024 + 1), + }, + }) + ) + + expect(response.status).toBe(413) + expect(mocks.updateVendor).not.toHaveBeenCalled() + }) + it('dispatches downloads with trusted execution context', async () => { const controller = new AbortController() diff --git a/apps/sim/lib/internal/quickbooks/execute-tool.ts b/apps/sim/lib/internal/quickbooks/execute-tool.ts index da73d506e80..b4e299adc10 100644 --- a/apps/sim/lib/internal/quickbooks/execute-tool.ts +++ b/apps/sim/lib/internal/quickbooks/execute-tool.ts @@ -1,10 +1,21 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { - quickBooksAddAttachmentBodySchema, - quickBooksDownloadDocumentBodySchema, + quickBooksAddAttachmentContract, + quickBooksCreateBillPaymentContract, + quickBooksDownloadDocumentContract, + quickBooksUpdateBillContract, + quickBooksUpdateBillPaymentContract, + quickBooksUpdateCreditMemoContract, + quickBooksUpdateCustomerPaymentContract, + quickBooksUpdateEmployeeContract, + quickBooksUpdateItemContract, + quickBooksUpdatePurchaseContract, + quickBooksUpdatePurchaseOrderContract, + quickBooksUpdateRefundReceiptContract, + quickBooksUpdateVendorContract, + quickBooksUpdateVendorCreditContract, } from '@/lib/api/contracts/tools/quickbooks' -import { getValidationErrorMessage } from '@/lib/api/server' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { executeQuickBooksAddAttachment, @@ -26,7 +37,8 @@ import { executeQuickBooksUpdateVendorCreditOperation, executeQuickBooksUpdateVendorOperation, } from '@/lib/internal/quickbooks/provider-operations' -import { executeToolOperationImplementation } from '@/lib/internal/tool-operations/execute' +import { executeInternalJsonToolOperation } from '@/lib/internal/tool-operations/execute-json-operation' +import { parseInternalContractInput } from '@/lib/internal/tool-operations/parse-contract-input' import type { InternalToolOperationCall, InternalToolOperationHandler, @@ -76,50 +88,118 @@ function operationContext(request: InternalToolOperationCall): QuickBooksOperati } } +/** + * Every QuickBooks tool id passes the same admission gates — cancellation, the + * operation input cap, and the trusted execution identity — before any provider + * work is dispatched. + */ export const executeQuickBooksTool: InternalToolOperationHandler = async (request) => { request.signal?.throwIfAborted() + + const sizeError = inputSizeError(request.input) + if (sizeError) return sizeError + + const context = operationContext(request) + if (!context) { + return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) + } + switch (request.toolId) { case 'quickbooks_create_bill_payment': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksCreateBillPaymentContract, + request.input, executeQuickBooksCreateBillPaymentOperation, - request + 'Failed to create QuickBooks bill payment', + request.signal ) case 'quickbooks_update_bill': - return executeToolOperationImplementation(executeQuickBooksUpdateBillOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateBillContract, + request.input, + executeQuickBooksUpdateBillOperation, + 'Failed to update QuickBooks bill', + request.signal + ) case 'quickbooks_update_bill_payment': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateBillPaymentContract, + request.input, executeQuickBooksUpdateBillPaymentOperation, - request + 'Failed to update QuickBooks bill payment', + request.signal ) case 'quickbooks_update_credit_memo': - return executeToolOperationImplementation(executeQuickBooksUpdateCreditMemoOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateCreditMemoContract, + request.input, + executeQuickBooksUpdateCreditMemoOperation, + 'Failed to update QuickBooks credit memo', + request.signal + ) case 'quickbooks_update_customer_payment': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateCustomerPaymentContract, + request.input, executeQuickBooksUpdateCustomerPaymentOperation, - request + 'Failed to update QuickBooks customer payment', + request.signal ) case 'quickbooks_update_employee': - return executeToolOperationImplementation(executeQuickBooksUpdateEmployeeOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateEmployeeContract, + request.input, + executeQuickBooksUpdateEmployeeOperation, + 'Failed to update QuickBooks employee', + request.signal + ) case 'quickbooks_update_item': - return executeToolOperationImplementation(executeQuickBooksUpdateItemOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateItemContract, + request.input, + executeQuickBooksUpdateItemOperation, + 'Failed to update QuickBooks item', + request.signal + ) case 'quickbooks_update_purchase': - return executeToolOperationImplementation(executeQuickBooksUpdatePurchaseOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdatePurchaseContract, + request.input, + executeQuickBooksUpdatePurchaseOperation, + 'Failed to update QuickBooks purchase', + request.signal + ) case 'quickbooks_update_purchase_order': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdatePurchaseOrderContract, + request.input, executeQuickBooksUpdatePurchaseOrderOperation, - request + 'Failed to update QuickBooks purchase order', + request.signal ) case 'quickbooks_update_refund_receipt': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateRefundReceiptContract, + request.input, executeQuickBooksUpdateRefundReceiptOperation, - request + 'Failed to update QuickBooks refund receipt', + request.signal ) case 'quickbooks_update_vendor': - return executeToolOperationImplementation(executeQuickBooksUpdateVendorOperation, request) + return executeInternalJsonToolOperation( + quickBooksUpdateVendorContract, + request.input, + executeQuickBooksUpdateVendorOperation, + 'Failed to update QuickBooks vendor', + request.signal + ) case 'quickbooks_update_vendor_credit': - return executeToolOperationImplementation( + return executeInternalJsonToolOperation( + quickBooksUpdateVendorCreditContract, + request.input, executeQuickBooksUpdateVendorCreditOperation, - request + 'Failed to update QuickBooks vendor credit', + request.signal ) } @@ -133,28 +213,13 @@ export const executeQuickBooksTool: InternalToolOperationHandler = async (reques ) } - const sizeError = inputSizeError(request.input) - if (sizeError) return sizeError - const context = operationContext(request) - if (!context) { - return Response.json({ success: false, error: 'Authentication required' }, { status: 401 }) - } - try { if (request.toolId === 'quickbooks_add_attachment') { - const parsed = quickBooksAddAttachmentBodySchema.safeParse(request.input) - if (!parsed.success) { - return Response.json( - { - success: false, - error: getValidationErrorMessage(parsed.error, 'Invalid request data'), - }, - { status: 400 } - ) - } + const parsed = parseInternalContractInput(quickBooksAddAttachmentContract, request.input) + if (!parsed.success) return parsed.response return Response.json({ success: true, - output: await executeQuickBooksAddAttachment(parsed.data, context), + output: await executeQuickBooksAddAttachment(parsed.data.body, context), }) } @@ -163,19 +228,11 @@ export const executeQuickBooksTool: InternalToolOperationHandler = async (reques documentKind: request.toolId === 'quickbooks_download_attachment' ? 'attachment' : 'transaction_pdf', } - const parsed = quickBooksDownloadDocumentBodySchema.safeParse(documentInput) - if (!parsed.success) { - return Response.json( - { - success: false, - error: getValidationErrorMessage(parsed.error, 'Invalid request data'), - }, - { status: 400 } - ) - } + const parsed = parseInternalContractInput(quickBooksDownloadDocumentContract, documentInput) + if (!parsed.success) return parsed.response return Response.json({ success: true, - output: await executeQuickBooksDownloadDocument(parsed.data, context), + output: await executeQuickBooksDownloadDocument(parsed.data.body, context), }) } catch (error) { request.signal?.throwIfAborted() diff --git a/apps/sim/lib/internal/quickbooks/operations.test.ts b/apps/sim/lib/internal/quickbooks/operations.test.ts index a7784674c94..27b9044ea6b 100644 --- a/apps/sim/lib/internal/quickbooks/operations.test.ts +++ b/apps/sim/lib/internal/quickbooks/operations.test.ts @@ -171,6 +171,34 @@ describe('QuickBooks internal operations', () => { expect(mocks.uploadCopilotFile).not.toHaveBeenCalled() }) + it('refuses a transaction PDF that advertises more than the attachment limit', async () => { + const pdf = new TextEncoder().encode('%PDF-1.7\n') + vi.mocked(fetch).mockResolvedValue( + new Response(pdf, { + headers: { + 'content-type': 'application/pdf', + 'content-length': String(QUICKBOOKS_MAX_ATTACHMENT_BYTES + 1), + }, + }) + ) + + await expect( + executeQuickBooksDownloadDocument( + { + documentKind: 'transaction_pdf', + accessToken: 'secret-token', + realmId: '123', + quickBooksEnvironment: 'sandbox', + transactionType: 'invoice', + transactionId: 'invoice-1', + }, + context() + ) + ).rejects.toThrow('QuickBooks transaction PDF') + expect(mocks.uploadCopilotFile).not.toHaveBeenCalled() + expect(mocks.uploadExecutionFile).not.toHaveBeenCalled() + }) + it('stores valid PDFs in trusted execution scope', async () => { const pdf = new TextEncoder().encode('%PDF-1.7\n') vi.mocked(fetch).mockResolvedValue( diff --git a/apps/sim/lib/internal/quickbooks/operations.ts b/apps/sim/lib/internal/quickbooks/operations.ts index 13a53ffb154..33707ff90a2 100644 --- a/apps/sim/lib/internal/quickbooks/operations.ts +++ b/apps/sim/lib/internal/quickbooks/operations.ts @@ -188,7 +188,7 @@ async function downloadQuickBooksTransactionPdf( headers: { ...buildQuickBooksHeaders(body.accessToken), Accept: 'application/pdf' }, signal: transferSignal, }) - if (!response.ok) throw await getQuickBooksDocumentError(response, signal) + if (!response.ok) throw await getQuickBooksDocumentError(response, transferSignal) const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() ?? '' diff --git a/apps/sim/lib/internal/quickbooks/provider-operations.test.ts b/apps/sim/lib/internal/quickbooks/provider-operations.test.ts new file mode 100644 index 00000000000..16c2fb1f4a4 --- /dev/null +++ b/apps/sim/lib/internal/quickbooks/provider-operations.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env', () => ({ + env: { QUICKBOOKS_ENV: 'production' }, +})) + +import { + executeQuickBooksCreateBillPaymentOperation, + executeQuickBooksUpdateRefundReceiptOperation, +} from '@/lib/internal/quickbooks/provider-operations' + +const AUTH = { + accessToken: 'token', + realmId: '123', + quickBooksEnvironment: 'sandbox', +} as const + +function billPaymentParams(paymentType: 'check' | 'credit_card') { + return { + ...AUTH, + vendorId: 'vendor-1', + paymentType, + paymentAccountId: 'account-1', + billAllocations: [{ billId: 'bill-1', amount: 10 }], + totalAmount: 10, + } +} + +describe('QuickBooks bill payment account compatibility', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('refuses a Bank account whose sub-type is not the documented Checking', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ + Account: { + Id: 'account-1', + SyncToken: '0', + AccountType: 'Bank', + AccountSubType: 'Savings', + }, + }) + ) + + await expect( + executeQuickBooksCreateBillPaymentOperation(billPaymentParams('check')) + ).rejects.toThrow('Checking sub-type') + expect(fetch).toHaveBeenCalledOnce() + }) + + it('refuses a Credit Card account whose sub-type is not the documented CreditCard', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ + Account: { + Id: 'account-1', + SyncToken: '0', + AccountType: 'Credit Card', + AccountSubType: 'LineOfCredit', + }, + }) + ) + + await expect( + executeQuickBooksCreateBillPaymentOperation(billPaymentParams('credit_card')) + ).rejects.toThrow('CreditCard sub-type') + expect(fetch).toHaveBeenCalledOnce() + }) + + it('accepts the documented Bank/Checking pair', async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + Response.json({ + Account: { + Id: 'account-1', + SyncToken: '0', + AccountType: 'Bank', + AccountSubType: 'Checking', + }, + }) + ) + .mockResolvedValueOnce(Response.json({ BillPayment: { Id: 'pay-1', SyncToken: '0' } })) + + const result = await executeQuickBooksCreateBillPaymentOperation(billPaymentParams('check')) + expect(result.output.recordId).toBe('pay-1') + expect(fetch).toHaveBeenCalledTimes(2) + }) +}) + +describe('QuickBooks refund receipt sparse update', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('posts the documented sparse body without reading the record first', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + Response.json({ RefundReceipt: { Id: 'refund-1', SyncToken: '3' } }) + ) + + const result = await executeQuickBooksUpdateRefundReceiptOperation({ + ...AUTH, + transactionId: 'refund-1', + syncToken: '2', + lines: [{ lineType: 'item', amount: 10, itemId: 'item-1' }], + }) + + expect(fetch).toHaveBeenCalledOnce() + const [url, init] = vi.mocked(fetch).mock.calls[0] ?? [] + expect(String(url)).toContain('/refundreceipt') + expect(String(init?.method)).toBe('POST') + expect(JSON.parse(String(init?.body))).toEqual({ + Id: 'refund-1', + SyncToken: '2', + sparse: true, + Line: [ + { + Amount: 10, + DetailType: 'SalesItemLineDetail', + SalesItemLineDetail: { ItemRef: { value: 'item-1' } }, + }, + ], + }) + expect(result.output.syncToken).toBe('3') + }) +}) diff --git a/apps/sim/lib/internal/quickbooks/provider-operations.ts b/apps/sim/lib/internal/quickbooks/provider-operations.ts index c2060fac1ee..c6a2a644904 100644 --- a/apps/sim/lib/internal/quickbooks/provider-operations.ts +++ b/apps/sim/lib/internal/quickbooks/provider-operations.ts @@ -55,6 +55,23 @@ import { validateQuickBooksOptionalNumber, } from '@/tools/quickbooks/values' +/** + * Intuit constrains the BillPayment payment account by both classification + * fields, not by `AccountType` alone. `BillPaymentCheck.BankAccountRef`: "The + * specified account must have `Account.AccountType` set to `Bank` and + * `Account.AccountSubType` set to `Checking`." + * `BillPaymentCreditCard.CCAccountRef`: "The specified account must have + * `Account.AccountType` set to `Credit Card` and `Account.AccountSubType` set + * to `CreditCard`." + */ +const QUICKBOOKS_BILL_PAYMENT_ACCOUNTS = { + check: { label: 'Check', accountType: 'Bank', accountSubType: 'Checking' }, + credit_card: { label: 'Credit-card', accountType: 'Credit Card', accountSubType: 'CreditCard' }, +} as const satisfies Record< + QuickBooksCreateBillPaymentParams['paymentType'], + { label: string; accountType: string; accountSubType: string } +> + function assertCompatiblePaymentAccount( account: QuickBooksAccount, paymentType: QuickBooksCreateBillPaymentParams['paymentType'], @@ -68,10 +85,18 @@ function assertCompatiblePaymentAccount( throw new Error('QuickBooks payment account is inactive. Select an active account.') } - const expectedAccountType = paymentType === 'check' ? 'Bank' : 'Credit Card' - if (account.AccountType !== expectedAccountType) { + const expected = QUICKBOOKS_BILL_PAYMENT_ACCOUNTS[paymentType] + if (!expected) { + throw new Error(`Unsupported QuickBooks bill payment type: ${String(paymentType)}`) + } + if (account.AccountType !== expected.accountType) { + throw new Error( + `${expected.label} Bill Payments require a QuickBooks ${expected.accountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.` + ) + } + if (account.AccountSubType !== expected.accountSubType) { throw new Error( - `${paymentType === 'check' ? 'Check' : 'Credit-card'} Bill Payments require a QuickBooks ${expectedAccountType} account. Account ${paymentAccountId} is ${account.AccountType || 'missing an account type'}.` + `${expected.label} Bill Payments require a QuickBooks ${expected.accountType} account with the ${expected.accountSubType} sub-type. Account ${paymentAccountId} is ${account.AccountSubType || 'missing an account sub-type'}.` ) } } @@ -237,19 +262,32 @@ export function executeQuickBooksUpdateCreditMemoOperation( }) } -export function executeQuickBooksUpdateRefundReceiptOperation( +/** + * Intuit documents `RefundReceipt::UPDATE "Sparse update a refund receipt"`: + * "Sparse updating provides the ability to update a subset of properties for a + * given object; only elements specified in the request are updated. Missing + * elements are left untouched." The sparse operation is posted directly, so no + * read-merge-write round trip is needed to preserve untouched fields. + */ +export async function executeQuickBooksUpdateRefundReceiptOperation( params: QuickBooksUpdateRefundReceiptParams, signal?: AbortSignal ) { - return executeQuickBooksFullUpdate({ - params, + const response = await fetch(buildQuickBooksEntityUrl(params, 'refundreceipt'), { + method: 'POST', + headers: getQuickBooksToolHeaders(params.accessToken, 'application/json'), + body: JSON.stringify(buildQuickBooksUpdateSalesDocumentBody(params)), signal, - entity: 'RefundReceipt', - resource: 'refundreceipt', - recordId: params.transactionId, - syncToken: params.syncToken, - buildPatch: buildQuickBooksUpdateSalesDocumentBody, }) + if (!response.ok) { + throw await getQuickBooksOperationError(response, 'RefundReceipt', signal) + } + return transformQuickBooksMutationResponse( + response, + 'RefundReceipt', + undefined, + signal + ) } /** Preserves QuickBooks' all-or-none Payment lines across a full update. */ diff --git a/apps/sim/lib/webhooks/providers/quickbooks.test.ts b/apps/sim/lib/webhooks/providers/quickbooks.test.ts index 28af6e2df1e..53363dae06e 100644 --- a/apps/sim/lib/webhooks/providers/quickbooks.test.ts +++ b/apps/sim/lib/webhooks/providers/quickbooks.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { quickBooksHandler, verifyQuickBooksSignature, + verifyQuickBooksSignatureAgainstVerifierTokenStream, verifyQuickBooksSignatureAgainstVerifierTokens, } from '@/lib/webhooks/providers/quickbooks' import { @@ -48,8 +49,72 @@ describe('QuickBooks webhook provider', () => { expect(isQuickBooksEventMatch('quickbooks_bill_events', event.type, ['updated'])).toBe(false) }) + it('stops decrypting verifier tokens once one matches the signature', async () => { + const body = JSON.stringify([event]) + const signature = crypto.createHmac('sha256', 'first-verifier').update(body).digest('base64') + const yielded: string[] = [] + async function* tokens(): AsyncGenerator { + for (const token of ['first-verifier', 'second-verifier']) { + yielded.push(token) + yield token + } + } + + expect( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + signature, + tokens(), + 'request-stream-1' + ) + ).toBeNull() + expect(yielded).toEqual(['first-verifier']) + }) + + it('fails closed when no streamed verifier token matches', async () => { + const body = JSON.stringify([event]) + async function* tokens(): AsyncGenerator { + yield 'first-verifier' + } + async function* noTokens(): AsyncGenerator {} + + expect( + ( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + 'invalid', + tokens(), + 'request-stream-2' + ) + )?.status + ).toBe(401) + expect( + ( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + 'irrelevant', + noTokens(), + 'request-stream-3' + ) + )?.status + ).toBe(401) + expect( + ( + await verifyQuickBooksSignatureAgainstVerifierTokenStream( + body, + null, + tokens(), + 'request-stream-4' + ) + )?.status + ).toBe(401) + }) + it('normalizes Intuit void events to the configured voided action', async () => { - for (const entity of ['invoice', 'payment']) { + for (const [entity, entityType] of [ + ['invoice', 'Invoice'], + ['payment', 'Payment'], + ]) { const voidEvent = { ...event, type: `qbo.${entity}.void.v1` } expect( isQuickBooksEventMatch(`quickbooks_${entity}_events`, voidEvent.type, ['voided']) @@ -64,7 +129,7 @@ describe('QuickBooks webhook provider', () => { }) expect(result.input).toMatchObject({ eventType: `qbo.${entity}.void.v1`, - entityType: entity, + entityType, action: 'voided', }) } @@ -81,7 +146,7 @@ describe('QuickBooks webhook provider', () => { expect(result.input).toEqual({ eventId: 'event-1', eventType: 'qbo.invoice.updated.v1', - entityType: 'invoice', + entityType: 'Invoice', action: 'updated', entityId: '123', realmId: '456', diff --git a/apps/sim/lib/webhooks/providers/quickbooks.ts b/apps/sim/lib/webhooks/providers/quickbooks.ts index 66b978b6285..faacc304dbc 100644 --- a/apps/sim/lib/webhooks/providers/quickbooks.ts +++ b/apps/sim/lib/webhooks/providers/quickbooks.ts @@ -31,6 +31,11 @@ export function verifyQuickBooksSignature( ) } +function unauthorized(requestId: string, reason: string): NextResponse { + logger.warn(`[${requestId}] ${reason}`) + return new NextResponse('Unauthorized', { status: 401 }) +} + export function verifyQuickBooksSignatureAgainstVerifierTokens( rawBody: string, signature: string | null, @@ -41,12 +46,10 @@ export function verifyQuickBooksSignatureAgainstVerifierTokens( new Set(verifierTokens.map((token) => token.trim()).filter(Boolean)) ) if (configuredTokens.length === 0) { - logger.warn(`[${requestId}] QuickBooks webhook verifier token is not configured`) - return new NextResponse('Unauthorized', { status: 401 }) + return unauthorized(requestId, 'QuickBooks webhook verifier token is not configured') } if (!signature) { - logger.warn(`[${requestId}] QuickBooks webhook is missing intuit-signature`) - return new NextResponse('Unauthorized', { status: 401 }) + return unauthorized(requestId, 'QuickBooks webhook is missing intuit-signature') } const receivedSignature = signature.trim() @@ -56,12 +59,39 @@ export function verifyQuickBooksSignatureAgainstVerifierTokens( isValid = safeCompare(expected, receivedSignature) || isValid } if (!isValid) { - logger.warn(`[${requestId}] QuickBooks webhook signature verification failed`) - return new NextResponse('Unauthorized', { status: 401 }) + return unauthorized(requestId, 'QuickBooks webhook signature verification failed') } return null } +/** + * Verifies the delivery against verifier tokens produced one at a time, stopping at the first + * match so an app-level webhook does not decrypt every connected account before acknowledging. + */ +export async function verifyQuickBooksSignatureAgainstVerifierTokenStream( + rawBody: string, + signature: string | null, + verifierTokens: AsyncIterable, + requestId: string +): Promise { + if (!signature) { + return unauthorized(requestId, 'QuickBooks webhook is missing intuit-signature') + } + + const receivedSignature = signature.trim() + let sawConfiguredToken = false + for await (const verifierToken of verifierTokens) { + const trimmedToken = verifierToken.trim() + if (!trimmedToken) continue + sawConfiguredToken = true + if (safeCompare(hmacSha256Base64(rawBody, trimmedToken), receivedSignature)) return null + } + if (!sawConfiguredToken) { + return unauthorized(requestId, 'QuickBooks webhook verifier token is not configured') + } + return unauthorized(requestId, 'QuickBooks webhook signature verification failed') +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null return value as Record @@ -131,14 +161,17 @@ export const quickBooksHandler: WebhookProviderHandler = { async formatInput({ body }: FormatInputContext): Promise { const event = asRecord(body) ?? {} const eventType = typeof event.type === 'string' ? event.type : '' - const { parseQuickBooksWebhookType } = await import('@/triggers/quickbooks/quickbooks') + const { getQuickBooksTriggerDefinitionByEntity, parseQuickBooksWebhookType } = await import( + '@/triggers/quickbooks/quickbooks' + ) const parsed = parseQuickBooksWebhookType(eventType) + const definition = parsed ? getQuickBooksTriggerDefinitionByEntity(parsed.entity) : undefined return { input: { eventId: typeof event.id === 'string' ? event.id : '', eventType, - entityType: parsed?.entity ?? '', + entityType: definition?.entityType ?? '', action: parsed?.action ?? '', entityId: typeof event.intuitentityid === 'string' ? event.intuitentityid : '', realmId: typeof event.intuitaccountid === 'string' ? event.intuitaccountid : '', diff --git a/apps/sim/lib/webhooks/quickbooks-credentials.test.ts b/apps/sim/lib/webhooks/quickbooks-credentials.test.ts index a28178e2f20..08fe008ca42 100644 --- a/apps/sim/lib/webhooks/quickbooks-credentials.test.ts +++ b/apps/sim/lib/webhooks/quickbooks-credentials.test.ts @@ -20,9 +20,17 @@ import { buildQuickBooksWebhookAccountIdPattern, buildQuickBooksWebhookRoutingKey, getQuickBooksWebhookClientConfigByCredentialId, - getQuickBooksWebhookVerifierTokensByAppKey, + streamQuickBooksWebhookVerifierTokensByAppKey, } from '@/lib/webhooks/quickbooks-credentials' +async function collectVerifierTokens(appKey: string): Promise { + const tokens: string[] = [] + for await (const token of streamQuickBooksWebhookVerifierTokensByAppKey(appKey)) { + tokens.push(token) + } + return tokens +} + const CLIENT_CONFIG: QuickBooksOAuthClientConfig = { clientId: 'client-id', clientSecret: 'client-secret', @@ -51,9 +59,7 @@ describe('QuickBooks webhook credential lookup', () => { }, ]) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([ - 'verifier-token', - ]) + await expect(collectVerifierTokens(APP_KEY)).resolves.toEqual(['verifier-token']) expect(mockDecryptSecret).toHaveBeenCalledWith('encrypted-config') }) @@ -73,12 +79,29 @@ describe('QuickBooks webhook credential lookup', () => { decrypted: JSON.stringify({ ...CLIENT_CONFIG, webhookVerifierToken: 'second-verifier' }), }) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([ + await expect(collectVerifierTokens(APP_KEY)).resolves.toEqual([ 'first-verifier', 'second-verifier', ]) }) + it('decrypts one account at a time so an early match skips the rest of the app', async () => { + queueTableRows( + account, + Array.from({ length: 10 }, (_, index) => ({ + accountId: createQuickBooksAccountId(String(index + 1), `subject-${index}`, CLIENT_CONFIG), + oauthConfig: 'encrypted-config', + })) + ) + + for await (const token of streamQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)) { + expect(token).toBe('verifier-token') + break + } + + expect(mockDecryptSecret).toHaveBeenCalledTimes(1) + }) + it('fails closed instead of loading an unbounded number of app accounts', async () => { queueTableRows( account, @@ -88,7 +111,7 @@ describe('QuickBooks webhook credential lookup', () => { })) ) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).rejects.toThrow( + await expect(collectVerifierTokens(APP_KEY)).rejects.toThrow( 'QuickBooks webhook app account limit exceeded' ) expect(dbChainMockFns.limit).toHaveBeenCalledWith(1001) @@ -128,7 +151,7 @@ describe('QuickBooks webhook credential lookup', () => { decrypted: JSON.stringify({ ...CLIENT_CONFIG, clientId: 'different-app' }), }) - await expect(getQuickBooksWebhookVerifierTokensByAppKey(APP_KEY)).resolves.toEqual([]) + await expect(collectVerifierTokens(APP_KEY)).resolves.toEqual([]) }) it('escapes wildcard characters in the app-scoped account lookup', async () => { diff --git a/apps/sim/lib/webhooks/quickbooks-credentials.ts b/apps/sim/lib/webhooks/quickbooks-credentials.ts index ed73bc31108..b7d1b3b539d 100644 --- a/apps/sim/lib/webhooks/quickbooks-credentials.ts +++ b/apps/sim/lib/webhooks/quickbooks-credentials.ts @@ -59,10 +59,14 @@ async function decryptValidatedClientConfig( } } -/** Loads every verifier token configured for the Intuit app addressed by its non-secret route key. */ -export async function getQuickBooksWebhookVerifierTokensByAppKey( +/** + * Yields every distinct verifier token configured for the Intuit app addressed by its non-secret + * route key, decrypting one account at a time so a caller that stops at the first match never pays + * for the whole app's fan-out. + */ +export async function* streamQuickBooksWebhookVerifierTokensByAppKey( appKey: string -): Promise { +): AsyncGenerator { const normalizedAppKey = normalizeQuickBooksWebhookAppKey(appKey) const rows = await db .select({ @@ -82,16 +86,18 @@ export async function getQuickBooksWebhookVerifierTokensByAppKey( throw new Error('QuickBooks webhook app account limit exceeded') } - const verifierTokens = new Set() + const yieldedTokens = new Set() for (const row of rows) { const config = await decryptValidatedClientConfig( row.accountId, row.oauthConfig, normalizedAppKey ) - if (config) verifierTokens.add(config.webhookVerifierToken) + const verifierToken = config?.webhookVerifierToken + if (!verifierToken || yieldedTokens.has(verifierToken)) continue + yieldedTokens.add(verifierToken) + yield verifierToken } - return Array.from(verifierTokens) } /** Loads the user-owned Intuit app configuration behind one QuickBooks OAuth credential. */ diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 84483755628..a8a1222f546 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -735,6 +735,166 @@ describe('migrateSubblockIds', () => { }) }) + describe('quickbooks block', () => { + function quickbooksBlock(subBlocks: Record) { + return { + b1: makeBlock({ + type: 'quickbooks', + subBlocks: subBlocks as BlockState['subBlocks'], + }), + } + } + + it('moves a by-ID read target onto readTransactionId', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_read_purchasing_transactions', + }, + readMode: { id: 'readMode', type: 'dropdown', value: 'by_id' }, + transactionId: { id: 'transactionId', type: 'short-input', value: '5' }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.readTransactionId.value).toBe('5') + expect(blocks.b1.subBlocks.transactionId).toBeUndefined() + }) + + it('moves the sales and accounting by-ID read targets too', () => { + for (const operation of [ + 'quickbooks_read_sales_transactions', + 'quickbooks_read_accounting_transactions', + ]) { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { id: 'operation', type: 'dropdown', value: operation }, + transactionId: { id: 'transactionId', type: 'short-input', value: '7' }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.readTransactionId.value).toBe('7') + } + }) + + it('leaves an update target on transactionId', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_update_purchase_order', + }, + transactionId: { id: 'transactionId', type: 'short-input', value: '5' }, + }) + ) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.transactionId.value).toBe('5') + expect(blocks.b1.subBlocks.readTransactionId).toBeUndefined() + }) + + it('leaves a void target on transactionId', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { id: 'operation', type: 'dropdown', value: 'quickbooks_void_invoice' }, + transactionId: { id: 'transactionId', type: 'short-input', value: '9' }, + }) + ) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.transactionId.value).toBe('9') + expect(blocks.b1.subBlocks.readTransactionId).toBeUndefined() + }) + + it('recovers each retired summarize-columns subset onto reportSummarizeBy', () => { + for (const [from, value] of [ + ['reportCustomerSalesSummarizeBy', 'item'], + ['reportVendorExpenseSummarizeBy', 'vendor'], + ['reportTimeSummarizeBy', 'quarter'], + ] as const) { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_run_financial_report', + }, + [from]: { id: from, type: 'dropdown', value }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.reportSummarizeBy.value).toBe(value) + expect(blocks.b1.subBlocks[from]).toBeUndefined() + } + }) + + it('never clobbers a reportSummarizeBy value that is already set', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_run_financial_report', + }, + reportSummarizeBy: { id: 'reportSummarizeBy', type: 'dropdown', value: 'month' }, + reportCustomerSalesSummarizeBy: { + id: 'reportCustomerSalesSummarizeBy', + type: 'dropdown', + value: 'item', + }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.reportSummarizeBy.value).toBe('month') + expect(blocks.b1.subBlocks.reportCustomerSalesSummarizeBy).toBeUndefined() + }) + + it('moves the download-side file name onto downloadAttachmentFileName', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { + id: 'operation', + type: 'dropdown', + value: 'quickbooks_download_attachment', + }, + attachmentFileName: { + id: 'attachmentFileName', + type: 'short-input', + value: 'receipt.pdf', + }, + }) + ) + + expect(migrated).toBe(true) + expect(blocks.b1.subBlocks.downloadAttachmentFileName.value).toBe('receipt.pdf') + expect(blocks.b1.subBlocks.attachmentFileName).toBeUndefined() + }) + + it('leaves the add-side file name on attachmentFileName', () => { + const { blocks, migrated } = migrateSubblockIds( + quickbooksBlock({ + operation: { id: 'operation', type: 'dropdown', value: 'quickbooks_add_attachment' }, + attachmentKind: { id: 'attachmentKind', type: 'dropdown', value: 'file' }, + attachmentFileName: { + id: 'attachmentFileName', + type: 'short-input', + value: 'receipt.pdf', + }, + }) + ) + + expect(migrated).toBe(false) + expect(blocks.b1.subBlocks.attachmentFileName.value).toBe('receipt.pdf') + expect(blocks.b1.subBlocks.downloadAttachmentFileName).toBeUndefined() + }) + }) + it('should handle blocks with empty subBlocks', () => { const input: Record = { b1: makeBlock({ type: 'knowledge', subBlocks: {} }), diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index c12c94fcab8..8abccdd327f 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -307,6 +307,58 @@ export const SUBBLOCK_ID_MIGRATIONS: Record