Skip to content

OUT-3886: use user-selected Xero accounts for sync (income/bank/expense) - #61

Merged
SandipBajracharya merged 16 commits into
mainfrom
OUT-3886
Jun 23, 2026
Merged

OUT-3886: use user-selected Xero accounts for sync (income/bank/expense)#61
SandipBajracharya merged 16 commits into
mainfrom
OUT-3886

Conversation

@SandipBajracharya

Copy link
Copy Markdown
Collaborator

Summary

Backend half of OUT-3872. Lets sync use user-selected Xero accounts (income/bank/expense) instead of only hardcoded region-default codes. No UI yet — that's the follow-up ticket OUT-3887.

Resolves OUT-3886.

What changed

  • Schema: three nullable uuid columns on settings (incomeAccountId, bankAccountId, expenseAccountId) holding Xero AccountIDs + additive migration. Null ⇒ existing region-default behavior (non-breaking).
  • Shared util src/lib/xero/accounts.ts — account-type groupings + categorization, reused by sync and (later) the UI.
  • Sync resolution: SyncedAccountsService prefers the selected account (resolved by AccountID), falling back to region-default get-or-create when unset, missing in Xero, code-less, or wrong-type (clear CONFLICT).
  • Income account now actually drives invoices: the resolved sales account code is threaded into invoice line items and the invoice payment (previously the selection had no effect on invoices).
  • Refactors: single shared SETTINGS_SELECT_FIELDS (kills duplicated field lists); extracted getSelectedAccount helper (DRY across the three account methods).

