feat(skattekonto): bulk Bokför + inline single-row booking (fewer clicks) (#1535)

* feat(skattekonto): bulk Bokför and inline single-row booking

Booking a year of skattekonto events took 6 clicks per row (list, Bokför,
review page, Bokför, confirm, navigate back), even for a +1 kr
intäktsränta row.

- GET /skattekonto/transaktioner now attaches a deterministic
  booking_suggestion per unbooked row (one hoisted skattekonto_rules +
  entity_type fetch via the new attachBookingSuggestions), shown as muted
  text on the inbox row ('Bokförs mot 8314 ...').
- New POST /skattekonto/transaktioner/bokfor-batch (Zod, max 200 ids):
  sequential draft+commit per row server-side (no orphan drafts), per-row
  results, never aborts on a row failure. Commit attribution: bulk_accept
  for real batches, user_accept for the one-row inline flow. A failed
  commit keeps the linked draft (degrades to the old review flow).
- Inbox: hover-reveal checkboxes on eligible SKV rows (suggestion
  present, no duplicate hint, unbooked, genomförd), separate skvSelectedIds
  set, bulkbar 'Bokför valda (N)' with ONE summary ConfirmationDialog
  grouped by suggestion with per-group sums, chunked batchProgress, ONE
  aggregate toast, local state patch with exit animation.
- Single-row: new SkattekontoBookDialog (dynamic import) replaces the
  draft-then-window.location detour on both /transactions and /skattekonto;
  'Öppna som utkast' keeps the old draft path via router.push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(skattekonto): batch booking guards for unsettled rows, double-post race and locked periods

- reject status != booked rows in the batch flow with NOT_SETTLED before
  any draft exists (server no longer trusts client eligibility); the
  single-row draft endpoint keeps its behaviour
- make the journal_entry_id backlink a conditional claim (update where
  journal_entry_id is null, select affected rows): zero affected rows maps
  to ALREADY_BOOKED and commitEntry only runs after a won claim, so two
  concurrent submissions can no longer double-post the same row
- detect the period-lock trigger signature in the batch catch and map it
  to PERIOD_LOCKED with Swedish text instead of UNKNOWN with raw DB output
- SkattekontoBookDialog: rows with no matched rule no longer get the
  guaranteed-422 draft CTA; they route to the existing match flow and to
  manual verifikat creation in /bookkeeping
- pass an explicit skv_book_dialog.commit_warning key for the
  direct-commit warning instead of ConfirmationDialog's hardcoded default

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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-12 21:00:57 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 7cf0e34434
commit dea5e31756
13 changed files with 1884 additions and 156 deletions
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// The route delegates to the batch function: stub it so these tests cover
// the route's validation/envelope behaviour only (the batch logic itself is
// covered in skattekonto-booking-batch.test.ts).
vi.mock('../lib/skattekonto-booking', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/skattekonto-booking')>()
return {
...actual,
bokforSkattekontoTransactionsBatch: vi.fn(),
}
})
import { skatteverketExtension } from '../index'
import { bokforSkattekontoTransactionsBatch } from '../lib/skattekonto-booking'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { SkattekontoBatchResult } from '@/types/skatteverket'
const ROUTE_PATH = '/skattekonto/transaktioner/bokfor-batch'
const UUID_A = '11111111-1111-4111-8111-111111111111'
const UUID_B = '22222222-2222-4222-8222-222222222222'
function findRoute() {
const route = skatteverketExtension.apiRoutes?.find(
(r) => r.method === 'POST' && r.path === ROUTE_PATH,
)
if (!route) throw new Error('bokfor-batch route not registered')
return route
}
function makeContext(): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'skatteverket',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: { from: vi.fn() } as any,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
function makeRequest(body: unknown): Request {
return new Request('http://localhost/api/extensions/ext/skatteverket' + ROUTE_PATH, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: typeof body === 'string' ? body : JSON.stringify(body),
})
}
describe('POST /skattekonto/transaktioner/bokfor-batch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 500 without an extension context', async () => {
const res = await findRoute().handler(makeRequest({ ids: [UUID_A] }))
expect(res.status).toBe(500)
})
it('rejects invalid JSON with 400', async () => {
const res = await findRoute().handler(makeRequest('not json{'), makeContext())
expect(res.status).toBe(400)
expect(vi.mocked(bokforSkattekontoTransactionsBatch)).not.toHaveBeenCalled()
})
it('rejects an empty ids list with 400', async () => {
const res = await findRoute().handler(makeRequest({ ids: [] }), makeContext())
expect(res.status).toBe(400)
})
it('rejects non-uuid ids with 400', async () => {
const res = await findRoute().handler(
makeRequest({ ids: ['abc; drop table'] }),
makeContext(),
)
expect(res.status).toBe(400)
})
it('rejects more than 200 ids with 400', async () => {
const ids = Array.from({ length: 201 }, (_, i) =>
`${String(i).padStart(8, '0')}-1111-4111-8111-111111111111`,
)
const res = await findRoute().handler(makeRequest({ ids }), makeContext())
expect(res.status).toBe(400)
expect(vi.mocked(bokforSkattekontoTransactionsBatch)).not.toHaveBeenCalled()
})
it('runs the batch and returns its results in the data envelope', async () => {
const payload: SkattekontoBatchResult = {
results: [
{ id: UUID_A, ok: true, journal_entry_id: 'je-1', voucher_number: 7, voucher_series: 'A' },
{ id: UUID_B, ok: false, error_code: 'PERIOD_LOCKED', error_message: 'Låst period' },
],
summary: { total: 2, succeeded: 1, failed: 1 },
}
vi.mocked(bokforSkattekontoTransactionsBatch).mockResolvedValue(payload)
const ctx = makeContext()
const res = await findRoute().handler(makeRequest({ ids: [UUID_A, UUID_B] }), ctx)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toEqual(payload)
expect(vi.mocked(bokforSkattekontoTransactionsBatch)).toHaveBeenCalledWith(
ctx.supabase,
'company-1',
'user-1',
[UUID_A, UUID_B],
)
})
it('dedupes repeated ids before running the batch', async () => {
vi.mocked(bokforSkattekontoTransactionsBatch).mockResolvedValue({
results: [{ id: UUID_A, ok: true }],
summary: { total: 1, succeeded: 1, failed: 0 },
})
await findRoute().handler(makeRequest({ ids: [UUID_A, UUID_A] }), makeContext())
expect(vi.mocked(bokforSkattekontoTransactionsBatch)).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
[UUID_A],
)
})
})
@@ -0,0 +1,441 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
// The batch loop delegates all journal writes to the engine: stub the three
// engine entry points so the tests exercise the batch/enrichment logic, not
// the engine's own chains (those are covered by the engine's tests).
vi.mock('@/lib/bookkeeping/engine', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/bookkeeping/engine')>()
return {
...actual,
findFiscalPeriod: vi.fn(),
createDraftEntry: vi.fn(),
commitEntry: vi.fn(),
}
})
import { findFiscalPeriod, createDraftEntry, commitEntry } from '@/lib/bookkeeping/engine'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import {
attachBookingSuggestions,
bokforSkattekontoTransactionsBatch,
} from '../lib/skattekonto-booking'
/**
* System seeds mirror supabase/migrations/20260519100000_skattekonto_rules.sql
* (same fixture as skattekonto-booking.test.ts).
*/
const SEED_RULES = [
{
id: 'sys-1', priority: 10, pattern: 'inbetalning bokförd,inbetalning,överföring från bank',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '__PRIMARY_SEK__', counter_account_ef: null,
label: 'Inbetalning till skattekonto', active: true,
},
{
id: 'sys-3', priority: 20, pattern: 'debiterad preliminärskatt,preliminärskatt,f-skatt,fskatt',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '2510', counter_account_ef: '2012',
label: 'Preliminär skatt', active: true,
},
{
id: 'sys-8', priority: 30, pattern: 'kostnadsränta',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '8423', counter_account_ef: null,
label: 'Kostnadsränta skattekonto', active: true,
},
{
id: 'sys-9', priority: 30, pattern: 'intäktsränta',
amount_min: null, amount_max: null, company_type: 'all',
counter_account: '8314', counter_account_ef: null,
label: 'Intäktsränta skattekonto', active: true,
},
]
let rowSeq = 0
function makeSkvRow(overrides: Record<string, unknown> = {}) {
rowSeq += 1
return {
id: `row-${rowSeq}`,
company_id: 'company-1',
transaktionstext: 'Intäktsränta',
transaktionsdatum: '2026-01-15',
belopp_skatteverket: 1,
status: 'booked',
journal_entry_id: null,
...overrides,
}
}
function makeSupabase() {
return createQueuedMockSupabase()
}
/** Number of times a table was targeted by supabase.from(). */
function fromCount(
supabase: ReturnType<typeof makeSupabase>['supabase'],
table: string,
): number {
return supabase.from.mock.calls.filter((c: unknown[]) => c[0] === table).length
}
beforeEach(() => {
vi.clearAllMocks()
rowSeq = 0
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
vi.mocked(createDraftEntry).mockImplementation(async () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ id: 'je-draft-1' }) as any,
)
vi.mocked(commitEntry).mockImplementation(async () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
({ id: 'je-draft-1', voucher_number: 42, voucher_series: 'A' }) as any,
)
})
describe('attachBookingSuggestions', () => {
it('attaches account, BAS name and rule label on a rule match', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES }) // skattekonto_rules
enqueue({ data: { entity_type: 'aktiebolag' } }) // company_settings
const rows = [makeSkvRow({ transaktionstext: 'Intäktsränta' })]
const enriched = await attachBookingSuggestions(
supabase as unknown as SupabaseClient,
'company-1',
rows,
)
expect(enriched[0].booking_suggestion).toEqual({
account: '8314',
account_name: getBASReference('8314')?.account_name ?? null,
label: 'Intäktsränta skattekonto',
})
})
it('resolves EF-specific counter accounts from the hoisted entity_type', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'enskild_firma' } })
const enriched = await attachBookingSuggestions(
supabase as unknown as SupabaseClient,
'company-1',
[makeSkvRow({ transaktionstext: 'Debiterad preliminärskatt' })],
)
expect(enriched[0].booking_suggestion?.account).toBe('2012')
const ab = makeSupabase()
ab.enqueue({ data: SEED_RULES })
ab.enqueue({ data: { entity_type: 'aktiebolag' } })
const enrichedAb = await attachBookingSuggestions(
ab.supabase as unknown as SupabaseClient,
'company-1',
[makeSkvRow({ transaktionstext: 'Debiterad preliminärskatt' })],
)
expect(enrichedAb[0].booking_suggestion?.account).toBe('2510')
})
it('returns null when no rule matches', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
const enriched = await attachBookingSuggestions(
supabase as unknown as SupabaseClient,
'company-1',
[makeSkvRow({ transaktionstext: 'Något helt okänt' })],
)
expect(enriched[0].booking_suggestion).toBeNull()
})
it('hoists the rules fetch: one skattekonto_rules query for many rows', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
const rows = [
makeSkvRow({ transaktionstext: 'Intäktsränta' }),
makeSkvRow({ transaktionstext: 'Kostnadsränta' }),
makeSkvRow({ transaktionstext: 'Debiterad preliminärskatt' }),
]
const enriched = await attachBookingSuggestions(
supabase as unknown as SupabaseClient,
'company-1',
rows,
)
expect(enriched.map((r) => r.booking_suggestion?.account)).toEqual([
'8314',
'8423',
'2510',
])
expect(fromCount(supabase, 'skattekonto_rules')).toBe(1)
expect(fromCount(supabase, 'company_settings')).toBe(1)
})
it('resolves the __PRIMARY_SEK__ sentinel once via cash_accounts', async () => {
const { supabase, enqueue } = makeSupabase()
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: { ledger_account: '1932' } }) // cash_accounts primary
const rows = [
makeSkvRow({ transaktionstext: 'Inbetalning bokförd 240412' }),
makeSkvRow({ transaktionstext: 'Inbetalning bokförd 240513' }),
]
const enriched = await attachBookingSuggestions(
supabase as unknown as SupabaseClient,
'company-1',
rows,
)
expect(enriched[0].booking_suggestion?.account).toBe('1932')
expect(enriched[1].booking_suggestion?.account).toBe('1932')
// Memoized: the second sentinel row must not refetch cash_accounts.
expect(fromCount(supabase, 'cash_accounts')).toBe(1)
})
it('gives booked rows null without any fetches when nothing needs a suggestion', async () => {
const { supabase } = makeSupabase()
const enriched = await attachBookingSuggestions(
supabase as unknown as SupabaseClient,
'company-1',
[makeSkvRow({ journal_entry_id: 'je-9' }), makeSkvRow({ status: 'upcoming' })],
)
expect(enriched.every((r) => r.booking_suggestion === null)).toBe(true)
expect(supabase.from).not.toHaveBeenCalled()
})
})
describe('bokforSkattekontoTransactionsBatch', () => {
it('books each row, commits per row, and never aborts on a row failure', async () => {
const { supabase, enqueue } = makeSupabase()
const row1 = makeSkvRow({ transaktionstext: 'Intäktsränta' })
const row2 = makeSkvRow({ transaktionstext: 'Ingen regel matchar detta' })
const row3 = makeSkvRow({ transaktionstext: 'Kostnadsränta', belopp_skatteverket: -100 })
enqueue({ data: SEED_RULES }) // hoisted rules
enqueue({ data: { entity_type: 'aktiebolag' } }) // hoisted entity_type
enqueue({ data: row1 }) // tx fetch row1
enqueue({ data: [{ id: row1.id }] }) // journal_entry_id claim row1
enqueue({ data: row2 }) // tx fetch row2 (fails matching, no claim)
enqueue({ data: row3 }) // tx fetch row3
enqueue({ data: [{ id: row3.id }] }) // claim row3
const result = await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row1.id, row2.id, row3.id],
)
expect(result.results).toHaveLength(3)
expect(result.results[0]).toMatchObject({
id: row1.id,
ok: true,
journal_entry_id: 'je-draft-1',
voucher_number: 42,
voucher_series: 'A',
})
expect(result.results[1]).toMatchObject({
id: row2.id,
ok: false,
error_code: 'NO_COUNTER_ACCOUNT',
})
expect(result.results[2]).toMatchObject({ id: row3.id, ok: true })
expect(result.summary).toEqual({ total: 3, succeeded: 2, failed: 1 })
// Commit runs once per successful row, attributed as a bulk acceptance.
expect(vi.mocked(commitEntry)).toHaveBeenCalledTimes(2)
expect(vi.mocked(commitEntry)).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-draft-1',
'bulk_accept',
)
expect(vi.mocked(createDraftEntry)).toHaveBeenCalledTimes(2)
})
it('hoists rules/entity_type: one fetch each for the whole batch', async () => {
const { supabase, enqueue } = makeSupabase()
const row1 = makeSkvRow({ transaktionstext: 'Intäktsränta' })
const row2 = makeSkvRow({ transaktionstext: 'Kostnadsränta' })
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: row1 })
enqueue({ data: [{ id: row1.id }] })
enqueue({ data: row2 })
enqueue({ data: [{ id: row2.id }] })
await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row1.id, row2.id],
)
expect(fromCount(supabase, 'skattekonto_rules')).toBe(1)
expect(fromCount(supabase, 'company_settings')).toBe(1)
})
it('attributes a one-row batch as user_accept', async () => {
const { supabase, enqueue } = makeSupabase()
const row = makeSkvRow({ transaktionstext: 'Intäktsränta' })
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: row })
enqueue({ data: [{ id: row.id }] })
await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row.id],
)
expect(vi.mocked(commitEntry)).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
'je-draft-1',
'user_accept',
)
})
it('reports COMMIT_FAILED with the kept draft id when the commit throws', async () => {
const { supabase, enqueue } = makeSupabase()
const row = makeSkvRow({ transaktionstext: 'Intäktsränta' })
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: row })
enqueue({ data: [{ id: row.id }] })
vi.mocked(commitEntry).mockRejectedValueOnce(new Error('Bokföringen är låst'))
const result = await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row.id],
)
expect(result.results[0]).toMatchObject({
id: row.id,
ok: false,
error_code: 'COMMIT_FAILED',
journal_entry_id: 'je-draft-1',
error_message: 'Bokföringen är låst',
})
expect(result.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
})
it('reports ALREADY_BOOKED for rows that already carry a journal entry', async () => {
const { supabase, enqueue } = makeSupabase()
const row = makeSkvRow({ journal_entry_id: 'je-existing' })
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: row })
const result = await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row.id],
)
expect(result.results[0]).toMatchObject({
id: row.id,
ok: false,
error_code: 'ALREADY_BOOKED',
})
expect(vi.mocked(createDraftEntry)).not.toHaveBeenCalled()
expect(vi.mocked(commitEntry)).not.toHaveBeenCalled()
})
it('rejects unsettled (kommande) rows with NOT_SETTLED before any draft exists', async () => {
const { supabase, enqueue } = makeSupabase()
// Rule WOULD match: only status must stop the row from being posted.
const row = makeSkvRow({ transaktionstext: 'Intäktsränta', status: 'upcoming' })
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: row })
const result = await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row.id],
)
expect(result.results[0]).toMatchObject({
id: row.id,
ok: false,
error_code: 'NOT_SETTLED',
})
expect(result.summary).toEqual({ total: 1, succeeded: 0, failed: 1 })
expect(vi.mocked(createDraftEntry)).not.toHaveBeenCalled()
expect(vi.mocked(commitEntry)).not.toHaveBeenCalled()
})
it('reports ALREADY_BOOKED and never commits when the backlink claim affects 0 rows', async () => {
const { supabase, enqueue } = makeSupabase()
const row = makeSkvRow({ transaktionstext: 'Intäktsränta' })
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: row }) // tx fetch: still unbooked at precheck time
enqueue({ data: [] }) // claim: a concurrent submission won the race
const result = await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row.id],
)
expect(result.results[0]).toMatchObject({
id: row.id,
ok: false,
error_code: 'ALREADY_BOOKED',
})
// The draft was created before the claim, but the losing request must
// never post it: no voucher may be committed for a row someone else owns.
expect(vi.mocked(createDraftEntry)).toHaveBeenCalledTimes(1)
expect(vi.mocked(commitEntry)).not.toHaveBeenCalled()
})
it('maps the period-lock trigger error to PERIOD_LOCKED with Swedish text', async () => {
const { supabase, enqueue } = makeSupabase()
const row = makeSkvRow({ transaktionstext: 'Intäktsränta' })
enqueue({ data: SEED_RULES })
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: row })
// A locked-but-not-closed period passes findFiscalPeriod's is_closed
// precheck; the enforcement trigger then rejects the draft INSERT with
// this signature (wrapped by the engine's BookkeepingDatabaseError).
vi.mocked(createDraftEntry).mockRejectedValueOnce(
new Error(
'Database operation "create_draft_entry" failed: Cannot write to locked/closed fiscal period "2026" (is_closed=f, locked_at=2026-02-01 00:00:00+00)',
),
)
const result = await bokforSkattekontoTransactionsBatch(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
[row.id],
)
expect(result.results[0]).toMatchObject({
id: row.id,
ok: false,
error_code: 'PERIOD_LOCKED',
})
expect(result.results[0].error_message).not.toContain('locked/closed fiscal period')
expect(vi.mocked(commitEntry)).not.toHaveBeenCalled()
})
})
+70 -2
View File
@@ -1,4 +1,5 @@
import crypto from 'crypto'
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse, after } from 'next/server'
@@ -45,7 +46,12 @@ import {
} from './lib/agi-client'
import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync'
import { runPostConnectRefresh } from './lib/post-connect-refresh'
import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking'
import {
attachBookingSuggestions,
bokforSkattekontoTransaction,
bokforSkattekontoTransactionsBatch,
SkattekontoBookingError,
} from './lib/skattekonto-booking'
import { handleSkattekontoDriftDetected } from './lib/skattekonto-drift-email'
import { handleSkattekontoConnectionExpired } from './lib/connection-expired-notification'
import {
@@ -61,6 +67,13 @@ import { createLogger } from '@/lib/logger'
const log = createLogger('skatteverket')
// Body for POST /skattekonto/transaktioner/bokfor-batch. Capped at 200 ids:
// a full year of skattekonto events fits comfortably, and the sequential
// draft+commit loop stays well inside the dispatcher's time budget.
const SkattekontoBokforBatchSchema = z.object({
ids: z.array(z.string().uuid()).min(1).max(200),
})
/**
* Skatteverket integration extension.
*
@@ -2097,7 +2110,16 @@ export const skatteverketExtension: Extension = {
})),
)
const bookedEnriched = booked.map(r => ({
// Deterministic booking suggestion per unbooked row (one hoisted
// rules fetch): the UI shows what "Bokför" will do and uses it to
// decide bulk-booking eligibility.
const withBookingSuggestions = await attachBookingSuggestions(
ctx.supabase,
ctx.companyId,
booked,
)
const bookedEnriched = withBookingSuggestions.map(r => ({
...r,
match_suggestion: suggestions.get(r.id) ?? null,
}))
@@ -2132,6 +2154,52 @@ export const skatteverketExtension: Extension = {
},
},
// ── Bokför several rows → committed verifikat per row ─────────
// Draft + commit per id, server-side, so a successful row lands as a
// posted verifikat with no orphan drafts. Row failures never abort the
// loop: the response carries per-row results plus a summary for one
// aggregate toast. Also serves the inline single-row flow (ids: [id]).
{
method: 'POST',
path: '/skattekonto/transaktioner/bokfor-batch',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) {
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
}
let parsedBody: unknown
try {
parsedBody = await request.json()
} catch {
return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 })
}
const parsed = SkattekontoBokforBatchSchema.safeParse(parsedBody)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Ogiltiga parametrar: ids måste vara en lista med 1-200 transaktions-id.' },
{ status: 400 },
)
}
// Dedupe: a repeated id would just burn an ALREADY_BOOKED failure
// on its second pass.
const ids = [...new Set(parsed.data.ids)]
try {
const result = await bokforSkattekontoTransactionsBatch(
ctx.supabase,
ctx.companyId,
ctx.userId,
ids,
)
return NextResponse.json({ data: result })
} catch (err) {
return handleSkvError(err)
}
},
},
// ── Bokför one row → draft journal entry ──────────────────────
// Creates a DRAFT verifikat in /bookkeeping for the user to review
// and commit. The skattekonto_transactions row is linked via
@@ -1,11 +1,17 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { commitEntry, createDraftEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
} from '@/types'
import type {
SkattekontoBatchResult,
SkattekontoBatchRowResult,
SkattekontoBookingSuggestion,
} from '@/types/skatteverket'
/**
* Per-row "Bokför" helper.
@@ -60,6 +66,7 @@ export class SkattekontoBookingError extends Error {
| 'NO_FISCAL_PERIOD'
| 'PERIOD_LOCKED'
| 'ALREADY_BOOKED'
| 'NOT_SETTLED'
| 'TRANSACTION_NOT_FOUND',
) {
super(message)
@@ -107,37 +114,22 @@ const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
const SKATTEKONTO_RULE_COLUMNS =
'id, priority, pattern, amount_min, amount_max, company_type, counter_account, counter_account_ef, label, active'
export async function guessCounterAccount(
supabase: SupabaseClient,
companyId: string,
/**
* Pure core matcher shared by every suggestion/booking path: walk the
* priority-ordered rules and return the first match. The returned account may
* still be the __PRIMARY_SEK__ sentinel; callers resolve it against
* cash_accounts (so the DB round trip stays out of the pure matcher).
*/
function matchSkattekontoRule(
rules: SkattekontoRuleRow[],
transaktionstext: string,
entityType: EntityType,
belopp?: number,
): Promise<CounterAccountMatch | null> {
if (!SAFE_ID_PATTERN.test(companyId)) {
// The caller is supposed to pass a validated company id (from
// requireCompanyId). Refuse rather than interpolate an unknown string
// into the PostgREST filter: the .or() string parser is forgiving and
// we don't want to depend on it for safety.
return null
}
): CounterAccountMatch | null {
const normalized = transaktionstext.toLowerCase()
const absBelopp = belopp === undefined ? null : Math.abs(belopp)
const { data: rules, error } = await supabase
.from('skattekonto_rules')
.select(SKATTEKONTO_RULE_COLUMNS)
.eq('active', true)
.or(`company_id.eq.${companyId},company_id.is.null`)
.order('priority', { ascending: true })
.order('id', { ascending: true })
if (error || !rules || rules.length === 0) {
return null
}
for (const rule of rules as SkattekontoRuleRow[]) {
for (const rule of rules) {
if (rule.company_type !== 'all' && rule.company_type !== entityType) {
continue
}
@@ -154,15 +146,11 @@ export async function guessCounterAccount(
if (!patterns.some(p => normalized.includes(p))) continue
let account =
const account =
entityType === 'enskild_firma' && rule.counter_account_ef
? rule.counter_account_ef
: rule.counter_account
if (account === PRIMARY_SEK_SENTINEL) {
account = await resolvePrimarySekAccount(supabase, companyId)
}
return {
account,
label: rule.label ?? transaktionstext,
@@ -172,6 +160,169 @@ export async function guessCounterAccount(
return null
}
/** The active rules for a company (system seeds + overrides), priority order. */
async function fetchSkattekontoRules(
supabase: SupabaseClient,
companyId: string,
): Promise<SkattekontoRuleRow[]> {
const { data: rules, error } = await supabase
.from('skattekonto_rules')
.select(SKATTEKONTO_RULE_COLUMNS)
.eq('active', true)
.or(`company_id.eq.${companyId},company_id.is.null`)
.order('priority', { ascending: true })
.order('id', { ascending: true })
if (error || !rules) return []
return rules as SkattekontoRuleRow[]
}
export async function guessCounterAccount(
supabase: SupabaseClient,
companyId: string,
transaktionstext: string,
entityType: EntityType,
belopp?: number,
): Promise<CounterAccountMatch | null> {
if (!SAFE_ID_PATTERN.test(companyId)) {
// The caller is supposed to pass a validated company id (from
// requireCompanyId). Refuse rather than interpolate an unknown string
// into the PostgREST filter: the .or() string parser is forgiving and
// we don't want to depend on it for safety.
return null
}
const rules = await fetchSkattekontoRules(supabase, companyId)
if (rules.length === 0) return null
const match = matchSkattekontoRule(rules, transaktionstext, entityType, belopp)
if (!match) return null
return {
...match,
account:
match.account === PRIMARY_SEK_SENTINEL
? await resolvePrimarySekAccount(supabase, companyId)
: match.account,
}
}
/**
* One-shot context for enrichment/batch paths: hoists the rules and
* entity_type fetches out of per-row work. The primary SEK account is
* resolved lazily and memoized: most batches never contain an
* in-/utbetalning row, so the cash_accounts query only runs when a
* __PRIMARY_SEK__ rule actually matches.
*/
export interface SkattekontoRuleContext {
rules: SkattekontoRuleRow[]
entityType: EntityType
resolvePrimarySek: () => Promise<string>
}
export async function loadRuleContext(
supabase: SupabaseClient,
companyId: string,
): Promise<SkattekontoRuleContext> {
if (!SAFE_ID_PATTERN.test(companyId)) {
// Same defence-in-depth refusal as guessCounterAccount: never interpolate
// an unvalidated id into the PostgREST .or() filter string.
return {
rules: [],
entityType: 'aktiebolag',
resolvePrimarySek: async () => PRIMARY_SEK_FALLBACK,
}
}
const [rules, settingsResult] = await Promise.all([
fetchSkattekontoRules(supabase, companyId),
supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.single(),
])
let primary: string | null = null
return {
rules,
entityType: (settingsResult.data?.entity_type as EntityType) ?? 'aktiebolag',
resolvePrimarySek: async () => {
if (primary === null) {
primary = await resolvePrimarySekAccount(supabase, companyId)
}
return primary
},
}
}
/**
* Enrich skattekonto rows with the deterministic booking suggestion the
* per-row "Bokför" would use, so the list can show what a booking will do
* before the user commits to it. One rules/entity_type fetch for the whole
* page of rows.
*
* Only unbooked, genomförda rows get a computed suggestion; already-booked
* rows and kommande rows get `booking_suggestion: null` without any matching
* work (and a page of only such rows skips the fetches entirely).
*/
export async function attachBookingSuggestions<
T extends {
transaktionstext: string
belopp_skatteverket: number | string
journal_entry_id: string | null
status: string
},
>(
supabase: SupabaseClient,
companyId: string,
rows: T[],
): Promise<(T & { booking_suggestion: SkattekontoBookingSuggestion | null })[]> {
const needsSuggestion = (row: T) =>
!row.journal_entry_id && row.status !== 'upcoming'
if (!rows.some(needsSuggestion)) {
return rows.map(row => ({ ...row, booking_suggestion: null }))
}
const ctx = await loadRuleContext(supabase, companyId)
const enriched: (T & { booking_suggestion: SkattekontoBookingSuggestion | null })[] = []
for (const row of rows) {
if (!needsSuggestion(row)) {
enriched.push({ ...row, booking_suggestion: null })
continue
}
const match = matchSkattekontoRule(
ctx.rules,
row.transaktionstext,
ctx.entityType,
Number(row.belopp_skatteverket),
)
if (!match) {
enriched.push({ ...row, booking_suggestion: null })
continue
}
const account =
match.account === PRIMARY_SEK_SENTINEL
? await ctx.resolvePrimarySek()
: match.account
enriched.push({
...row,
booking_suggestion: {
account,
account_name: getBASReference(account)?.account_name ?? null,
label: match.label,
},
})
}
return enriched
}
/**
* Create a draft journal entry for one skattekonto_transactions row.
*
@@ -188,6 +339,15 @@ export async function bokforSkattekontoTransaction(
companyId: string,
userId: string,
transactionId: string,
// Batch callers pass a preloaded context so rules/entity_type are fetched
// once per batch instead of once per row. Omitted → per-call fetches,
// identical to the original single-row behaviour.
ruleContext?: SkattekontoRuleContext,
// requireSettled: reject rows that Skatteverket has not settled yet
// (status !== 'booked'). The batch commit path sets this: a kommande row
// must never land in an immutable posted verifikat. The single-row draft
// endpoint keeps its historical behaviour (draft for user review).
options?: { requireSettled?: boolean },
): Promise<JournalEntry> {
// 1. Load the transaction
const { data: tx, error: txError } = await supabase
@@ -211,24 +371,50 @@ export async function bokforSkattekontoTransaction(
)
}
// 2. Get entity_type for AB/EF-specific accounts
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.single()
if (options?.requireSettled && tx.status !== 'booked') {
throw new SkattekontoBookingError(
'Händelsen är inte genomförd hos Skatteverket ännu och kan inte bokföras.',
'NOT_SETTLED',
)
}
const entityType: EntityType =
(settings?.entity_type as EntityType) ?? 'aktiebolag'
// 2+3. Resolve counter-account via skattekonto_rules (entity_type decides
// AB/EF-specific accounts).
let guess: CounterAccountMatch | null
if (ruleContext) {
const match = matchSkattekontoRule(
ruleContext.rules,
tx.transaktionstext,
ruleContext.entityType,
Number(tx.belopp_skatteverket),
)
guess = match
? {
...match,
account:
match.account === PRIMARY_SEK_SENTINEL
? await ruleContext.resolvePrimarySek()
: match.account,
}
: null
} else {
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.single()
// 3. Resolve counter-account via skattekonto_rules
const guess = await guessCounterAccount(
supabase,
companyId,
tx.transaktionstext,
entityType,
Number(tx.belopp_skatteverket),
)
const entityType: EntityType =
(settings?.entity_type as EntityType) ?? 'aktiebolag'
guess = await guessCounterAccount(
supabase,
companyId,
tx.transaktionstext,
entityType,
Number(tx.belopp_skatteverket),
)
}
if (!guess) {
throw new SkattekontoBookingError(
`Vi kunde inte gissa motkontot för "${tx.transaktionstext}". Skapa verifikatet manuellt.`,
@@ -296,12 +482,152 @@ export async function bokforSkattekontoTransaction(
const entry = await createDraftEntry(supabase, companyId, userId, input)
// Link the row back so the dashboard can show "Bokförd" status.
await supabase
// Link the row back so the dashboard can show "Bokförd" status. The
// backlink is a conditional CLAIM, not a blind write: `.is('journal_entry_id',
// null)` makes concurrent submissions race on the same row and lets exactly
// one win. Zero affected rows means another request already booked the row
// between our precheck and now: surface ALREADY_BOOKED instead of
// double-posting. The just-created draft is left behind unlinked: the
// engine has no sanctioned draft-discard function and journal tables must
// never be raw-deleted, so an orphan draft (legally deletable by the user
// in /bookkeeping) is the safe leftover.
const { data: claimed, error: claimError } = await supabase
.from('skattekonto_transactions')
.update({ journal_entry_id: entry.id })
.eq('id', tx.id)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.select('id')
if (claimError || !claimed || claimed.length === 0) {
throw new SkattekontoBookingError(
'Transaktionen är redan bokförd.',
'ALREADY_BOOKED',
)
}
return entry
}
export type { SkattekontoBatchResult, SkattekontoBatchRowResult }
/**
* The period-lock enforcement trigger (migration 20240101000017) raises
* 'Cannot write to locked/closed fiscal period "..." (is_closed=..., locked_at=...)'.
* findFiscalPeriod's precheck only filters is_closed=false, so a period that
* is locked (locked_at set) but not closed passes the precheck and the draft
* INSERT throws this instead. Detect the signature (also when wrapped by
* BookkeepingDatabaseError, which appends the DB message) so the batch can
* report PERIOD_LOCKED rather than UNKNOWN with raw English trigger text.
*/
function isPeriodLockTriggerError(err: unknown): boolean {
return (
err instanceof Error &&
err.message.includes('locked/closed fiscal period')
)
}
/**
* Book several skattekonto rows in one server-side pass: draft + commit per
* row so a successful row lands as a posted verifikat immediately (no orphan
* drafts for the user to chase). Rules/entity_type are fetched once for the
* whole batch.
*
* A row failure never aborts the loop: the caller gets a per-row result list
* plus a summary and reports the aggregate. If the draft was created but the
* commit failed (e.g. a mandatory-dimension policy), the draft is kept and
* stays linked to the row: that degrades to the pre-existing
* draft-then-review flow instead of deleting bookkeeping material.
*/
export async function bokforSkattekontoTransactionsBatch(
supabase: SupabaseClient,
companyId: string,
userId: string,
ids: string[],
): Promise<SkattekontoBatchResult> {
const ruleContext = await loadRuleContext(supabase, companyId)
// A one-row batch is the inline single-row flow: attribute it as a normal
// user acceptance; real bulk runs are attributed as bulk_accept.
const commitMethod = ids.length === 1 ? 'user_accept' : 'bulk_accept'
const results: SkattekontoBatchRowResult[] = []
for (const id of ids) {
let entry: JournalEntry
try {
entry = await bokforSkattekontoTransaction(
supabase,
companyId,
userId,
id,
ruleContext,
// Batch rows commit immediately: never post an unsettled (kommande)
// Skatteverket row into an immutable verifikat.
{ requireSettled: true },
)
} catch (err) {
if (err instanceof SkattekontoBookingError) {
results.push({
id,
ok: false,
error_code: err.code,
error_message: err.message,
})
} else if (isPeriodLockTriggerError(err)) {
results.push({
id,
ok: false,
error_code: 'PERIOD_LOCKED',
error_message:
'Raden ligger i en låst räkenskapsperiod. Lås upp perioden eller hoppa över raden.',
})
} else {
results.push({
id,
ok: false,
error_code: 'UNKNOWN',
error_message:
err instanceof Error ? err.message : 'Transaktionen kunde inte bokföras.',
})
}
continue
}
try {
const committed = await commitEntry(
supabase,
companyId,
userId,
entry.id,
commitMethod,
)
results.push({
id,
ok: true,
journal_entry_id: committed.id,
voucher_number: committed.voucher_number ?? null,
voucher_series: committed.voucher_series ?? null,
})
} catch (err) {
results.push({
id,
ok: false,
journal_entry_id: entry.id,
error_code: 'COMMIT_FAILED',
error_message:
err instanceof Error
? err.message
: 'Utkastet skapades men kunde inte bokföras.',
})
}
}
const succeeded = results.filter(r => r.ok).length
return {
results,
summary: {
total: results.length,
succeeded,
failed: results.length - succeeded,
},
}
}
+1
View File
@@ -324,6 +324,7 @@ export interface SkatteverketFel {
export type {
StoredSkattekontoTransaction,
SkattekontoMatchSuggestion,
SkattekontoBookingSuggestion,
SkattekontoTransactionWithSuggestion,
} from '@/types/skatteverket'