fix(bank): keep other companies' accounts out of the EB account picker (#2141)
* fix(bank): keep other companies' accounts out of the EB account picker At one-session banks (SEB) a single BankID consent returns every account the signer can see across all their companies, so a reconnect from company A carries company B's accounts. PR #2116 made those arrive unchecked, labelled and unmirrored; they were still listed in company A's picker and in the connection's account list in settings, which read as "the wrong company's data in my books" (user report, Deepgrid group). - New lib/claimed-accounts.ts: partitionByClaim() splits a connection's accounts on claimed_by_company_id; describeClaimedElsewhere() renders the one-line Swedish summary. Unit-tested, including the legacy double-claim (no flag, stays own) and carried-deselection cases. - AccountPickerDialog: main list, "Markera alla" and the "x av y valda" counter cover own accounts only. Claimed accounts sit behind a collapsed "N konton synkas i <bolag>" disclosure (still tickable: a claim is a strong hint, not proof of ownership). Row markup extracted into renderAccountRow so both lists share it. - BankConnectionStatus: foreign rows dropped from the details list and the "x av y konton synkas" count; one muted summary line instead. No data or callback changes; brand-new never-claimed accounts still list unchecked, since Enable Banking's account resource carries no owner org number. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi * fix(bank): close the review and skeptic findings on the claimed-accounts picker Review (CodeRabbit): describeClaimedElsewhere decided "same claimant" on the display name; two companies can share a name. Now keyed on claimed_by_company_id as well, with a test. Skeptics (correctness + regression): - The empty-own-list message asserted "synkas redan i andra bolag" even for a consent with no accounts at all (failed connect, nothing ticked at the bank). Now only when claimed accounts exist; otherwise a plain "inga konton" message. - "Markera alla" stayed enabled but inert with zero own accounts: allSelected is now vacuously true there, so the button disables. - A claimed account ticked inside the disclosure kept counting after the disclosure was collapsed: the disclosure line now names the ticked count so the "x av y valda" counter never exceeds what is visible. - The pending_selection row in settings still counted foreign accounts ("3 konton tillgängliga" beside a picker saying none): now own accounts, with a dedicated line when everything is claimed elsewhere. - Claim flags did not survive an in-place renewal (accountsMetadata is rebuilt without them and the guard skipped seen-on-row accounts), so the sibling's accounts returned to the main list unlabeled on the next reconnect. The callback now re-derives the label from a fresh lookup for accounts that stay disabled here; released claims clear themselves. Two callback tests. Skeptic (compliance) hardening: - partitionByClaim requires enabled === false alongside the flag, so a flagged-but-enabled row (any future writer) can never hide a syncing account. - The sibling company's name is data-ph-masked on the settings summary line and the disclosure line, matching the row label. Declined: CodeRabbit docstring-coverage warning (repo has no docstring requirement; the touched functions carry inline rationale comments). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi * fix(bank): keep a re-stamped sibling claim out of the cash-account mirror CodeRabbit round 2: the renewal branch that re-derives the claim label left the account out of guardDisabledUids, so the mirror below still ran upsertFromPsd2 for it. The first connect never mirrored that account (#2116), so a renewal would have planted the sibling's IBAN in this company's cash_accounts and burned a 19xx slot for an account that stays off. Now excluded like a fresh claim; the renewal test asserts only the own account is mirrored. Declined (recorded for the summary): compliance-swarm advisory that the sibling's account metadata reaches the client. Both companies belong to the same signed-in user and the data arrives under that user's own PSD2 consent; the ownership decision is already made server-side in the callback, the picker only renders it. Non-blocking, no cross-user data. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GY5eTAUFZDoCfbdWoERrsi --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
11017ef9ee
commit
158108ec01
@@ -1475,6 +1475,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-01] mcp.tool_called gets errorCause = errorCauseTag(err) on the two execution catch paths only (#2051): SQLSTATE or coded-error code, else the error class name, capped at 64 chars; a plain Error deliberately tags null because the class name 'Error' is noise, and pre-execution denials pass nothing since their errorCode already IS the vocabulary. Raw driver messages stay out of event_log on purpose: a constraint-violation message can quote row values.
|
||||
[2026-09-01] counterparty_aliases joins the categorization_templates audit-trigger strip list (20260901200000) instead of staying logged: prod falsified the original exclusion list within 30 minutes of 20260901103000 going live (15 of the first 16 UPDATE audit rows were alias+learning noise, ~800/day projected vs ~50/day of real rule changes), because the learning path merges aliases in the same write that bumps occurrence_count. Explicit trade-off: a human editing ONLY aliases is no longer logged; accepted since alias growth is overwhelmingly automatic and any change also touching accounts/VAT/pattern/active still logs (first real one, 19:02:17Z same day, captured correctly). Pre-fix noise rows stay in audit_log (append-only) and the read model stops labelling the column so they render as no-ops.
|
||||
[2026-09-01] MCP catalog budget attacked at the duplicated staged envelope rather than by demoting more reads: measuring the payload by segment showed outputSchema is 38 % of the whole catalog (23 290 tokens) and STAGED_OPERATION_SCHEMA alone 14 736 of it, the same envelope transmitted 58 times, while descriptions (what the three previous rounds trimmed) are only 10 %. period_status now carries its shape in one sentence instead of declared JSON Schema, matching actor/approve/preview which were always bare objects; 2 552 tokens reclaimed with no tool demoted and no field removed. Every edit is in the LOOSER direction because the server emits structuredContent for every tool and the documented failure mode is a declaration too tight making a strict client reject a successful call. next kept additionalProperties: false: staging.test.ts pins it closed and a guard whose reason is not in front of you is not one to loosen for 420 tokens. Ceiling ratcheted to 60 000 rather than the usual ~300 margin, leaving ~1 070 deliberate working margin: server.ts took 70 commits in 14 days and the previous 116-token margin is what starts the ratchet-block-bump-demote cycle visible in the bench log.
|
||||
[2026-09-01] Other companies' accounts in the EB account picker are collapsed behind a disclosure, not removed: a sibling claim (claimed_by_company_id from PR #2116) is a strong hint, not proof of ownership, and hiding the rows outright would leave a legacy or mis-booked account unreachable without a database edit. The main list, 'Markera alla' and the 'x av y' counter cover own accounts only; the settings row shows one muted summary line instead of the foreign rows. Brand-new never-claimed accounts still list (unchecked): Enable Banking's account resource carries no owner org number, so nothing in the data attributes them.
|
||||
[2026-09-01] EB callback re-stamps a sibling claim across an in-place renewal (PR #2141 skeptic finding): accountsMetadata is rebuilt from the bank's list on every callback, so the claimed_by_company_* label from PR #2116 was lost the first time the consent was renewed and the sibling's accounts came back as plain unchecked own accounts. The label is re-derived from a fresh cross-company lookup on accounts that stay disabled here, never copied from the prior row, so a released claim clears itself; an enabled account is the active company's standing state and is never labelled. partitionByClaim also requires enabled === false alongside the flag: a syncing account must never be tucked out of sight, whatever wrote the flag.
|
||||
[2026-09-01] Inbox +lev/+ver plus-addressing (#2129) stores the sender's tag in a new nullable invoice_inbox_items.kind_hint column rather than inside extracted_data.documentKind: retry-extraction overwrites that JSONB container wholesale, and the sender's statement must outlive the AI's guess. Unknown tags route with kind_hint NULL (a typo never loses a document); the type filter's two narrow entries exclude unclassified rows on purpose (a narrow filter promises a known kind; 'Alla typer' is where the rest live), and the whole type menu stays hidden until at least one row is classified so an unclassified inbox gets no dead control.
|
||||
[2026-09-01] Ta bort underlag (#2132): detach gate and 409 mapping live in a pure helper (components/transactions/detach-underlag.ts) with file-level assertions, not a rendered component test, because Vitest runs in node and never renders. The skeptic pass refuted the frontend-only plan: the existing DELETE attach-document route left invoice_inbox_items.matched_transaction_id set, and propagateUnderlagForBookedTransaction selects on exactly that column at booking time, so the detached receipt would have been re-anchored onto the new verifikation as immutable underlag (BFL 5 kap 7 §). The route now clears the back-link for the detached doc (scoped to items not yet consumed by a verifikat), mirroring the invoice-inbox unmatch. Accepted limitation: the history list cannot see transaction_voucher_links, so bulk-booked rows (journal_entry_id null) still show the item and get the route 409 with the storno message; the same row-classification gap already applies to Matcha mot befintlig verifikation and Ta bort on those rows, and fixing it means new list-side data plumbing, filed as follow-up with the MCP detach tool.
|
||||
[2026-09-01] Checklist "Anslut till Claude" done-signal = unrevoked api_keys row named MCP-klient (OAuth) for the USER, not per company: the OAuth token route is the only writer of that name and the key company_id is whatever was active at sign-in (null for companyless signups), so a company filter would miss real connections; the AI-profile flag it replaced never meant "connected to Claude" (#2133). Counted through the service client with an explicit user_id filter, not the user client: api_keys' SELECT policy is company-scoped (20260330130000), which hides companyless and archived-company keys and left the step open for exactly the user who had just connected (skeptic refutation on PR #2147). The manual create route reserves the name (400) rather than adding a source column: a migration for a cosmetic tick is not worth it, and the name is already the only marker every reader of that row uses. The consent-page default (all scopes pre-selected, founder decision 2026-08-26) is described, not changed; the compliance swarm's GDPR Art.25(2) finding on this PR targets that decision and is Emil's call, not this docs fix.
|
||||
|
||||
@@ -559,6 +559,94 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-stamps the sibling claim on a disabled account across an in-place renewal', async () => {
|
||||
// accountsMetadata is rebuilt from the bank's list on every callback, so a
|
||||
// claim label stored on the previous consent would be lost on renewal and
|
||||
// the sibling's account would come back as a plain unchecked own account.
|
||||
const capturedUpdates = mockConnectionFlow({
|
||||
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'expired',
|
||||
accounts_data: [
|
||||
{ uid: 'acc-own-old', iban: 'SE1234', name: 'Företagskonto', currency: 'SEK', enabled: true },
|
||||
{
|
||||
uid: 'acc-foreign-old', iban: 'SE9999', name: 'Annat bolags konto', currency: 'SEK',
|
||||
enabled: false, claimed_by_company_id: 'company-2', claimed_by_company_name: 'Other AB',
|
||||
},
|
||||
],
|
||||
})
|
||||
mockCrossCompanyContext.mockResolvedValue({
|
||||
claims: new Map([['SE9999', { companyId: 'company-2', companyName: 'Other AB' }]]),
|
||||
deselectedIbans: new Set(),
|
||||
activeCompanyIbans: new Set(['SE1234']),
|
||||
})
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
accounts: [
|
||||
{ uid: 'acc-own-new', account_id: { iban: 'SE1234' }, name: 'Företagskonto', currency: 'SEK' },
|
||||
{ uid: 'acc-foreign-new', account_id: { iban: 'SE9999' }, name: 'Annat bolags konto', currency: 'SEK' },
|
||||
],
|
||||
access: { valid_until: '2027-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'SEB', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
await response.text()
|
||||
|
||||
const accountsData = capturedUpdates[0].accounts_data as Array<{
|
||||
uid: string
|
||||
enabled: boolean
|
||||
claimed_by_company_id?: string
|
||||
claimed_by_company_name?: string
|
||||
}>
|
||||
const own = accountsData.find(a => a.uid === 'acc-own-new')
|
||||
const foreign = accountsData.find(a => a.uid === 'acc-foreign-new')
|
||||
expect(own?.enabled).toBe(true)
|
||||
expect(own?.claimed_by_company_id).toBeUndefined()
|
||||
expect(foreign?.enabled).toBe(false)
|
||||
expect(foreign?.claimed_by_company_id).toBe('company-2')
|
||||
expect(foreign?.claimed_by_company_name).toBe('Other AB')
|
||||
// The re-stamped claim stays out of the mirror exactly like a fresh one:
|
||||
// only the own account gets a cash_accounts row.
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
|
||||
expect((mockUpsertFromPsd2.mock.calls[0][2] as { external_uid: string }).external_uid).toBe('acc-own-new')
|
||||
})
|
||||
|
||||
it('drops a stale claim on renewal when the sibling no longer books the account', async () => {
|
||||
// The label is re-derived from a fresh lookup, never copied from the prior
|
||||
// row: once company B has released the IBAN, company A's picker must show
|
||||
// it as a plain unchecked account again, not as "synkas i B".
|
||||
const capturedUpdates = mockConnectionFlow({
|
||||
id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'SEB', status: 'expired',
|
||||
accounts_data: [
|
||||
{
|
||||
uid: 'acc-old', iban: 'SE9999', name: 'Konto', currency: 'SEK',
|
||||
enabled: false, claimed_by_company_id: 'company-2', claimed_by_company_name: 'Other AB',
|
||||
},
|
||||
],
|
||||
})
|
||||
mockCrossCompanyContext.mockResolvedValue({
|
||||
claims: new Map(),
|
||||
deselectedIbans: new Set(),
|
||||
activeCompanyIbans: new Set(),
|
||||
})
|
||||
mockCreateSession.mockResolvedValue({
|
||||
session_id: 'sess-2',
|
||||
accounts: [{ uid: 'acc-new', account_id: { iban: 'SE9999' }, name: 'Konto', currency: 'SEK' }],
|
||||
access: { valid_until: '2027-12-31T00:00:00Z' },
|
||||
aspsp: { name: 'SEB', country: 'SE' },
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
await response.text()
|
||||
|
||||
const accountsData = capturedUpdates[0].accounts_data as Array<{
|
||||
uid: string
|
||||
enabled: boolean
|
||||
claimed_by_company_id?: string
|
||||
}>
|
||||
expect(accountsData[0].enabled).toBe(false)
|
||||
expect(accountsData[0].claimed_by_company_id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries a deselection made on another connection row onto a fresh connect', async () => {
|
||||
// C2: "Synkas ej" chosen under one company must not come back pre-checked
|
||||
// when a new connection row (any company) sees the same IBAN.
|
||||
|
||||
@@ -589,7 +589,30 @@ async function finalizeConnection(
|
||||
priorEnabledByUid.has(account.uid) ||
|
||||
(normalizedIban ? priorEnabledByIban.has(normalizedIban) : false) ||
|
||||
pairedPriorUidByNewUid.has(account.uid)
|
||||
if (seenOnThisRow) continue
|
||||
if (seenOnThisRow) {
|
||||
// The carried enabled/disabled state stands. The claim label is
|
||||
// metadata on top of it: accountsMetadata is rebuilt without the prior
|
||||
// flags, so without this an in-place renewal would drop the label and
|
||||
// the picker would list the sibling's accounts as plain unchecked own
|
||||
// accounts again. Re-stamp it only on an account that stays disabled
|
||||
// here (an enabled one is the active company's standing state, which
|
||||
// outranks any claim), and only from a fresh lookup, never from the
|
||||
// stale prior flag.
|
||||
if (account.enabled === false && crossCompany !== null) {
|
||||
const claim = normalizedIban ? crossCompany.claims.get(normalizedIban) : undefined
|
||||
if (claim) {
|
||||
account.claimed_by_company_id = claim.companyId
|
||||
if (claim.companyName) account.claimed_by_company_name = claim.companyName
|
||||
// Keep it out of the cash_accounts mirror too: the first connect
|
||||
// never mirrored it (see guardDisabledUids below), and mirroring
|
||||
// it now would plant the sibling's IBAN in this company's routing
|
||||
// table and burn a 19xx slot for an account that stays off.
|
||||
guardDisabledUids.add(account.uid)
|
||||
claimedCount += 1
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (crossCompany === null) {
|
||||
// Fail closed: without the claim set a free account cannot be told from
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import {
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
resolveFiscalYearStart,
|
||||
resolveGapFillStart,
|
||||
} from '../lib/date-suggestions'
|
||||
import { describeClaimedElsewhere, partitionByClaim } from '../lib/claimed-accounts'
|
||||
import type { StoredAccount } from '../types'
|
||||
import {
|
||||
BankSyncProgressDialog,
|
||||
@@ -90,6 +92,15 @@ export function AccountPickerDialog({
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
// Accounts the callback found booked by another of the user's companies
|
||||
// (one SEB consent covers every company the signer represents). They are
|
||||
// kept out of the main list so the picker shows THIS company's accounts,
|
||||
// and live behind a collapsed disclosure: still reachable, never pre-checked.
|
||||
const { own: ownAccounts, claimedElsewhere } = useMemo(
|
||||
() => partitionByClaim(accounts),
|
||||
[accounts],
|
||||
)
|
||||
const [claimedOpen, setClaimedOpen] = useState(false)
|
||||
// Server-side save rejection (validation / ledger conflict). Shown inline in
|
||||
// the dialog: a rejected save persisted nothing and started no sync, so the
|
||||
// user must see why and be able to correct the picks.
|
||||
@@ -166,6 +177,7 @@ export function AccountPickerDialog({
|
||||
)
|
||||
setSelected(initial)
|
||||
setSaveError(null)
|
||||
setClaimedOpen(false)
|
||||
setLookbackMode('fast')
|
||||
setCustomSubMode('date')
|
||||
setCustomDate('')
|
||||
@@ -300,13 +312,22 @@ export function AccountPickerDialog({
|
||||
return () => { cancelled = true }
|
||||
}, [open, isInitialSelection, company?.id, connectionId, supabase, accounts])
|
||||
|
||||
const allSelected = accounts.length > 0 && selected.size === accounts.length
|
||||
// "Alla" means this company's accounts: a claimed account is only ever
|
||||
// selected by an explicit tick inside the disclosure. Vacuously true when
|
||||
// there are no own accounts, so "Markera alla" is disabled instead of
|
||||
// being a live button that does nothing.
|
||||
const allSelected = ownAccounts.every((a) => selected.has(a.uid))
|
||||
const noneSelected = selected.size === 0
|
||||
// Claimed accounts the user deliberately ticked inside the disclosure. They
|
||||
// count in "x av y valda" and are named on the disclosure line even while
|
||||
// it is collapsed, so the counter never exceeds what the user can see.
|
||||
const selectedClaimedCount = claimedElsewhere.filter((a) => selected.has(a.uid)).length
|
||||
const selectableCount = ownAccounts.length + selectedClaimedCount
|
||||
|
||||
const sortedAccounts = useMemo(
|
||||
() => [...accounts].sort((a, b) => (a.name || a.iban || '').localeCompare(b.name || b.iban || '')),
|
||||
[accounts]
|
||||
)
|
||||
const byDisplayName = (a: StoredAccount, b: StoredAccount) =>
|
||||
(a.name || a.iban || '').localeCompare(b.name || b.iban || '')
|
||||
const sortedAccounts = useMemo(() => [...ownAccounts].sort(byDisplayName), [ownAccounts])
|
||||
const sortedClaimed = useMemo(() => [...claimedElsewhere].sort(byDisplayName), [claimedElsewhere])
|
||||
|
||||
// Detect cases where the user routed two enabled accounts with different
|
||||
// currencies to the same BAS account, usually a mistake, but allowed.
|
||||
@@ -334,7 +355,12 @@ export function AccountPickerDialog({
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
setSelected(new Set(accounts.map(a => a.uid)))
|
||||
// Own accounts only; a claimed account already ticked stays ticked.
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev)
|
||||
for (const a of ownAccounts) next.add(a.uid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectNone() {
|
||||
@@ -539,6 +565,102 @@ export function AccountPickerDialog({
|
||||
: lookback.days > daysBetween(gapFill.latestImportedDate)),
|
||||
)
|
||||
|
||||
// One account row; shared by the main list and the claimed-elsewhere
|
||||
// disclosure so the two can never drift apart.
|
||||
function renderAccountRow(account: StoredAccount) {
|
||||
const isChecked = selected.has(account.uid)
|
||||
const ledger = ledgerByUid[account.uid] || ''
|
||||
const ledgerExistsInChart = chartAccounts.some(c => c.account_number === ledger)
|
||||
return (
|
||||
<div
|
||||
key={account.uid}
|
||||
className="flex items-center gap-3 p-3 hover:bg-muted/50"
|
||||
>
|
||||
{/* Toggle area: label + Checkbox (a Radix Checkbox renders as
|
||||
its own <button role="checkbox">, so wrapping it in another
|
||||
<button> would be nested interactive elements: invalid HTML
|
||||
that browsers silently flatten and breaks event routing). */}
|
||||
<label className="flex flex-1 min-w-0 cursor-pointer items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggle(account.uid)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{account.currency}
|
||||
</span>
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</p>
|
||||
)}
|
||||
{/* The callback found this IBAN already booked by another
|
||||
of the user's companies (one consent can cover several
|
||||
companies' accounts at e.g. SEB). Unchecked by default;
|
||||
naming the claimant is what stops a reflexive
|
||||
select-all from booking it here too. */}
|
||||
{account.claimed_by_company_id && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Synkas redan i{' '}
|
||||
<span data-ph-mask="">
|
||||
{account.claimed_by_company_name || 'ett annat bolag'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{/* Carried deselection: the user said "Synkas ej" to this
|
||||
IBAN on another connection. An unexplained unchecked
|
||||
box reads as a glitch; a silent one hides a sync gap. */}
|
||||
{!account.claimed_by_company_id && account.deselected_elsewhere && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tidigare bortvald: markera för att synka i detta bolag
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.balance !== undefined && (
|
||||
<p className="text-sm font-medium tabular-nums shrink-0">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
{/* Ledger picker is a sibling of the label, not inside it:
|
||||
otherwise clicking the Select would also toggle the checkbox. */}
|
||||
<div className="w-44 shrink-0">
|
||||
{isChecked && (
|
||||
<Select
|
||||
value={ledger}
|
||||
onValueChange={(v) => setLedgerByUid(prev => ({ ...prev, [account.uid]: v }))}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Välj konto…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Surface a non-existent default so the user can see/correct it. */}
|
||||
{ledger && !ledgerExistsInChart && (
|
||||
<SelectItem value={ledger} disabled>
|
||||
{ledger}: finns ej i kontoplan
|
||||
</SelectItem>
|
||||
)}
|
||||
{chartAccounts.map(acc => (
|
||||
<SelectItem key={acc.account_number} value={acc.account_number}>
|
||||
<span className="tabular-nums">{acc.account_number}</span> {acc.account_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BankSyncProgressDialog
|
||||
@@ -821,7 +943,7 @@ export function AccountPickerDialog({
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{selected.size} av {accounts.length} valda
|
||||
{selected.size} av {selectableCount} valda
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -868,101 +990,50 @@ export function AccountPickerDialog({
|
||||
)}
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border divide-y divide-border">
|
||||
{sortedAccounts.map(account => {
|
||||
const isChecked = selected.has(account.uid)
|
||||
const ledger = ledgerByUid[account.uid] || ''
|
||||
const ledgerExistsInChart = chartAccounts.some(c => c.account_number === ledger)
|
||||
return (
|
||||
<div
|
||||
key={account.uid}
|
||||
className="flex items-center gap-3 p-3 hover:bg-muted/50"
|
||||
>
|
||||
{/* Toggle area: label + Checkbox (a Radix Checkbox renders as
|
||||
its own <button role="checkbox">, so wrapping it in another
|
||||
<button> would be nested interactive elements: invalid HTML
|
||||
that browsers silently flatten and breaks event routing). */}
|
||||
<label className="flex flex-1 min-w-0 cursor-pointer items-center gap-3">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggle(account.uid)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{account.name || account.iban || 'Okänt konto'}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{account.currency}
|
||||
</span>
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
|
||||
</p>
|
||||
)}
|
||||
{/* The callback found this IBAN already booked by another
|
||||
of the user's companies (one consent can cover several
|
||||
companies' accounts at e.g. SEB). Unchecked by default;
|
||||
naming the claimant is what stops a reflexive
|
||||
select-all from booking it here too. */}
|
||||
{account.claimed_by_company_id && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Synkas redan i{' '}
|
||||
<span data-ph-mask="">
|
||||
{account.claimed_by_company_name || 'ett annat bolag'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{/* Carried deselection: the user said "Synkas ej" to this
|
||||
IBAN on another connection. An unexplained unchecked
|
||||
box reads as a glitch; a silent one hides a sync gap. */}
|
||||
{!account.claimed_by_company_id && account.deselected_elsewhere && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tidigare bortvald: markera för att synka i detta bolag
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{account.balance !== undefined && (
|
||||
<p className="text-sm font-medium tabular-nums shrink-0">
|
||||
{new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: account.currency,
|
||||
}).format(account.balance)}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
{/* Ledger picker is a sibling of the label, not inside it:
|
||||
otherwise clicking the Select would also toggle the checkbox. */}
|
||||
<div className="w-44 shrink-0">
|
||||
{isChecked && (
|
||||
<Select
|
||||
value={ledger}
|
||||
onValueChange={(v) => setLedgerByUid(prev => ({ ...prev, [account.uid]: v }))}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Välj konto…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* Surface a non-existent default so the user can see/correct it. */}
|
||||
{ledger && !ledgerExistsInChart && (
|
||||
<SelectItem value={ledger} disabled>
|
||||
{ledger}: finns ej i kontoplan
|
||||
</SelectItem>
|
||||
)}
|
||||
{chartAccounts.map(acc => (
|
||||
<SelectItem key={acc.account_number} value={acc.account_number}>
|
||||
<span className="tabular-nums">{acc.account_number}</span> {acc.account_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{sortedAccounts.map(renderAccountRow)}
|
||||
{/* Two distinct empty states: every account in the consent belongs
|
||||
to another company (the text below says so and points at the
|
||||
disclosure), or the consent simply carries no accounts (a failed
|
||||
connect, or nothing ticked at the bank), where a claim would be
|
||||
a false statement. */}
|
||||
{sortedAccounts.length === 0 && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{claimedElsewhere.length > 0
|
||||
? 'Inga konton att välja: alla konton i den här bankkopplingen synkas redan i andra bolag.'
|
||||
: 'Bankkopplingen innehåller inga konton. Förnya anslutningen och välj konton hos banken.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accounts another of the user's companies already books (one SEB
|
||||
consent covers every company the signer represents). Collapsed by
|
||||
default so this company's picker shows this company's accounts;
|
||||
expandable because a claim is a strong hint, not proof, and an
|
||||
account that belongs here must stay reachable. */}
|
||||
{claimedElsewhere.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setClaimedOpen((v) => !v)}
|
||||
aria-expanded={claimedOpen}
|
||||
className="flex min-h-9 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', claimedOpen && 'rotate-90')}
|
||||
/>
|
||||
<span className="tabular-nums" data-ph-mask="">
|
||||
{describeClaimedElsewhere(claimedElsewhere)}
|
||||
{selectedClaimedCount > 0 && ` (${selectedClaimedCount} valt här)`}
|
||||
</span>
|
||||
</button>
|
||||
{claimedOpen && (
|
||||
<div className="max-h-[30vh] overflow-y-auto rounded-lg border border-border divide-y divide-border">
|
||||
{sortedClaimed.map(renderAccountRow)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { ChevronRight, Loader2, MoreHorizontal } from 'lucide-react'
|
||||
import { getConnectionUiState } from '../lib/connection-state'
|
||||
import { describeClaimedElsewhere, partitionByClaim } from '../lib/claimed-accounts'
|
||||
import type { BankConnection } from '@/types'
|
||||
|
||||
interface BankConnectionStatusProps {
|
||||
@@ -56,8 +57,14 @@ export function BankConnectionStatus({
|
||||
balance?: number
|
||||
balance_updated_at?: string
|
||||
enabled?: boolean
|
||||
claimed_by_company_id?: string
|
||||
claimed_by_company_name?: string
|
||||
}>) || []
|
||||
const enabledCount = accounts.filter((a) => a.enabled !== false).length
|
||||
// Accounts another of the user's companies books (one SEB consent covers
|
||||
// every company the signer represents) are not this company's accounts:
|
||||
// they are left out of the list and the count, and summarised in one line.
|
||||
const { own: ownAccounts, claimedElsewhere } = partitionByClaim(accounts)
|
||||
const enabledCount = ownAccounts.filter((a) => a.enabled !== false).length
|
||||
|
||||
function formatBalanceAge(updatedAt: string): string {
|
||||
const hoursAgo = Math.floor((now - new Date(updatedAt).getTime()) / (1000 * 60 * 60))
|
||||
@@ -182,7 +189,11 @@ export function BankConnectionStatus({
|
||||
)}
|
||||
{uiState === 'pending_selection' ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{accounts.length} konton tillgängliga: inga transaktioner synkas ännu
|
||||
{ownAccounts.length > 0
|
||||
? `${ownAccounts.length} konton tillgängliga: inga transaktioner synkas ännu`
|
||||
: claimedElsewhere.length > 0
|
||||
? 'Alla konton i kopplingen synkas redan i andra bolag'
|
||||
: 'Inga konton i kopplingen: inga transaktioner synkas ännu'}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
@@ -290,7 +301,7 @@ export function BankConnectionStatus({
|
||||
className={cn('h-3.5 w-3.5 transition-transform duration-150', detailsOpen && 'rotate-90')}
|
||||
/>
|
||||
<span className="tabular-nums">
|
||||
{enabledCount} av {accounts.length} konton synkas
|
||||
{enabledCount} av {ownAccounts.length} konton synkas
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -331,7 +342,7 @@ export function BankConnectionStatus({
|
||||
)
|
||||
})()}
|
||||
|
||||
{accounts.map((account) => {
|
||||
{ownAccounts.map((account) => {
|
||||
const isDisabled = account.enabled === false
|
||||
return (
|
||||
<div
|
||||
@@ -372,6 +383,15 @@ export function BankConnectionStatus({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Sibling companies' accounts carried by this consent: one
|
||||
muted line, never rows. Moving one here is done in the
|
||||
account picker ("Hantera konton"), where it is a deliberate
|
||||
tick inside a disclosure. */}
|
||||
{claimedElsewhere.length > 0 && (
|
||||
<div className="py-2 text-xs text-muted-foreground tabular-nums" data-ph-mask="">
|
||||
{describeClaimedElsewhere(claimedElsewhere)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { describeClaimedElsewhere, partitionByClaim } from '../claimed-accounts'
|
||||
import type { StoredAccount } from '../../types'
|
||||
|
||||
function account(over: Partial<StoredAccount> & { uid: string }): StoredAccount {
|
||||
return { currency: 'SEK', ...over }
|
||||
}
|
||||
|
||||
describe('partitionByClaim', () => {
|
||||
it('keeps unclaimed accounts in the main list and moves claimed ones aside', () => {
|
||||
const own1 = account({ uid: 'a', iban: 'SE1' })
|
||||
const own2 = account({ uid: 'b', iban: 'SE2', enabled: false })
|
||||
const foreign = account({
|
||||
uid: 'c',
|
||||
iban: 'SE3',
|
||||
enabled: false,
|
||||
claimed_by_company_id: 'company-b',
|
||||
claimed_by_company_name: 'Testbrand Holding AB',
|
||||
})
|
||||
|
||||
const { own, claimedElsewhere } = partitionByClaim([own1, foreign, own2])
|
||||
|
||||
expect(own).toEqual([own1, own2])
|
||||
expect(claimedElsewhere).toEqual([foreign])
|
||||
})
|
||||
|
||||
it('treats a legacy double-claim (no flag, enabled here) as own', () => {
|
||||
// The callback lets the active company's standing state outrank a sibling
|
||||
// claim, so such an account carries no flag: it must stay visible.
|
||||
const legacy = account({ uid: 'a', iban: 'SE1', enabled: true })
|
||||
expect(partitionByClaim([legacy]).own).toEqual([legacy])
|
||||
expect(partitionByClaim([legacy]).claimedElsewhere).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a flagged account visible when it is enabled here (invariant breach must not hide a syncing feed)', () => {
|
||||
const flaggedButEnabled = account({ uid: 'a', iban: 'SE1', enabled: true, claimed_by_company_id: 'x' })
|
||||
const flaggedNoEnabledField = account({ uid: 'b', iban: 'SE2', claimed_by_company_id: 'x' })
|
||||
const { own, claimedElsewhere } = partitionByClaim([flaggedButEnabled, flaggedNoEnabledField])
|
||||
// enabled missing means enabled (back-compat default), so both stay own.
|
||||
expect(own).toEqual([flaggedButEnabled, flaggedNoEnabledField])
|
||||
expect(claimedElsewhere).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a carried deselection without a claim as own', () => {
|
||||
const deselected = account({ uid: 'a', iban: 'SE1', enabled: false, deselected_elsewhere: true })
|
||||
expect(partitionByClaim([deselected]).own).toEqual([deselected])
|
||||
})
|
||||
|
||||
it('preserves order within each partition and handles an empty list', () => {
|
||||
expect(partitionByClaim([])).toEqual({ own: [], claimedElsewhere: [] })
|
||||
const rows = ['1', '2', '3', '4'].map((n, i) =>
|
||||
account({ uid: n, enabled: false, claimed_by_company_id: i % 2 ? 'x' : undefined }),
|
||||
)
|
||||
const { own, claimedElsewhere } = partitionByClaim(rows)
|
||||
expect(own.map((a) => a.uid)).toEqual(['1', '3'])
|
||||
expect(claimedElsewhere.map((a) => a.uid)).toEqual(['2', '4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeClaimedElsewhere', () => {
|
||||
it('names the claimant when every account belongs to the same company', () => {
|
||||
const rows = [
|
||||
account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
||||
account({ uid: 'b', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
||||
]
|
||||
expect(describeClaimedElsewhere(rows)).toBe('2 konton synkas i Testbrand AB')
|
||||
})
|
||||
|
||||
it('uses the singular noun for one account', () => {
|
||||
const rows = [account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' })]
|
||||
expect(describeClaimedElsewhere(rows)).toBe('1 konto synkas i Testbrand AB')
|
||||
})
|
||||
|
||||
it('falls back to a generic phrase when claimants differ or a name is missing', () => {
|
||||
const mixed = [
|
||||
account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
||||
account({ uid: 'b', claimed_by_company_id: 'y', claimed_by_company_name: 'Testbrand Holding AB' }),
|
||||
]
|
||||
expect(describeClaimedElsewhere(mixed)).toBe('2 konton synkas i andra bolag')
|
||||
|
||||
const unnamed = [account({ uid: 'a', claimed_by_company_id: 'x' })]
|
||||
expect(describeClaimedElsewhere(unnamed)).toBe('1 konto synkas i ett annat bolag')
|
||||
})
|
||||
|
||||
it('does not merge two different companies that share a display name', () => {
|
||||
const sameNameDifferentIds = [
|
||||
account({ uid: 'a', claimed_by_company_id: 'x', claimed_by_company_name: 'Testbrand AB' }),
|
||||
account({ uid: 'b', claimed_by_company_id: 'y', claimed_by_company_name: 'Testbrand AB' }),
|
||||
]
|
||||
expect(describeClaimedElsewhere(sameNameDifferentIds)).toBe('2 konton synkas i andra bolag')
|
||||
})
|
||||
|
||||
it('returns an empty string for no accounts', () => {
|
||||
expect(describeClaimedElsewhere([])).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { StoredAccount } from '../types'
|
||||
|
||||
/**
|
||||
* Split a connection's accounts into the ones that belong in THIS company's
|
||||
* books and the ones the OAuth callback marked as already booked by another
|
||||
* of the user's companies (`claimed_by_company_id`, see lib/session-sharing).
|
||||
*
|
||||
* At one-session banks (SEB) a single BankID consent returns every account the
|
||||
* signer can see across all their companies, so a reconnect from company A
|
||||
* carries company B's accounts too. Since PR #2116 those arrive unchecked and
|
||||
* unmirrored; this helper is what keeps them out of the main list altogether,
|
||||
* so a user working in company A sees company A's accounts. The claimed set is
|
||||
* still returned (never dropped): the picker renders it behind a collapsed
|
||||
* disclosure, because an account that genuinely belongs here must stay
|
||||
* reachable (a claim is a strong hint, not proof of ownership).
|
||||
*
|
||||
* An account counts as claimed elsewhere only while it is BOTH flagged and
|
||||
* disabled here. Every writer keeps those two in step (the callback sets them
|
||||
* together, the selection save clears the flag on enable), but the invariant
|
||||
* is load-bearing: an account that syncs in this company must never be
|
||||
* tucked out of sight, so a flagged-but-enabled row (a future writer, a
|
||||
* support SQL fix) stays in the main list. The callback already lets the
|
||||
* active company's own standing state outrank a sibling claim, so a legacy
|
||||
* account enabled in both companies carries no flag and stays visible too.
|
||||
*/
|
||||
export function partitionByClaim<T extends Pick<StoredAccount, 'claimed_by_company_id' | 'enabled'>>(
|
||||
accounts: readonly T[],
|
||||
): { own: T[]; claimedElsewhere: T[] } {
|
||||
const own: T[] = []
|
||||
const claimedElsewhere: T[] = []
|
||||
for (const account of accounts) {
|
||||
if (account.claimed_by_company_id && account.enabled === false) claimedElsewhere.push(account)
|
||||
else own.push(account)
|
||||
}
|
||||
return { own, claimedElsewhere }
|
||||
}
|
||||
|
||||
/**
|
||||
* One Swedish line summarising the hidden accounts, e.g.
|
||||
* "2 konton synkas i Testbrand AB" or "3 konton synkas i andra bolag" when the
|
||||
* claimants differ (or a name is missing). "Same claimant" is decided on the
|
||||
* company id, not the display name: two companies can share a name. Empty
|
||||
* string for an empty list so callers can render conditionally on the text.
|
||||
*/
|
||||
export function describeClaimedElsewhere(
|
||||
accounts: readonly Pick<StoredAccount, 'claimed_by_company_id' | 'claimed_by_company_name'>[],
|
||||
): string {
|
||||
if (accounts.length === 0) return ''
|
||||
const noun = accounts.length === 1 ? 'konto' : 'konton'
|
||||
const ids = new Set<string>()
|
||||
const names = new Set<string>()
|
||||
let anonymous = false
|
||||
for (const account of accounts) {
|
||||
if (account.claimed_by_company_id) ids.add(account.claimed_by_company_id)
|
||||
if (account.claimed_by_company_name) names.add(account.claimed_by_company_name)
|
||||
else anonymous = true
|
||||
}
|
||||
if (ids.size === 1 && names.size === 1 && !anonymous) {
|
||||
return `${accounts.length} ${noun} synkas i ${[...names][0]}`
|
||||
}
|
||||
return `${accounts.length} ${noun} synkas i ${accounts.length === 1 ? 'ett annat bolag' : 'andra bolag'}`
|
||||
}
|
||||
Reference in New Issue
Block a user