Skip to content

OUT-3275: account dropdowns in QB settings#253

Merged
SandipBajracharya merged 10 commits into
masterfrom
OUT-3275
May 22, 2026
Merged

OUT-3275: account dropdowns in QB settings#253
SandipBajracharya merged 10 commits into
masterfrom
OUT-3275

Conversation

@SandipBajracharya
Copy link
Copy Markdown
Collaborator

@SandipBajracharya SandipBajracharya commented May 20, 2026

Summary

Adds an Other Settings accordion section to the QuickBooks integration's settings dashboard that lets portal admins pick which QBO accounts the integration uses:

  • Income account — default income account assigned to services synced from Assembly.
  • Expense account — where absorbed invoice payment fees are recorded as expenses.
  • Bank account — funds those expenses (paired with the expense account).

Linear: https://linear.app/assemblycom/issue/OUT-3275

Scope note: This PR is the dropdown half of OUT-3275 only. The "colon in product name causes duplicate items" half of the same ticket is descoped to a separate effort.

Architecture

  • Server — single route src/app/api/quickbooks/accounts/route.ts exports both GET (returns { options, selected } per bucket) and PATCH (validates each ref's AccountType against QBO, then updates qb_portal_connections scoped by portalId).
  • Validation safety — token fields can't leak: the response uses QBPortalConnectionSelectSchema.pick({...}) as an allowlist; tests assert accessToken/refreshToken/tokenType are absent from the response.
  • Multi-tenancy — every read/write scoped via eq(QBPortalConnection.portalId, this.user.workspaceId); tenant-isolation integration test confirms portal A's token cannot mutate portal B's row.
  • QBO query parser quirk_getAccountsForProductMapping uses three flat queries (one per AccountType); OR, parentheses, and IN-on-AccountType all rejected by QBO's parser. Verified empirically in sandbox.
  • UIuseOtherSettings hook gates the GET on syncFlag && portalConnectionStatus; when disconnected, shows an inline "Connect to QuickBooks" message and never calls the API. Custom button-based dropdown mirrors the existing QuickBooks-items dropdown styling (no search, no sticky options).

Commits

edbde4c fix(OUT-3275): address pre-push review
1a84634 test(OUT-3275): unit + integration coverage for account-ref endpoints
6b99712 feat(OUT-3275): Other Settings accordion with QBO account dropdowns
1dc37d8 feat(OUT-3275): GET/PATCH /api/quickbooks/accounts and supporting helpers

Test plan

  • yarn test — 211/211 pass (34 files, unit + integration via testcontainers)
  • npx tsc --noEmit — zero errors
  • yarn lint:check + yarn prettier:check — clean (10 pre-existing warnings, none new)
  • Manual smoke against QBO sandbox — opening "Other Settings" loads all three dropdowns; changing the income account and syncing a Copilot product produces a QBO item with the chosen income account.
  • Reviewer to spot-check the _getAnAccount SELECT extension (AccountType added) doesn't surprise any existing callers (verified: all 5 existing callers only read Id/Name/SyncToken/Active).
  • Reviewer to confirm the QBO AccountType narrowing (income subtype = SalesOfProductIncome, asset = Bank) matches product intent; "user will refine subtypes later" is captured.

Testing Criteria

https://www.loom.com/share/95f84287758c4917af56ca099e948e9f

Known follow-ups (separate tickets)

  • checkAndUpdateAccountStatus self-heal audit — confirm it doesn't silently overwrite a user-chosen ref. Pre-existing concern flagged in the spec, not part of this PR.
  • Custom dropdown ARIA: this PR adds aria-haspopup/aria-expanded/role="listbox"/role="option"; full keyboard navigation (arrow keys, Escape) deferred.

🤖 Generated with Claude Code

SandipBajracharya and others added 4 commits May 20, 2026 14:36
…pers

Adds the server side of the settings dashboard account-ref editor: a single
/api/quickbooks/accounts route handling GET (lists income/expense/bank
accounts from QBO plus the portal's currently-selected refs) and PATCH
(validates each provided ref against QBO and updates qb_portal_connections
scoped by portalId).

- IntuitAPI.getAccountsForProductMapping runs three flat queries (no OR,
  parens, or IN — QBO's parser rejects all three on AccountType).
- _getAnAccount SELECT now includes AccountType so PATCH validation can
  read it; QBAccountRowSchema tightened to require AccountType.
- TokenService.updateAccountRefs validates AccountType per bucket in
  parallel before delegating to updateQBPortalConnection.
- Response uses an allowlist (QBPortalConnectionSelectSchema.pick) so
  token fields can't leak.
- patchFetcher helper mirrors postFetcher (throws on non-OK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third accordion section below Invoice Details that lets the user
pick which QBO accounts the integration uses for income, expense, and
bank. Skips the /accounts fetch entirely when QB isn't connected
(syncFlag off or no portal connection) and surfaces a "Connect to
QuickBooks" message inline instead.

- useOtherSettings hook backs the section via SWR; submits only changed
  fields through patchFetcher and mutates the cache on success.
- Custom dropdown component mirrors the QuickBooks-items dropdown
  pattern (button + click-outside-to-close panel) instead of a native
  <select>; no search box or sticky options since the lists are short.
- SettingAccordion wires the new section with the existing
  Cancel/Update button pattern, gated on syncFlag and showButton.
- useSettings opens the new "other-settings" item by default for
  un-enabled portals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- IntuitAPI.getAccountsForProductMapping: asserts the three queries are
  built without OR / parens / IN (QBO parser limits) and that the asset
  bucket queries the Bank type.
- AccountService.listAccountsForProductMapping: happy path + 404 when
  portal connection is missing; verifies SyncToken/Active are stripped.
- TokenService.updateAccountRefs: per-bucket AccountType validation,
  rejects mismatches and missing accounts, writes scoped by portalId.
- GET /api/quickbooks/accounts: 200 happy path with TEST_*_ACCOUNT_REF
  constants; 401 without token.
- PATCH /api/quickbooks/accounts: single-field update, wrong-AccountType
  rejection, empty-body 422, all-three-valid, tenant isolation.
- Extends QBAccountRowSchema fixtures in intuitAPI.responses.test.ts
  with AccountType after the schema tightening.
- Pins integration mock factories on globalThis (per the documented
  setupFile re-entry workaround) and adds getAccountsForProductMapping
  to the IntuitAPI mock default so existing tests don't break.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- TokenService.updateAccountRefs: guard the post-update row destructure
  against undefined so a zero-row WHERE produces an explicit 500 instead
  of a misleading 422 from SafePortalConnectionSchema.parse(undefined).
- Replace inline 'Income'/'Expense'/'Bank' magic strings with a local
  QBO_ACCOUNT_TYPE map. The existing AccountTypeObj enum holds
  lowercase routing keys, not QBO API values, so reusing it would
  mislead — keep the maps distinct, documented inline.
- IntuitAPI.getAccountsForProductMapping: reuse QB_ACCOUNT_COLUMNS in
  the SELECT clauses so the schema/SQL stay in sync; drop the "no
  response from QBO" throw (customQuery already throws on no-response;
  the remaining undefined-QueryResponse case is now treated as an
  empty bucket so the UI shows "No matching accounts" instead of
  "Could not load"); rename assetOtherRaw → assetRaw.
- OtherSettings dropdown: add aria-haspopup="listbox", aria-expanded,
  aria-labelledby on the trigger and role="listbox"/role="option"/
  aria-selected on the panel so screen readers can navigate it.
- Add regression test for the undefined-bucket → empty path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 20, 2026

OUT-3275

@vercel
Copy link
Copy Markdown

vercel Bot commented May 20, 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 20, 2026 9:59am

Request Review

@SandipBajracharya SandipBajracharya changed the title feat(OUT-3275): account dropdowns in QB settings OUT-3275: account dropdowns in QB settings May 20, 2026
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 20, 2026

Greptile Summary

This PR adds an "Other Settings" accordion to the QuickBooks integration dashboard, letting portal admins choose QBO accounts for income, expense, and bank buckets via three custom dropdowns backed by a new GET/PATCH /api/quickbooks/accounts endpoint.

  • API layer: AccountService fetches active accounts per bucket with three separate flat QBO queries; TokenService.updateAccountRefs validates each supplied ID against QBO before updating the scoped portal connection row. A Zod allowlist (SafePortalConnectionSchema) ensures token fields can never appear in the response.
  • UI layer: useOtherSettings gates the SWR fetch behind syncFlag && portalConnectionStatus, tracks dirty state, and handles optimistic cancel. OtherSettings.tsx adds a stale-ID hint when a previously saved account no longer appears in the dropdown options.
  • Test coverage: 211 tests pass; unit and integration tests cover happy paths, wrong AccountType rejections, empty-body 422s, all-three-refs updates, tenant isolation, and auth guards.

Confidence Score: 4/5

Safe to merge — the new endpoint is correctly scoped per tenant, token fields are allowlisted out of responses, and account validation against QBO runs before any DB write.

The core GET/PATCH flows, validation, and tenant isolation are solid. Two style-level concerns hold the score back slightly: AccountOption is defined identically in both common.ts and useSettings.ts (drift risk), and the accordion save/cancel button visibility is gated on index === 2 which will silently misfire if sections are ever reordered.

src/components/dashboard/settings/SettingAccordion.tsx for the positional index coupling; src/hook/useSettings.ts and src/type/common.ts for the duplicated AccountOption type.

Important Files Changed

Filename Overview
src/app/api/quickbooks/accounts/accounts.controller.ts New controller for GET/PATCH; uses SafePortalConnectionSchema allowlist to strip token fields. Auth and error handling look correct.
src/app/api/quickbooks/accounts/accounts.service.ts Thin service wrapping IntuitAPI.getAccountsForProductMapping, maps QBO rows to {id, name} options. Error handling for missing portal connection is correct.
src/app/api/quickbooks/token/token.service.ts New updateAccountRefs validates each account ID against QBO then updates the portal connection row scoped by workspaceId. Payload is parsed twice (controller + service), redundant but harmless.
src/utils/intuitAPI.ts Adds AccountType to QB_ACCOUNT_COLUMNS and adds _getAccountsForProductMapping with three flat queries to work around QBO parser limitations.
src/hook/useSettings.ts New useOtherSettings hook with SWR gating, dirty-state tracking, and optimistic cancel. AccountOption is re-declared instead of imported from common.ts.
src/components/dashboard/settings/SettingAccordion.tsx Wires in OtherSettings accordion; button visibility uses fragile index === 2 pattern.
src/components/dashboard/settings/sections/other/OtherSettings.tsx New AccountSelect with click-outside, ARIA attributes, and stale-ID hint. Disconnect/error states handled correctly.
src/type/common.ts Adds AccountOption, AccountsListResponse, AccountRefsUpdateSchema, AccountRefsUpdateType. AccountOption duplicated in useSettings.ts.
test/integration/quickbooks/accounts/updateAccountRefs.test.ts Thorough integration coverage: happy path, wrong AccountType, empty body 422, all-three-refs, tenant isolation, auth guard.

Sequence Diagram

sequenceDiagram
    participant UI as OtherSettings (React)
    participant Hook as useOtherSettings
    participant GETRoute as GET /api/quickbooks/accounts
    participant PATCHRoute as PATCH /api/quickbooks/accounts
    participant AccountSvc as AccountService
    participant TokenSvc as TokenService
    participant QBO as QuickBooks Online
    participant DB as qb_portal_connections

    UI->>Hook: mount
    Hook->>GETRoute: SWR fetch
    GETRoute->>AccountSvc: listAccountsForProductMapping()
    AccountSvc->>DB: getPortalTokens(portalId)
    DB-->>AccountSvc: tokens + selected refs
    AccountSvc->>QBO: 3x customQuery (Income/Expense/Bank)
    QBO-->>AccountSvc: account lists
    AccountSvc-->>GETRoute: options + selected
    GETRoute-->>Hook: 200
    Hook-->>UI: render dropdowns
    UI->>Hook: changeSettings()
    Hook-->>UI: "showButton=true"
    UI->>Hook: submitOtherSettings()
    Hook->>PATCHRoute: PATCH changed fields
    PATCHRoute->>TokenSvc: updateAccountRefs(payload)
    TokenSvc->>DB: getPortalTokens(portalId)
    loop for each changed ref
        TokenSvc->>QBO: getAnAccount(id)
        QBO-->>TokenSvc: account row
        TokenSvc->>TokenSvc: validate AccountType
    end
    TokenSvc->>DB: updateQBPortalConnection
    DB-->>TokenSvc: updated row
    TokenSvc-->>PATCHRoute: conn
    PATCHRoute-->>Hook: 200 portalConnection
    Hook->>GETRoute: mutate
Loading

Comments Outside Diff (1)

  1. src/components/dashboard/settings/SettingAccordion.tsx, line 267 (link)

    P2 Fragile positional index for button render gating

    The index === 2 guard follows the same pre-existing pattern as index === 0 (product-mapping) and index === 1 (invoice-detail), but extending it makes the coupling harder to notice. If a future section is inserted before "Other Settings" in accordionItems, the save/cancel buttons will render in the wrong accordion panel with no compile-time indication. Consider keying the footer buttons on item.id === 'other-settings' instead of the positional index.

Reviews (1): Last reviewed commit: "fix(OUT-3275): address pre-push review" | Re-trigger Greptile

Comment thread src/type/common.ts
AccountOption was declared identically in src/type/common.ts and
src/hook/useSettings.ts. Drift risk: any addition to one shape would
silently diverge from the other across the client/server boundary.

Drop the local declaration in useSettings.ts; import the canonical
AccountOption from @/type/common. OtherSettings.tsx imports the type
directly from @/type/common too so each consumer reads the same source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/app/api/quickbooks/accounts/accounts.controller.ts Outdated
Comment thread src/app/api/quickbooks/accounts/accounts.controller.ts Outdated
Comment thread src/app/api/quickbooks/accounts/accounts.service.ts Outdated
Comment thread src/app/api/quickbooks/accounts/accounts.service.ts
Comment thread src/app/api/quickbooks/token/token.service.ts Outdated
Comment thread src/app/api/quickbooks/token/token.service.ts Outdated
Comment thread src/app/api/quickbooks/token/token.service.ts Outdated
Comment thread src/app/api/quickbooks/token/token.service.ts Outdated
Comment thread src/app/api/quickbooks/token/token.service.ts
Comment thread src/hook/useSettings.ts
…ocess

Per review feedback: variable names should answer "what is this value?",
not "what function ran to produce it?". Renamed in the controller +
service:

- `parsed` → `accountRefs`
- `data` → `accountsResponse`
- `payload` (local, not parameter) → `accountRefs`
- `conn` → `updatedConnection`
- `safe` → `safePortalConnection`

No behavior change. Tests + tsc + lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SandipBajracharya and others added 2 commits May 21, 2026 14:31
The PATCH endpoint is dashboard-only. Every account ref the dashboard
sends came from GET /api/quickbooks/accounts, which itself reads from
QBO. Re-fetching each ref to validate AccountType is redundant work
(up to 3 QBO API calls per save) defending against scenarios that
require either a dashboard bug or direct API misuse — neither of which
this layer is the right place to defend against.

- TokenService.updateAccountRefs: drop the per-ref getAnAccount +
  AccountType check; just write the refs scoped by portal_id and
  return the updated row.
- Lift the 404 into getPortalTokens itself (throws APIError(404) on
  missing connection) so callers don't need to try/catch and rewrap.
- accounts.service.ts: drop the now-redundant try/catch around
  getPortalTokens; the APIError it throws already has the right shape.
- accounts.controller.ts: inline SafePortalConnectionSchema.parse into
  the response.
- Tests: remove the now-stale "wrong AccountType" assertions (4 unit
  cases + 1 integration case); simplify the survivors to drop unused
  getAnAccount mocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…useSwrHelper

Four of the five useSwrHelper callers in the settings dashboard pass the
same { suspense: true, revalidateOnMount: false } overrides. Move them
into the helper's defaults; callers can still override per-call.

useOtherSettings keeps an explicit { suspense: false, revalidateOnMount:
true } override because its loading/error state must stay inline to the
accordion section. Without it, SWR's suspense bubble would hit the
page-level loading.tsx and flash a full-screen spinner over the rest of
the dashboard on first open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Callers can now pass useSwrHelper<MyType>(url) and `data` is typed as
MyType | undefined instead of any. Default T = any keeps existing
call sites working without changes.

useOtherSettings adopts the generic and drops its `as ...` cast on
data?.options. The other consumers in this file (product mapping,
invoice detail) stay on the implicit any default and can be migrated
incrementally as they're touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Collaborator

@priosshrsth priosshrsth left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Renames the third settings accordion ("Other Settings" → "Account Mapping")
end-to-end: section header, accordion id, file/folder path, component, hook,
state/props types, handlers, and destructure aliases. Also updates the
missing-option placeholder copy to "Please select <label>" instead of
surfacing the opaque QBO ref id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@SandipBajracharya SandipBajracharya merged commit ff8129e into master May 22, 2026
5 checks passed
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.

2 participants