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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
7cf0e34434
commit
dea5e31756
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,7 @@ export interface SkatteverketFel {
|
||||
export type {
|
||||
StoredSkattekontoTransaction,
|
||||
SkattekontoMatchSuggestion,
|
||||
SkattekontoBookingSuggestion,
|
||||
SkattekontoTransactionWithSuggestion,
|
||||
} from '@/types/skatteverket'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user