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>
This commit is contained in:
Jakob Wennberg
2026-08-19 19:51:02 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 3a1b842e4a
commit bb5fafe87b
11 changed files with 431 additions and 11 deletions
+14
View File
@@ -8,6 +8,7 @@ import { BookWebshopOrderSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { ensureWebshopPrefillAccounts } from '@/lib/webshop-orders/ensure-accounts'
import { roundOre } from '@/lib/money'
import type { Currency, WebshopOrder } from '@/types'
@@ -140,6 +141,19 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
// with a conditional update BEFORE anything gets a voucher number: the
// loser's claim matches zero rows and its draft (no voucher yet, so no
// series gap) is cancelled.
// The prefill can legitimately reach 3004, 3740 and the 1686 clearing
// account, none of which seed_chart_of_accounts() seeds. Without this the
// first Bokför on a fresh company died on AccountsNotInChartError for an
// account the user never chose. Only our own closed prefill set is added,
// and failures here are swallowed so the engine's typed error still wins.
await ensureWebshopPrefillAccounts(
supabase,
companyId,
user.id,
lines.map((l) => l.account_number),
log,
)
let draft
try {
draft = await createDraftEntry(supabase, companyId, user.id, {
@@ -43,6 +43,13 @@ vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
}))
// Behaviour lives in lib/webshop-orders/__tests__/ensure-accounts.test.ts; here
// we only assert that the route hands it the accounts it is about to book.
const mockEnsureAccounts = vi.fn().mockResolvedValue(undefined)
vi.mock('@/lib/webshop-orders/ensure-accounts', () => ({
ensureWebshopPrefillAccounts: (...args: unknown[]) => mockEnsureAccounts(...args),
}))
import { POST } from '../[id]/book/route'
const PERIOD_UUID = '550e8400-e29b-41d4-a716-446655440000'
@@ -200,6 +207,26 @@ describe('POST /api/webshop-orders/[id]/book', () => {
expect(mockCommitEntry).toHaveBeenCalled()
})
it('ensures the prefill accounts exist in the chart before drafting', async () => {
// Regression: seed_chart_of_accounts() does not seed 1686/3740/3004, so a
// fresh company used to hit AccountsNotInChartError on its first Bokför.
enqueue({ data: makeOrderRow() })
enqueue({ data: [{ id: 'order-1' }] }) // claim
const { status } = await parseJsonResponse(await postBook())
expect(status).toBe(200)
expect(mockEnsureAccounts).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
validBody.lines.map((l) => l.account_number),
expect.anything(),
)
// Order matters: the chart must be repaired before the engine reads it.
expect(mockEnsureAccounts.mock.invocationCallOrder[0]).toBeLessThan(
mockCreateDraftEntry.mock.invocationCallOrder[0],
)
})
it('returns 422 when a non-SEK order has no rate and the retry fails', async () => {
enqueue({ data: makeOrderRow({ currency: 'EUR', total_sek: null, exchange_rate: null }) })
mockFetchExchangeRate.mockResolvedValue(null)