OUT-3543: Type and Zod-validate IntuitAPI responses#247
OUT-3543: Type and Zod-validate IntuitAPI responses#247SandipBajracharya wants to merge 18 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
a06ff16 to
7682b00
Compare
…move as-any casts
Drop .optional() from QBPurchaseRowSchema.SyncToken since QBO always returns SyncToken on a successful create and PaymentService's deletePurchase rollback path requires a non-undefined value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regressions surfaced post-merge of the IntuitAPI Zod-parse work: 1. _getSingleIncomeAccount SQL projected only Id, but the strict row schema required Name/SyncToken/Active — every token-bootstrap call threw ZodError. Extended the SELECT to project the fields the row schema and consumers expect. 2. _getAllItems double-parses through QBItemRowSchema then QBItemsResponseSchema. Real callers (backfillProductInfo) project columns without Active, so the first parse failed. Made Active optional on the row and added Description (consumed by QBItemsResponseSchema). While in the schemas, also: - Dropped 9 .passthrough() calls. Default .strip() matches consumer intent (every reader only touches modeled fields). - Dropped _getAllItems' columns = ['Id'] default — would break the first parse on any caller that omits columns. - Tightened CustomerListRowSchema.Address from z.unknown() to z.string() to match QBO's contract. Removed the corresponding unit-test case that injected non-string Addresses (premise wasn't real). - Made QBPaymentRowSchema.SyncToken required to match Purchase, so a future symmetric rollback path doesn't trip the same cascade Task 11 fixed for deletePurchase. - Made CompanyInfoSchema.Country optional. The single consumer (quickbooks.action.ts) handles undefined safely (!== 'US').
Comment described the prior z.unknown() Address schema; tightened to z.string() in the prior commit but the comment was missed.
…hemas
- Add assertNotQBFault helper + QBFaultSchema; replaces 15 imperative
`if (raw?.Fault) throw ...` blocks with a one-liner per method.
- Narrow postFetchWithHeaders body to Record<string, unknown> and
tighten the IntuitAPI-internal fetch wrappers to Promise<unknown>
(postFetcher / getFetcher in fetch.helper.ts unchanged so frontend
SWR consumers don't break).
- Add QBCustomerResponseSchema envelope so _createCustomer /
_customerSparseUpdate follow the same envelope-parse pattern as
every other create/update method (removes two `as { Customer? }`
casts).
- Drop unused `time` field from envelope schemas — no consumer reads
it; default .strip() handles QBO's extra fields.
- Drop CustomerListRowSchema — exact duplicate of
CustomerQueryResponseSchema after Address tightening. Envelope now
wraps CustomerQueryResponseSchema directly.
- _itemFullUpdate: log now references parsedItem.Item (was leftover
item.Item from before .parse was added).
- _getAllItems: JSDoc on required columns to surface the double-parse
contract.
- QBItemRowSchema.Description: tighten to z.string().nullish() to
match QBItemsResponseSchema — QBO returns null for items without
descriptions; the prior .optional() would have rejected null at the
double-parse boundary.
20 tests across customQuery-based reads and POST-based writes. Mocks @/helper/fetch.helper to feed canonical QBO response shapes through the real production methods. Why this exists: the integration suite mocks @/utils/intuitAPI wholesale (test/integration/setup.ts), so production .parse calls never run against realistic input in CI. These tests would have surfaced both SQL-projection regressions caught earlier in this branch (the _getSingleIncomeAccount Id-only SQL bug and the _getAllItems Active-required bug).
7682b00 to
1103ff5
Compare
Greptile SummaryThis PR brings every
Confidence Score: 5/5Safe to merge — all fault-check logic is preserved and the new Zod parse boundary is additive, not destructive. Every mutation is either narrowing an any to a concrete type or adding a .parse() call after an already-passing fault check. assertNotQBFault correctly uses safeParse and only throws when Fault is genuinely present. The two style observations have no runtime impact. No files require special attention for correctness. The double-parse in _getAllItems and inline schema duplication in intuitAPI.dto.ts are worth cleaning up in a follow-on. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant IntuitAPI
participant QBO as QBO API
participant Zod
Note over IntuitAPI: POST-based writes (createInvoice, createCustomer, etc.)
Caller->>IntuitAPI: createInvoice(payload)
IntuitAPI->>QBO: POST /invoice
QBO-->>IntuitAPI: raw: unknown
IntuitAPI->>IntuitAPI: if (!raw) throw APIError
IntuitAPI->>Zod: QBFaultSchema.safeParse(raw)
alt Fault response
Zod-->>IntuitAPI: "success=true"
IntuitAPI-->>Caller: throw APIError
else Valid response
Zod-->>IntuitAPI: "success=false"
IntuitAPI->>Zod: QBInvoiceResponseSchema.parse(raw)
Zod-->>IntuitAPI: parsed: QBInvoiceResponseType
IntuitAPI-->>Caller: parsed
end
Note over IntuitAPI: Query-based reads
Caller->>IntuitAPI: getAllItems(limit)
IntuitAPI->>QBO: GET /query
QBO-->>IntuitAPI: raw: unknown
IntuitAPI->>IntuitAPI: assertNotQBFault(raw)
IntuitAPI->>IntuitAPI: return raw.QueryResponse
IntuitAPI->>Zod: QBItemQueryResponseSchema.parse(queryResponse)
Zod-->>IntuitAPI: envelope
IntuitAPI->>Zod: QBItemsResponseSchema.parse(envelope.Item)
Zod-->>IntuitAPI: result: QBItemsResponseType
IntuitAPI-->>Caller: result
Reviews (2): Last reviewed commit: "fix(OUT-3543): address Greptile review —..." | Re-trigger Greptile |
…Fault Greptile P2: `error as unknown[] | undefined` lies about the runtime value when QBO returns Fault.Error as a singular object (a documented QBO shape, not just the array form). The cast satisfies TypeScript but APIError.errors is typed unknown[], so consumers reading it as an array would silently misbehave on the object branch. Pass arrays verbatim; drop non-array shapes to undefined. The thrown APIError still carries the human-readable message, and the preceding CustomLogger.error captures the full Error payload either way, so no diagnostic information is lost.
…column getAllItems - assertNotQBFault: pass array Fault.Error verbatim, drop non-array shapes to undefined instead of casting (APIError.errors is typed unknown[]; the cast was misleading). Diagnostic detail still flows through the preceding CustomLogger.error and the opName in the thrown message. - CustomerQueryResponseSchema.PrimaryEmailAddr: outer and inner are now .nullish() so a single malformed customer row (null PrimaryEmailAddr or null Address) cannot ZodError the entire paginated walk in _getCustomerByEmail. The find() predicate's `typeof addr === 'string'` guard handles the malformed shapes. Re-adds a defensive test for the null-PrimaryEmailAddr and null-Address branches. - _getAllItems: drop the dynamic columns parameter. SQL projects a fixed Id/Name/UnitPrice/Description/SyncToken to match QBItemsResponseSchema. Removes the runtime-only column contract the JSDoc had to document. Updates queryItemsFromQB, the product controller, the backfillProductInfo cmd, and the test fixtures.
Summary
Brings every
IntuitAPImethod onto a uniform Zod-parse-at-boundary contract and removesanyfrom the IntuitAPI surface. Linear: OUT-3543.Before: ~5 methods parsed; ~13 returned raw responses with no validation. Three
as anycasts, oneRecord<string, any>body, three inline TS interfaces,_customQuerytyped asany.After:
Schema.parse(raw)and declaresPromise<z.infer<typeof Schema>>._customQuery: Promise<unknown>— every caller parses an envelope before reading fields.as anywithas unknown as XxxOverloads.postFetchWithHeaders+postFetcherbody narrowed tounknown.Out of scope
Documented in
docs/superpowers/specs/2026-05-12-out-3543-intuit-response-types-design.md. Briefly:copilotAPI.tscatch-block narrowing,withRetry/withErrorHandlergenerics, axios error guards, framework-boundaryany(logger / crypto / swr / module decls). Copilot webhook + request-body Zod schemas were already in place pre-branch — the work concentrated on response types where the gap was.Design choices
.passthrough()not used; consumers only read modeled fields, default.strip()matches intent._customQueryreturnsunknown; callers parse the envelope matching their SQL.CustomerListEnvelopeSchemawrapsCustomerQueryResponseSchemadirectly (no duplicate row schema).Follow-ups (not blockers)
@/utils/intuitAPIentirely, so production.parsenever runs in CI. Worth a separate ticket: unit suite parsing captured real QBO sample responses against each schema.postFetcherreturnsPromise<any>— localinvoice/itembetween fetch and parse isany;.Faultchecks read offany. Out of scope but worth tightening next.Test plan
npx tsc --noEmit— cleannpx vitest run --project unit— 77/77 passyarn lint:check/yarn prettier:check— clean🤖 Generated with Claude Code