OUT-3886: use user-selected Xero accounts for sync (income/bank/expense) - #61
Conversation
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>
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis 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
Confidence Score: 5/5Safe 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
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)
%%{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)
Reviews (2): Last reviewed commit: "feat(OUT-3886): post invoice payments ag..." | Re-trigger Greptile |
- 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>
| this.settingsPromise ??= new SettingsService(this.user, this.connection).getOrCreateSettings() | ||
| return this.settingsPromise |
There was a problem hiding this comment.
| this.settingsPromise ??= new SettingsService(this.user, this.connection).getOrCreateSettings() | |
| return this.settingsPromise | |
| return this.settingsPromise || new SettingsService(this.user, this.connection).getOrCreateSettings() |
There was a problem hiding this comment.
The whole purpose is to assign the value to settingsPromise. With your suggestion, this.settingsPromise is always undefined.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
| 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.
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>
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
uuidcolumns onsettings(incomeAccountId,bankAccountId,expenseAccountId) holding Xero AccountIDs + additive migration. Null ⇒ existing region-default behavior (non-breaking).src/lib/xero/accounts.ts— account-type groupings + categorization, reused by sync and (later) the UI.SyncedAccountsServiceprefers the selected account (resolved by AccountID), falling back to region-default get-or-create when unset, missing in Xero, code-less, or wrong-type (clearCONFLICT).SETTINGS_SELECT_FIELDS(kills duplicated field lists); extractedgetSelectedAccounthelper (DRY across the three account methods).Behavior
null(every existing portal) → identical to current region-default get-or-create.Test Plan
No automated test framework in this repo. Verified:
pnpm typecheckpnpm lintpnpm build(migration applies cleanly viabuild:all)🤖 Generated with Claude Code