Skip to content

OUT-3543: Type and Zod-validate IntuitAPI responses#247

Open
SandipBajracharya wants to merge 18 commits into
masterfrom
OUT-3543
Open

OUT-3543: Type and Zod-validate IntuitAPI responses#247
SandipBajracharya wants to merge 18 commits into
masterfrom
OUT-3543

Conversation

@SandipBajracharya
Copy link
Copy Markdown
Collaborator

Summary

Brings every IntuitAPI method onto a uniform Zod-parse-at-boundary contract and removes any from the IntuitAPI surface. Linear: OUT-3543.

Before: ~5 methods parsed; ~13 returned raw responses with no validation. Three as any casts, one Record<string, any> body, three inline TS interfaces, _customQuery typed as any.

After:

  • Every method ends with Schema.parse(raw) and declares Promise<z.infer<typeof Schema>>.
  • _customQuery: Promise<unknown> — every caller parses an envelope before reading fields.
  • Three hoisted overload types replace as any with as unknown as XxxOverloads.
  • postFetchWithHeaders + postFetcher body narrowed to unknown.
  • Row + envelope decomposition applied uniformly across Item / Account / Invoice / Payment / Purchase, with dedicated delete and query envelopes.
$ rg ': any|<any>|as any' src/utils/intuitAPI.ts src/type/dto/intuitAPI.dto.ts
(empty)

Out of scope

Documented in docs/superpowers/specs/2026-05-12-out-3543-intuit-response-types-design.md. Briefly: copilotAPI.ts catch-block narrowing, withRetry / withErrorHandler generics, axios error guards, framework-boundary any (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.
  • Fault checks stay imperative (separate from shape parsing).
  • _customQuery returns unknown; callers parse the envelope matching their SQL.
  • CustomerListEnvelopeSchema wraps CustomerQueryResponseSchema directly (no duplicate row schema).

Follow-ups (not blockers)

  • CI test gap: integration tests mock @/utils/intuitAPI entirely, so production .parse never runs in CI. Worth a separate ticket: unit suite parsing captured real QBO sample responses against each schema.
  • postFetcher returns Promise<any> — local invoice / item between fetch and parse is any; .Fault checks read off any. Out of scope but worth tightening next.

Test plan

  • npx tsc --noEmit — clean
  • npx vitest run --project unit — 77/77 pass
  • yarn lint:check / yarn prettier:check — clean
  • Full suite at branch HEAD — 87/87 (5 unit + 7 integration via testcontainers)
  • Smoke against QBO sandbox before un-drafting (createItem / createCustomer / createInvoice / createPayment / createPurchase / void / delete)

🤖 Generated with Claude Code

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 12, 2026

OUT-3543

@vercel
Copy link
Copy Markdown

vercel Bot commented May 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
quickbooks-sync Ready Ready Preview, Comment May 13, 2026 9:19am
quickbooks-sync (dev) Ready Ready Preview, Comment May 13, 2026 9:19am

Request Review

SandipBajracharya and others added 12 commits May 13, 2026 14:12
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).
@SandipBajracharya SandipBajracharya marked this pull request as ready for review May 13, 2026 08:44
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 13, 2026

Greptile Summary

This PR brings every IntuitAPI method onto a uniform Zod-parse-at-boundary contract, eliminating all any from the public surface and replacing thirteen duplicated fault-check blocks with a single assertNotQBFault helper.

  • New Zod schemas: ~15 row/envelope/delete schemas added to intuitAPI.dto.ts; PrimaryEmailAddr.Address made .nullish() to survive malformed QBO rows without poisoning paginated walks.
  • Typed return values: Every public method now declares an explicit Promise<XxxType> return; _customQuery widens to Promise<unknown> and callers parse their own envelopes.
  • Test coverage: New intuitAPI.responses.test.ts exercises the production .parse paths against canonical QBO shapes for all major read and write methods; existing getCustomerByEmail tests updated to match the revised schema.

Confidence Score: 5/5

Safe 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

Filename Overview
src/utils/intuitAPI.ts Major refactor: all methods gain return types, assertNotQBFault replaces duplicated fault-check blocks, _customQuery returns unknown, overload types hoisted. One schema redundancy in _getAllItems (double-parse) noted.
src/type/dto/intuitAPI.dto.ts Adds ~15 new Zod schemas and types for rows, envelopes, and delete responses; PrimaryEmailAddr.Address made nullish; QBItemsResponseSchema still has its own inline schema rather than reusing the new QBItemRowSchema.
test/unit/utils/intuitAPI.responses.test.ts New 20-test suite exercising the production Zod parse paths against canonical QBO shapes for all key read and write methods. Several write paths are not yet covered.
test/unit/utils/intuitAPI.test.ts Updated row() helper and test for null/nullish PrimaryEmailAddr to match the schema change.
src/helper/fetch.helper.ts Narrows postFetcher body from Record<string, any> to Record<string, unknown>; safe, no logic changes.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (2): Last reviewed commit: "fix(OUT-3543): address Greptile review —..." | Re-trigger Greptile

Comment thread src/utils/intuitAPI.ts
Comment thread test/unit/utils/intuitAPI.test.ts
Comment thread src/utils/intuitAPI.ts Outdated
…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.
@SandipBajracharya
Copy link
Copy Markdown
Collaborator Author

@greptileai

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant