Files
accounted/lib/webshop-orders/__tests__/ensure-accounts.test.ts
T
bb5fafe87b fix(orders): book webshop orders against 1686 and stop the missing-account dead end (#1697)
Booking an order from the Orders page could fail outright on a fresh
company. seed_chart_of_accounts() seeds a deliberately small chart:
3001/3002/3003 and 2611/2621/2631 are in it, but 3004, 3740 and the
clearing account are not. All three are reachable from an entirely
ordinary order (a 0%-rate line, an ore residual, or simply no
payment-method mapping yet), and the engine treats a missing or
inactive account as AccountsNotInChartError, so the user's first click
on Bokfor returned an error naming accounts they had no reason to know
about, with no way forward but to hand-add them.

The book route now ensures the closed set of accounts our own prefill
can emit exists before drafting. Deliberately narrow: only accounts in
WEBSHOP_PREFILL_ACCOUNTS are ever created, and only when a submitted
line uses one, so an account the user typed still surfaces as a real
error instead of quietly growing the chart. A deactivated row is
reactivated rather than duplicated, and every failure is swallowed so
the engine's typed error still wins over a chart tidy-up.

The unmapped default also moves from 1680 to 1686. 1680 is the generic
"Andra kortfristiga fordringar" parent; 1686 "Fordringar for kontokort
och kuponger" is what BAS defines for a claim on a payment provider,
which is what money sitting at Klarna or Stripe actually is. The Stripe
extension already settles against 1686, so a store running both
surfaces now shares one clearing account instead of splitting the same
receivable across two.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:51:02 +02:00

178 lines
6.0 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ensureWebshopPrefillAccounts } from '../ensure-accounts'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
/**
* Minimal chart_of_accounts double: records what the helper selected,
* updated and upserted so each assertion can check the exact call shape.
*/
function makeSupabase(options: {
existing?: Array<{ id: string; account_number: string; is_active: boolean }>
selectError?: { message: string }
upsertError?: { message: string }
updateError?: { message: string }
} = {}) {
const calls = {
selectedIn: [] as string[][],
upserted: [] as Array<Record<string, unknown>>,
upsertOptions: [] as unknown[],
reactivatedIds: [] as string[][],
}
const from = vi.fn((table: string) => {
expect(table).toBe('chart_of_accounts')
return {
select: () => ({
eq: () => ({
in: (_col: string, values: string[]) => {
calls.selectedIn.push(values)
return Promise.resolve({
data: options.selectError ? null : (options.existing ?? []),
error: options.selectError ?? null,
})
},
}),
}),
update: (_patch: Record<string, unknown>) => ({
in: (_col: string, ids: string[]) => {
calls.reactivatedIds.push(ids)
return {
eq: () => Promise.resolve({ error: options.updateError ?? null }),
}
},
}),
upsert: (row: Record<string, unknown>, opts: unknown) => {
calls.upserted.push(row)
calls.upsertOptions.push(opts)
return Promise.resolve({ error: options.upsertError ?? null })
},
}
})
return { supabase: { from } as unknown as SupabaseClient, calls, from }
}
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: vi.fn(() => log),
} as unknown as Logger
beforeEach(() => {
vi.clearAllMocks()
})
describe('ensureWebshopPrefillAccounts', () => {
it('creates the prefill accounts that a seeded chart is missing', async () => {
const { supabase, calls } = makeSupabase({ existing: [] })
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686', '3740'], log)
// One literal-payload insert per account, so the phantom-column guard can
// actually verify the columns.
expect(calls.upserted).toHaveLength(2)
expect(calls.upserted.map((r) => r.account_number).sort()).toEqual(['1686', '3740'])
// BAS metadata comes from the reference, never invented.
const clearing = calls.upserted.find((r) => r.account_number === '1686')!
expect(clearing.account_name).toBe('Fordringar för kontokort och kuponger')
expect(clearing.account_class).toBe(1)
expect(clearing.account_type).toBe('asset')
expect(clearing.normal_balance).toBe('debit')
expect(clearing.company_id).toBe('company-1')
expect(clearing.user_id).toBe('user-1')
expect(clearing.is_active).toBe(true)
// Concurrent bookings must not 23505 each other.
expect(calls.upsertOptions[0]).toMatchObject({
onConflict: 'company_id,account_number',
ignoreDuplicates: true,
})
})
it('never creates an account outside the closed prefill set', async () => {
const { supabase, calls } = makeSupabase({ existing: [] })
// 1930 and 4010 are legitimate accounts a user may have picked, and 9999
// is a typo. None of them are ours to create.
await ensureWebshopPrefillAccounts(
supabase,
'company-1',
'user-1',
['1930', '4010', '9999'],
log,
)
expect(calls.selectedIn).toHaveLength(0)
expect(calls.upserted).toHaveLength(0)
})
it('does nothing when every prefill account is already active', async () => {
const { supabase, calls } = makeSupabase({
existing: [
{ id: 'a', account_number: '1686', is_active: true },
{ id: 'b', account_number: '3001', is_active: true },
],
})
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686', '3001'], log)
expect(calls.upserted).toHaveLength(0)
expect(calls.reactivatedIds).toHaveLength(0)
})
it('reactivates a deactivated account instead of inserting a duplicate', async () => {
// The engine treats is_active = false exactly like missing, so a user who
// once hid 3740 would otherwise be stuck with an unbookable order.
const { supabase, calls } = makeSupabase({
existing: [{ id: 'row-3740', account_number: '3740', is_active: false }],
})
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['3740'], log)
expect(calls.reactivatedIds).toEqual([['row-3740']])
expect(calls.upserted).toHaveLength(0)
})
it('deduplicates repeated account numbers from the submitted lines', async () => {
const { supabase, calls } = makeSupabase({ existing: [] })
await ensureWebshopPrefillAccounts(
supabase,
'company-1',
'user-1',
['3001', '3001', '2611'],
log,
)
expect(calls.selectedIn[0].sort()).toEqual(['2611', '3001'])
})
it('is a no-op for an empty line set', async () => {
const { supabase, from } = makeSupabase()
await ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', [], log)
expect(from).not.toHaveBeenCalled()
})
it('swallows a lookup failure so booking still reaches the engine', async () => {
const { supabase, calls } = makeSupabase({ selectError: { message: 'rls denied' } })
await expect(
ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686'], log),
).resolves.toBeUndefined()
expect(calls.upserted).toHaveLength(0)
expect(log.warn).toHaveBeenCalled()
})
it('swallows an insert failure so booking still reaches the engine', async () => {
const { supabase } = makeSupabase({ existing: [], upsertError: { message: 'boom' } })
await expect(
ensureWebshopPrefillAccounts(supabase, 'company-1', 'user-1', ['1686'], log),
).resolves.toBeUndefined()
expect(log.warn).toHaveBeenCalled()
})
})