fix(bank): unchecked accounts yield their bokföringskonto to a checked one in the picker (#2449)
Unchecking the wrong bank account and putting the right one on 1930 answered 400 "Flera bankkonton kan inte bokföras på samma konto": the collision pass counted every stored account as a claim, the picker hides the ledger dropdown for unchecked rows, and disconnect plus reconnect re-claims the same cash_accounts rows by IBAN. No route out (support case 2026-09-09, two 400s on the route in the Vercel logs). Checked accounts stay hard claims (duplicate and foreign-live = 400). Unchecked accounts hold their ledger as a soft claim: kept and mirrored with enabled=false unless a checked account wants it, then they yield and lose the prefill. Contested rows (this connection's row for a yielded or moved account, another connection's row for an account unchecked there) are demoted to manual in one update before the mirror, so upsertFromPsd2 promotes the holder in place and row ids, transaction links and the is_primary flag on the 1930 row survive. The same pass makes two checked accounts swapping ledgers work, which previously tripped the unique constraint in both upserts and was swallowed. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -1684,6 +1684,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version.
|
||||
[2026-09-08] Draft invoice PDF marks a draft with one diagonal, faint word (UTKAST / DRAFT) across every page instead of a banner in the top margin (#2437): a banner reads as UI chrome on a document, a watermark reads as a stamp and leaves the preview pixel-identical to the final print. The long legal sentence (saknar löpnummer, ML 17 kap 24 §) is dropped on purpose: the word alone says the document is not a valid invoice, and the download dialog (#2399) already explains why before the file exists. Rotation and opacity sit on a padded wrapper View so the word turns about its own centre. Skeptic refutation accepted: the first cut (#6b7280 at 0.14, about 92% brightness) would drop out of a monochrome print or greyscale scan, and a numbered draft otherwise prints title, number and OCR like a real faktura; now #4b5563 at 0.3 (about 79% brightness), with a test pinning the composited grey between 70% and 85%. A 1-bit scan can still threshold the word away; a second explicit line on numbered drafts was left out because the request was the word alone, and that residual is Emil's call. Second refutation accepted: the overlay is emitted as the LAST child of the Page, because react-pdf paints in document order and `fixed` does not hoist, so an overlay emitted first was painted under the opaque payment and customer boxes and the word vanished on the page that carries totals and OCR; a test now inflates the PDF content streams and asserts the glyph run comes after the last rectangle fill on every page. BETALD and MAKULERAD banners are left as they are.
|
||||
[2026-09-08] Negative journal-line amounts: fixed the sign at three levels (producers flip the SIDE via lib/bookkeeping/line-side.ts, the engine refuses negative amounts before any write, and a NOT VALID CHECK on journal_entry_lines) instead of only patching the supplier-invoice generator or hiding negative items in the form. Why: the invariant lived nowhere (no Zod rule, no engine check, no constraint), so MCP, templates and any future producer could repeat it; negative items themselves are valid input (rabatt, öresavrundning), so rejecting them at input would break real invoices. reverseEntry now swaps on the net so legacy negative lines storno cleanly before the data repair runs.
|
||||
[2026-09-09] Bank picker: an UNCHECKED account holds its bokföringskonto only as a soft claim (yields to a checked account that wants it), instead of either a hard claim (status quo: 400 "Flera bankkonton kan inte bokföras på samma konto", the support dead end where 1930 could never move from the wrong bank account to the right one because the picker hides the ledger dropdown for unchecked rows and disconnect + reconnect re-claims the same rows by IBAN) or a full release on every save (fewer states, but it demotes every unchecked row to a manual ghost holding its ledger and loses the "re-check lands back on the same account" prefill). Contested rows are demoted to manual in ONE update before the mirror, and upsertFromPsd2 then promotes the manual holder in place, which keeps row ids, transactions.cash_account_id links and the is_primary flag on the 1930 row; the same release pass also makes two checked accounts swapping ledgers work (previously both upserts tripped the unique constraint and were swallowed per-account). Another connection's UNCHECKED row yields the same way (cash_accounts.enabled = false), matching how session sharing already counts only enabled rows as claims; a synced-elsewhere row stays a 400. No new client/UI: the picker needed no change once the server stopped counting unchecked rows.
|
||||
|
||||
[2026-09-08] Zettle v1 = paid purchases→webshop_orders via Purchase API; Finance payouts out of scope; OAuth refresh-token store; unpaid IZETTLE_INVOICE-only skipped.
|
||||
[2026-09-08] Zettle v1 is paid purchases (and refunds) into webshop_orders via the Purchase API, Shopify-shaped feed-only: partner-hosted OAuth with rotating refresh token (encrypted), unpaid IZETTLE_INVOICE-only purchases skipped, line items + groupedVatAmounts as booking underlag. Finance API payouts/fees deliberately out of scope (phase 2, Stripe settlement style).
|
||||
|
||||
@@ -78,10 +78,18 @@ interface SupabaseStub {
|
||||
chartAccountNumbers?: string[]
|
||||
/** Existing cash_accounts rows for the company (ledger collision validation). */
|
||||
cashAccountRows?: Array<{
|
||||
id?: string
|
||||
external_uid: string | null
|
||||
bank_connection_id: string | null
|
||||
ledger_account: string
|
||||
iban?: string | null
|
||||
/** Mirrors the picker's checkbox in that row's connection; absent = live claim. */
|
||||
enabled?: boolean
|
||||
}>
|
||||
/** Release-pass updates (rows demoted to manual before the mirror), in order. */
|
||||
cashReleases?: Array<{ payload: Record<string, unknown>; ids: string[] }>
|
||||
/** Error returned by the release-pass update. */
|
||||
cashReleaseError?: { message: string } | null
|
||||
/** Completed SIE import overlapping the backfill window (renewal-flood guard). */
|
||||
sieImportRow?: { id: string } | null
|
||||
/** company_members row for the caller; role 'viewer' disables the sweep. */
|
||||
@@ -120,6 +128,13 @@ function buildSupabase(stub: SupabaseStub) {
|
||||
return {
|
||||
select: vi.fn().mockReturnThis(),
|
||||
eq: vi.fn(() => Promise.resolve({ data: stub.cashAccountRows ?? [], error: null })),
|
||||
// Release pass: update({...}).in('id', ids) awaited as a thenable.
|
||||
update: vi.fn((payload: Record<string, unknown>) => ({
|
||||
in: vi.fn((_col: string, ids: string[]) => {
|
||||
;(stub.cashReleases ??= []).push({ payload, ids })
|
||||
return Promise.resolve({ error: stub.cashReleaseError ?? null })
|
||||
}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
// Renewal-flood guard: SIE-overlap probe before the inline backfill.
|
||||
@@ -1360,6 +1375,229 @@ describe('PATCH /accounts (enable-banking)', () => {
|
||||
expect(written.find(a => a.uid === 'acc-1')?.ledger_account).toBe('1940')
|
||||
})
|
||||
|
||||
describe('unchecked accounts hold their ledger only as a soft claim', () => {
|
||||
// The reported dead end (support case 2026-09-09): the wrong bank
|
||||
// account had been synced onto 1930, the user unchecked it and put the
|
||||
// right one on 1930, and the save answered 400 because the unchecked
|
||||
// account still counted as a claim. The picker hides the ledger
|
||||
// dropdown for unchecked rows and disconnect + reconnect re-claims the
|
||||
// same rows by IBAN, so no route led out of it.
|
||||
const RELEASE = { bank_connection_id: null, external_uid: null }
|
||||
|
||||
it('lets a checked account take 1930 from an unchecked one on the same connection', async () => {
|
||||
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
|
||||
const stub: SupabaseStub = {
|
||||
authUser: { id: 'user-1' },
|
||||
chartAccountNumbers: ['1930'],
|
||||
cashAccountRows: [
|
||||
{ id: 'row-1930', external_uid: 'acc-wrong', bank_connection_id: 'conn-1', ledger_account: '1930' },
|
||||
{ id: 'row-1935', external_uid: 'acc-right', bank_connection_id: 'conn-1', ledger_account: '1935' },
|
||||
],
|
||||
connectionRow: {
|
||||
id: 'conn-1',
|
||||
status: 'pending_selection',
|
||||
accounts_data: [
|
||||
{ uid: 'acc-wrong', currency: 'SEK', enabled: true, ledger_account: '1930' },
|
||||
{ uid: 'acc-right', currency: 'SEK', enabled: true, ledger_account: '1935' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const supabase = buildSupabase(stub)
|
||||
const ctx = makeContext(supabase)
|
||||
|
||||
const res = await accountsRoute.handler(
|
||||
makeRequest({
|
||||
connection_id: 'conn-1',
|
||||
enabled_uids: ['acc-right'],
|
||||
account_mappings: [{ uid: 'acc-right', ledger_account: '1930' }],
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// The unchecked account's row handed over its slot before the mirror ran.
|
||||
expect(stub.cashReleases).toEqual([{ payload: RELEASE, ids: ['row-1930'] }])
|
||||
// accounts_data: the unchecked account no longer pre-fills 1930, the checked one holds it.
|
||||
const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[]
|
||||
const wrong = written.find(a => a.uid === 'acc-wrong')
|
||||
expect(wrong?.enabled).toBe(false)
|
||||
expect(wrong).not.toHaveProperty('ledger_account')
|
||||
expect(written.find(a => a.uid === 'acc-right')?.ledger_account).toBe('1930')
|
||||
// The mirror only touches the checked account; the yielded one has no row to write.
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
expect.objectContaining({ external_uid: 'acc-right', ledger_account: '1930', enabled: true })
|
||||
)
|
||||
expect(mockAllocate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps an unchecked account on its ledger when nobody claims it, mirrored as disabled', async () => {
|
||||
const stub: SupabaseStub = {
|
||||
authUser: { id: 'user-1' },
|
||||
cashAccountRows: [
|
||||
{ id: 'row-1930', external_uid: 'acc-1', bank_connection_id: 'conn-1', ledger_account: '1930' },
|
||||
{ id: 'row-1935', external_uid: 'acc-2', bank_connection_id: 'conn-1', ledger_account: '1935' },
|
||||
],
|
||||
connectionRow: {
|
||||
id: 'conn-1',
|
||||
status: 'active',
|
||||
accounts_data: [
|
||||
{ uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' },
|
||||
{ uid: 'acc-2', currency: 'SEK', enabled: true, ledger_account: '1935' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const supabase = buildSupabase(stub)
|
||||
const ctx = makeContext(supabase)
|
||||
|
||||
const res = await accountsRoute.handler(
|
||||
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(stub.cashReleases).toBeUndefined()
|
||||
const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[]
|
||||
expect(written.find(a => a.uid === 'acc-2')).toMatchObject({ enabled: false, ledger_account: '1935' })
|
||||
// Re-checking later lands back on 1935: the row stays, its enabled flag flips off.
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(2)
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
expect.objectContaining({ external_uid: 'acc-2', ledger_account: '1935', enabled: false })
|
||||
)
|
||||
})
|
||||
|
||||
it('lets two checked accounts swap ledgers in one save', async () => {
|
||||
const stub: SupabaseStub = {
|
||||
authUser: { id: 'user-1' },
|
||||
chartAccountNumbers: ['1930', '1935'],
|
||||
cashAccountRows: [
|
||||
{ id: 'row-1930', external_uid: 'acc-1', bank_connection_id: 'conn-1', ledger_account: '1930' },
|
||||
{ id: 'row-1935', external_uid: 'acc-2', bank_connection_id: 'conn-1', ledger_account: '1935' },
|
||||
],
|
||||
connectionRow: {
|
||||
id: 'conn-1',
|
||||
status: 'active',
|
||||
accounts_data: [
|
||||
{ uid: 'acc-1', currency: 'SEK', enabled: true, ledger_account: '1930' },
|
||||
{ uid: 'acc-2', currency: 'SEK', enabled: true, ledger_account: '1935' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const supabase = buildSupabase(stub)
|
||||
const ctx = makeContext(supabase)
|
||||
|
||||
const res = await accountsRoute.handler(
|
||||
makeRequest({
|
||||
connection_id: 'conn-1',
|
||||
enabled_uids: ['acc-1', 'acc-2'],
|
||||
account_mappings: [
|
||||
{ uid: 'acc-1', ledger_account: '1935' },
|
||||
{ uid: 'acc-2', ledger_account: '1930' },
|
||||
],
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
// Both rows are demoted in ONE update, so neither upsert can trip the
|
||||
// (company_id, ledger_account) constraint on the other's old slot.
|
||||
expect(stub.cashReleases).toHaveLength(1)
|
||||
expect([...stub.cashReleases![0].ids].sort()).toEqual(['row-1930', 'row-1935'])
|
||||
const written = stub.capturedUpdates?.[0]?.accounts_data as StoredAccount[]
|
||||
expect(written.find(a => a.uid === 'acc-1')?.ledger_account).toBe('1935')
|
||||
expect(written.find(a => a.uid === 'acc-2')?.ledger_account).toBe('1930')
|
||||
const mirrored = mockUpsertFromPsd2.mock.calls.map(c => {
|
||||
const input = c[2] as { external_uid: string; ledger_account: string }
|
||||
return [input.external_uid, input.ledger_account]
|
||||
})
|
||||
expect(mirrored.sort()).toEqual([
|
||||
['acc-1', '1935'],
|
||||
['acc-2', '1930'],
|
||||
])
|
||||
})
|
||||
|
||||
it('lets a mapping take a ledger held by an account that is unchecked in another connection', async () => {
|
||||
mockedSync.mockResolvedValue({ requestedFromDate: '2026-01-01', historyNarrowed: false, imported: 0, duplicates: 0, errors: 0 })
|
||||
const stub: SupabaseStub = {
|
||||
authUser: { id: 'user-1' },
|
||||
chartAccountNumbers: ['1935'],
|
||||
cashAccountRows: [
|
||||
{
|
||||
id: 'row-other',
|
||||
external_uid: 'other-acc',
|
||||
bank_connection_id: 'conn-OTHER',
|
||||
ledger_account: '1935',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
connectionRow: {
|
||||
id: 'conn-1',
|
||||
status: 'pending_selection',
|
||||
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
|
||||
},
|
||||
}
|
||||
const supabase = buildSupabase(stub)
|
||||
const ctx = makeContext(supabase)
|
||||
|
||||
const res = await accountsRoute.handler(
|
||||
makeRequest({
|
||||
connection_id: 'conn-1',
|
||||
enabled_uids: ['acc-1'],
|
||||
account_mappings: [{ uid: 'acc-1', ledger_account: '1935' }],
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
|
||||
// Contrast with the synced-elsewhere case above, which stays a 400.
|
||||
expect(res.status).toBe(200)
|
||||
expect(stub.cashReleases).toEqual([{ payload: RELEASE, ids: ['row-other'] }])
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
expect.objectContaining({ external_uid: 'acc-1', ledger_account: '1935' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 500 and persists nothing when the release update fails', async () => {
|
||||
const stub: SupabaseStub = {
|
||||
authUser: { id: 'user-1' },
|
||||
chartAccountNumbers: ['1930'],
|
||||
cashReleaseError: { message: 'boom' },
|
||||
cashAccountRows: [
|
||||
{ id: 'row-1930', external_uid: 'acc-wrong', bank_connection_id: 'conn-1', ledger_account: '1930' },
|
||||
{ id: 'row-1935', external_uid: 'acc-right', bank_connection_id: 'conn-1', ledger_account: '1935' },
|
||||
],
|
||||
connectionRow: {
|
||||
id: 'conn-1',
|
||||
status: 'active',
|
||||
accounts_data: [
|
||||
{ uid: 'acc-wrong', currency: 'SEK', enabled: true, ledger_account: '1930' },
|
||||
{ uid: 'acc-right', currency: 'SEK', enabled: true, ledger_account: '1935' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const supabase = buildSupabase(stub)
|
||||
const ctx = makeContext(supabase)
|
||||
|
||||
const res = await accountsRoute.handler(
|
||||
makeRequest({
|
||||
connection_id: 'conn-1',
|
||||
enabled_uids: ['acc-right'],
|
||||
account_mappings: [{ uid: 'acc-right', ledger_account: '1930' }],
|
||||
}),
|
||||
ctx
|
||||
)
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(stub.capturedUpdates).toBeUndefined()
|
||||
expect(mockUpsertFromPsd2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects account_mappings that is not an array', async () => {
|
||||
const stub: SupabaseStub = {
|
||||
authUser: { id: 'user-1' },
|
||||
|
||||
@@ -1288,7 +1288,7 @@ export const enableBankingExtension: Extension = {
|
||||
|
||||
const { data: companyCashRows } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, external_uid, bank_connection_id, ledger_account, iban')
|
||||
.select('id, external_uid, bank_connection_id, ledger_account, iban, enabled')
|
||||
.eq('company_id', companyId)
|
||||
const cashRows = (companyCashRows ?? []) as Array<{
|
||||
id: string
|
||||
@@ -1296,6 +1296,7 @@ export const enableBankingExtension: Extension = {
|
||||
bank_connection_id: string | null
|
||||
ledger_account: string
|
||||
iban: string | null
|
||||
enabled: boolean | null
|
||||
}>
|
||||
|
||||
// Rows that already represent one of THIS connection's accounts, matched
|
||||
@@ -1331,6 +1332,12 @@ export const enableBankingExtension: Extension = {
|
||||
// and upsertFromPsd2 promotes them in place too. Excluding them here
|
||||
// is the self-heal path for companies whose bank was disconnected
|
||||
// before disconnect started releasing ledger claims.
|
||||
//
|
||||
// Only ENABLED foreign rows count as live claims. A row whose account
|
||||
// was unchecked in that connection's picker is not being synced onto
|
||||
// the ledger; it keeps the slot only until someone who is syncing asks
|
||||
// for it (the release pass below hands it over). This mirrors how
|
||||
// session sharing already treats claims (see lib/session-sharing.ts).
|
||||
const foreignConnectionIds = [
|
||||
...new Set(
|
||||
cashRows
|
||||
@@ -1350,6 +1357,7 @@ export const enableBankingExtension: Extension = {
|
||||
r.bank_connection_id !== null &&
|
||||
r.bank_connection_id !== connection.id &&
|
||||
!revokedConnectionIds.has(r.bank_connection_id) &&
|
||||
r.enabled !== false &&
|
||||
// Same IBAN as one of this connection's accounts: the same
|
||||
// physical account under a stale owner, not a foreign claim.
|
||||
!ownIbanRowIds.has(r.id)
|
||||
@@ -1362,8 +1370,12 @@ export const enableBankingExtension: Extension = {
|
||||
const duplicateLedgers = new Set<string>()
|
||||
const conflictingLedgers = new Set<string>()
|
||||
|
||||
// First pass: accounts with an explicit or previously mirrored ledger.
|
||||
// First pass: CHECKED accounts with an explicit or previously mirrored
|
||||
// ledger. These are the hard claims: two of them on one ledger is a
|
||||
// user error (400), and one of them on a ledger another connection is
|
||||
// syncing onto is a conflict (400).
|
||||
for (const a of updatedAccounts) {
|
||||
if (!enabledSet.has(a.uid)) continue
|
||||
const ledger = a.ledger_account ?? existingLedgerByUid.get(a.uid)
|
||||
if (!ledger) continue
|
||||
if (usedLedgers.has(ledger)) duplicateLedgers.add(ledger)
|
||||
@@ -1374,6 +1386,39 @@ export const enableBankingExtension: Extension = {
|
||||
effectiveLedgerByUid.set(a.uid, ledger)
|
||||
}
|
||||
|
||||
// UNCHECKED accounts hold their ledger only as a soft claim: the slot
|
||||
// stays theirs (so re-checking lands back on the same BAS account and
|
||||
// the row's enabled flag flips off in the mirror) unless a checked
|
||||
// account wants it, in which case they yield. Without this, moving
|
||||
// 1930 from the wrong bank account to the right one was impossible:
|
||||
// the picker hides the ledger dropdown for unchecked rows, the
|
||||
// unchecked row still counted as a claim, and disconnect + reconnect
|
||||
// re-claims the same rows by IBAN, so every route ended in a 400.
|
||||
const yieldedUids = new Set<string>()
|
||||
for (const a of updatedAccounts) {
|
||||
if (enabledSet.has(a.uid)) continue
|
||||
const ledger =
|
||||
a.ledger_account ??
|
||||
existingLedgerByUid.get(a.uid) ??
|
||||
reuseRowByUid.get(a.uid)?.ledger_account
|
||||
if (!ledger) {
|
||||
// See neverMirroredDisabledUids above: a disabled account that has
|
||||
// never held a ledger or a mirrored row gets neither allocated nor
|
||||
// mirrored by this save.
|
||||
neverMirroredDisabledUids.add(a.uid)
|
||||
continue
|
||||
}
|
||||
const contested =
|
||||
usedLedgers.has(ledger) ||
|
||||
(foreignConnectedLedgers.has(ledger) && existingLedgerByUid.get(a.uid) !== ledger)
|
||||
if (contested) {
|
||||
yieldedUids.add(a.uid)
|
||||
continue
|
||||
}
|
||||
usedLedgers.add(ledger)
|
||||
effectiveLedgerByUid.set(a.uid, ledger)
|
||||
}
|
||||
|
||||
if (duplicateLedgers.size > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -1393,20 +1438,15 @@ export const enableBankingExtension: Extension = {
|
||||
)
|
||||
}
|
||||
|
||||
// Second pass: allocate a free slot for accounts with no ledger at all
|
||||
// (legacy connections mirrored before allocation existed, or mappings
|
||||
// explicitly cleared). Allocation failure must never block selection
|
||||
// save — fall back to the pre-allocator behavior (1930) and let the
|
||||
// mirror pass surface any collision per-account, as before.
|
||||
// Second pass: allocate a free slot for CHECKED accounts with no ledger
|
||||
// at all (legacy connections mirrored before allocation existed, or
|
||||
// mappings explicitly cleared). Unchecked accounts were all settled
|
||||
// above: kept, yielded, or never mirrored. Allocation failure must
|
||||
// never block selection save — fall back to the pre-allocator behavior
|
||||
// (1930) and let the mirror pass surface any collision per-account, as
|
||||
// before.
|
||||
for (const a of updatedAccounts) {
|
||||
if (effectiveLedgerByUid.has(a.uid)) continue
|
||||
// See neverMirroredDisabledUids above: a disabled account that has
|
||||
// never held a ledger or a mirrored row gets neither allocated nor
|
||||
// mirrored by this save.
|
||||
if (!enabledSet.has(a.uid) && !reuseRowByUid.has(a.uid)) {
|
||||
neverMirroredDisabledUids.add(a.uid)
|
||||
continue
|
||||
}
|
||||
if (!enabledSet.has(a.uid) || effectiveLedgerByUid.has(a.uid)) continue
|
||||
let allocated: string | null = null
|
||||
try {
|
||||
const resolved = await resolvePsd2LedgerAccount(supabase, companyId, user.id, {
|
||||
@@ -1431,11 +1471,70 @@ export const enableBankingExtension: Extension = {
|
||||
// accounts_data mirrors the resolved assignment so the picker
|
||||
// pre-fills reality on the next open. Skipped disabled accounts keep
|
||||
// no assignment: their slot is only claimed if they are ever enabled.
|
||||
// Yielded accounts lose theirs: the picker must not pre-fill a ledger
|
||||
// that now belongs to another account.
|
||||
for (const a of updatedAccounts) {
|
||||
if (neverMirroredDisabledUids.has(a.uid)) continue
|
||||
if (yieldedUids.has(a.uid)) {
|
||||
delete a.ledger_account
|
||||
continue
|
||||
}
|
||||
a.ledger_account = effectiveLedgerByUid.get(a.uid)
|
||||
}
|
||||
|
||||
// Release pass: rows that hold a ledger a checked account is about to
|
||||
// take must stop being PSD2-bound first, or the mirror's upsert trips
|
||||
// UNIQUE (company_id, ledger_account) and the failure is swallowed
|
||||
// per-account (accounts_data ahead of cash_accounts). Demoting them to
|
||||
// manual (bank_connection_id/external_uid NULL) keeps the row id, so
|
||||
// transactions.cash_account_id links and the ledger's history stay
|
||||
// put, and upsertFromPsd2 then promotes the manual holder in place for
|
||||
// the claimant. Three kinds of holder are released: this connection's
|
||||
// own row for an account that yielded (unchecked) or moved (two
|
||||
// checked accounts swapping ledgers), and another connection's row for
|
||||
// an account unchecked there. Manual and revoked holders are already
|
||||
// promotable; a live foreign holder was rejected above.
|
||||
const claimantByLedger = new Map<string, string>()
|
||||
for (const a of updatedAccounts) {
|
||||
if (!enabledSet.has(a.uid)) continue
|
||||
const ledger = effectiveLedgerByUid.get(a.uid)
|
||||
if (ledger) claimantByLedger.set(ledger, a.uid)
|
||||
}
|
||||
const releaseRowIds = cashRows
|
||||
.filter(r => {
|
||||
const claimant = claimantByLedger.get(r.ledger_account)
|
||||
if (!claimant) return false
|
||||
if (r.bank_connection_id === null) return false
|
||||
if (revokedConnectionIds.has(r.bank_connection_id)) return false
|
||||
// The claimant's own row under a stale uid (re-authorization
|
||||
// changed the provider ids): promoted via reuse, not released.
|
||||
if (reuseRowByUid.get(claimant)?.id === r.id) return false
|
||||
if (r.bank_connection_id === connection.id) return r.external_uid !== claimant
|
||||
return r.enabled === false
|
||||
})
|
||||
.map(r => r.id)
|
||||
if (releaseRowIds.length > 0) {
|
||||
const { error: releaseError } = await supabase
|
||||
.from('cash_accounts')
|
||||
.update({ bank_connection_id: null, external_uid: null })
|
||||
.in('id', releaseRowIds)
|
||||
if (releaseError) {
|
||||
// Nothing persisted yet (accounts_data is written below): fail
|
||||
// loudly rather than save a selection the mirror cannot honor.
|
||||
log.error('[enable-banking] Failed to release ledger claims on selection save', {
|
||||
errorMessage: releaseError.message,
|
||||
connectionId: connection.id,
|
||||
releaseRowIds,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte frigöra bokföringskontot från det tidigare bankkontot. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// State machine: only transition pending_selection → active. Once
|
||||
// active, the status field is omitted from the update so the same
|
||||
// endpoint can be reused to change account selection without
|
||||
@@ -1469,7 +1568,9 @@ export const enableBankingExtension: Extension = {
|
||||
for (const a of updatedAccounts) {
|
||||
// Never-mirrored disabled accounts (callback-guard leftovers the
|
||||
// user did not enable) get no cash_accounts row: see above.
|
||||
if (neverMirroredDisabledUids.has(a.uid)) continue
|
||||
// Yielded accounts have no ledger any more; their old row was
|
||||
// released above and is promoted by the claimant's upsert.
|
||||
if (neverMirroredDisabledUids.has(a.uid) || yieldedUids.has(a.uid)) continue
|
||||
const ledgerAccount = a.ledger_account ?? '1930'
|
||||
// Only reuse the IBAN-matched row when it already sits on the
|
||||
// ledger we are about to write. If the user deliberately remapped
|
||||
|
||||
Reference in New Issue
Block a user