Behavior

  • All three columns null (every existing portal) → identical to current region-default get-or-create.
  • A selected account → used for posting; payments enabled where required (sales/expense; bank accounts don't support the flag).

Test Plan

No automated test framework in this repo. Verified:

  • pnpm typecheck
  • pnpm lint
  • pnpm build (migration applies cleanly via build:all)
  • Manual: with no accounts selected, invoice + payment sync posts to region-default codes (unchanged)
  • Manual: with an income account selected, invoice line items + payment post to the selected account's code
  • Manual: selecting an archived/deleted account falls back with a logged warning

🤖 Generated with Claude Code

SandipBajracharya and others added 2 commits June 18, 2026 18:09
Add nullable income/bank/expense account id columns (Xero AccountID) to
settings, expose them through a single shared SETTINGS_SELECT_FIELDS set,
and add a shared account-type util for categorizing Xero accounts. Columns
are null by default so existing portals keep region-default behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SyncedAccountsService now resolves the user-selected account by AccountID
(via getSelectedAccount) and falls back to region-default get-or-create
when unset, missing in Xero, or code-less. The resolved sales account code
is threaded into invoice line items and the invoice payment, so a selected
income account actually drives where invoices post. No-op when unselected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 18, 2026

Copy link
Copy Markdown

OUT-3886

@supabase

supabase Bot commented Jun 18, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project pkdwtcdqcefmlgxmcwmc because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
xero-integration Ready Ready Preview, Comment Jun 23, 2026 11:58am

Request Review

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR wires user-selected Xero accounts (income, bank, expense) into the invoice sync flow, falling back gracefully to region defaults when no selection is made or when the stored account is archived, missing, or type-mismatched. It also stores the resolved salesAccountId on the synced invoice record so the payment step can post to the same account as the invoice line items.

  • Schema: adds three nullable UUID columns (incomeAccountId, bankAccountId, expenseAccountId) to settings and salesAccountId to synced_invoices; both are additive, null-safe migrations with no breaking changes.
  • SyncedAccountsService: new getSelectedAccount helper consolidates account-resolution logic (ACTIVE-status check, type validation, code presence guard, payments-enable) across all three account types; getSalesAccountById exposes the same path for the payment step to consume the stored account ID.
  • SyncedInvoicesService: syncInvoiceToXero now stores the resolved salesAccountId on success; syncPaidInvoiceToXero reads it back via getSalesAccountById and only falls through to getOrCreateCopilotSalesAccount when the stored ID is absent or no longer valid.

Confidence Score: 5/5

Safe to merge — all three account-resolution paths handle the null/archived/type-mismatch cases, migrations are additive and non-breaking, and existing portals with no selections get identical behavior to before.

The core sync paths are well-guarded: ACTIVE-status checks, type validation, code-presence guards, and payments-enable logic are all present. The salesAccountId round-trip correctly anchors payments to the same account as invoice line items. The only finding is a latent UX issue in the toClientXeroAccount utility — it doesn't yet filter inactive accounts, which will matter when the follow-up UI ticket is built but has no impact on the current sync flow.

src/lib/xero/accounts.ts — toClientXeroAccount should filter inactive accounts before the UI ticket (OUT-3887) consumes it.

Important Files Changed

Filename Overview
src/features/invoice-sync/lib/SyncedAccounts.service.ts Core of this PR: consolidates account resolution into getSelectedAccount/resolveSelectedAccount helpers with proper ACTIVE-status check, type guard, code-presence guard, and payments-enable logic. All three account roles covered.
src/features/invoice-sync/lib/SyncedInvoices.service.ts Stores resolved salesAccountId on the synced invoice at creation and reads it back at payment time via getSalesAccountById, with a clean fallback to getOrCreateCopilotSalesAccount.
src/lib/xero/accounts.ts New shared util; account-type arrays and toClientXeroAccount are correct but toClientXeroAccount does not filter out inactive/archived accounts, which will present stale selections to users when the UI ticket (OUT-3887) is built.
src/db/schema/settings.schema.ts Adds three nullable UUID columns and SETTINGS_SELECT_FIELDS constant; SettingsUpdateSchema correctly excludes countryCode but allows account IDs to be set client-side as intended.
src/db/schema/syncedInvoices.schema.ts Adds nullable salesAccountId UUID column; schema and migration are additive and consistent.
src/features/settings/actions/settings.ts Now imports SETTINGS_SELECT_FIELDS from the shared constant; deduplication is clean and the SettingsUpdateSchema correctly parses client payloads.
src/features/settings/context/SettingsContext.tsx Threads the three new account ID fields through the settings context provider; no logic changes.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant W as Webhook
    participant SI as SyncedInvoicesService
    participant SA as SyncedAccountsService
    participant DB as Database
    participant X as Xero API

    Note over W,X: invoice.created
    W->>SI: syncInvoiceToXero(data)
    SI->>DB: getOrCreateInvoiceRecord
    SI->>SA: getOrCreateCopilotSalesAccount(regionConfig)
    SA->>DB: getSettings() → incomeAccountId?
    alt incomeAccountId set
        SA->>X: getAccounts()
        SA-->>SA: resolveSelectedAccount (ACTIVE + type + code checks)
        SA-->>SI: selected Account
    else no selection / fallback
        SA->>X: getAccounts() / createSalesAccount()
        SA-->>SI: region-default Account
    end
    SI->>X: createInvoice(lineItems with salesAccount.code)
    X-->>SI: Invoice (invoiceID)
    SI->>DB: "updateInvoiceRecord(salesAccountId = salesAccount.accountID)"

    Note over W,X: invoice.paid
    W->>SI: syncPaidInvoiceToXero(copilotInvoiceId)
    SI->>DB: getValidatedInvoiceRecord → invoiceRecord.salesAccountId
    SI->>SA: getSalesAccountById(salesAccountId)
    alt stored accountID still ACTIVE + valid
        SA-->>SI: same Account used for invoice lines
    else archived / missing / null
        SA->>SA: getOrCreateCopilotSalesAccount(regionConfig)
        SA-->>SI: re-resolved Account (fallback)
    end
    SI->>X: markInvoicePaid(salesAccount.code)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant W as Webhook
    participant SI as SyncedInvoicesService
    participant SA as SyncedAccountsService
    participant DB as Database
    participant X as Xero API

    Note over W,X: invoice.created
    W->>SI: syncInvoiceToXero(data)
    SI->>DB: getOrCreateInvoiceRecord
    SI->>SA: getOrCreateCopilotSalesAccount(regionConfig)
    SA->>DB: getSettings() → incomeAccountId?
    alt incomeAccountId set
        SA->>X: getAccounts()
        SA-->>SA: resolveSelectedAccount (ACTIVE + type + code checks)
        SA-->>SI: selected Account
    else no selection / fallback
        SA->>X: getAccounts() / createSalesAccount()
        SA-->>SI: region-default Account
    end
    SI->>X: createInvoice(lineItems with salesAccount.code)
    X-->>SI: Invoice (invoiceID)
    SI->>DB: "updateInvoiceRecord(salesAccountId = salesAccount.accountID)"

    Note over W,X: invoice.paid
    W->>SI: syncPaidInvoiceToXero(copilotInvoiceId)
    SI->>DB: getValidatedInvoiceRecord → invoiceRecord.salesAccountId
    SI->>SA: getSalesAccountById(salesAccountId)
    alt stored accountID still ACTIVE + valid
        SA-->>SI: same Account used for invoice lines
    else archived / missing / null
        SA->>SA: getOrCreateCopilotSalesAccount(regionConfig)
        SA-->>SI: re-resolved Account (fallback)
    end
    SI->>X: markInvoicePaid(salesAccount.code)
Loading

Reviews (2): Last reviewed commit: "feat(OUT-3886): post invoice payments ag..." | Re-trigger Greptile

Comment thread src/features/invoice-sync/lib/SyncedAccounts.service.ts Outdated
Comment thread src/features/invoice-sync/lib/SyncedAccounts.service.ts Outdated
Comment thread src/features/invoice-sync/lib/SyncedInvoices.service.ts
SandipBajracharya and others added 2 commits June 22, 2026 14:47
- Treat an archived/deleted selected account as missing and fall back to
  the region default instead of failing opaquely at the Xero API
- Reject a region-default code held by an archived account (it still
  reserves the code, so it can neither be reused nor recreated)
- Use the "bank" role label for the asset/bank account so errors match
  the bankAccountId setting the user configured
- Type role labels with AssemblyAccountRole and switch resolver helpers
  to object params

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ccount

A payment previously re-resolved the sales account from current settings,
so it could post against a different account than the invoice's line items
if the income-account setting changed in between.

- Add a nullable sales_account_id column to synced_invoices
- Capture the resolved sales account id when the invoice is created
- Re-resolve that id at payment so it posts to the same account; fall back
  to region-default resolution for pre-migration or archived accounts
- Refactor updateInvoiceRecord to an object param

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SandipBajracharya

Copy link
Copy Markdown
Collaborator Author

@greptileai

@SandipBajracharya SandipBajracharya changed the title feat(OUT-3886): use user-selected Xero accounts for sync (income/bank/expense) OUT-3886: use user-selected Xero accounts for sync (income/bank/expense) Jun 22, 2026
Comment on lines +30 to +31
this.settingsPromise ??= new SettingsService(this.user, this.connection).getOrCreateSettings()
return this.settingsPromise

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
this.settingsPromise ??= new SettingsService(this.user, this.connection).getOrCreateSettings()
return this.settingsPromise
return this.settingsPromise || new SettingsService(this.user, this.connection).getOrCreateSettings()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The whole purpose is to assign the value to settingsPromise. With your suggestion, this.settingsPromise is always undefined.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ok. We should not be dependending upon these kind of mutations if possible though.

if (!selectedAccountId) return null

const accounts = await this.getAccounts()
const selected = accounts.find((acc) => acc.accountID === selectedAccountId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const selected = accounts.find((acc) => acc.accountID === selectedAccountId)
const selectedAccount = accounts.find((acc) => acc.accountID === selectedAccountId)

Optional but imo selectedAccount is a better name here.

Comment thread src/features/invoice-sync/lib/SyncedAccounts.service.ts Outdated
Comment thread src/features/invoice-sync/lib/SyncedAccounts.service.ts
SandipBajracharya and others added 11 commits June 23, 2026 17:42
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Arrow keys now reach the "Use default account" option, and the default
account no longer appears twice when payments are enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Promise.all fetch block and xeroAuthFailed tracking out of
the Home component into a dedicated getPageData function.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Panel-only searchable select that owns search filtering, keyboard
navigation, focus management, scroll-into-view, and click-outside.
Supports an optional keyboard-reachable action row via render slots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the hand-rolled dropdown (search input, keyboard handler,
focus effects, options list) with the shared SearchableSelectMenu.
AccountSelect keeps only its trigger, tone logic, and domain filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match search per-field (getSearchValues) so a query spanning the
name/code boundary no longer false-matches, and keep a stable close
reference so useDropdown doesn't re-bind its listener each render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tMenu

Replace the hand-rolled dropdown with the shared SearchableSelectMenu.
The "Exclude from mapping" row is now keyboard-reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the let-based if/else chain for the trigger label and tone with a
module-level pure helper using early returns, returning const bindings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@SandipBajracharya
SandipBajracharya merged commit 2b895e6 into main Jun 23, 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