Add/user feedback (#679)

* feat(bookkeeping): make blocked fiscal-year creation actionable

When creating a new räkenskapsår is blocked because a prior period is
still open, the "Skapa räkenskapsår" dialog no longer dead-ends on an
English toast. The API now returns the canonical bilingual error envelope
with the blocking periods (id/name/dates) under details, and the dialog
renders a Swedish panel that locks them inline (reversible locked_at) via
the existing /lock endpoint and retries creation.

The guard rule is unchanged and remains BFL-compliant: BFL 6 kap allows
löpande bokföring of the new year in parallel with the prior year's
bokslut, so a lock (not a full close) is sufficient and reversible.

- Add PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS structured error code
- Return envelope + details.blockingPeriods from the 409 (was English string)
- CreatePeriodDialog: inline "lås och skapa" panel + lock-and-retry
- Update route tests for the new envelope shape

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): prevent mouse wheel from mutating number inputs

A focused <input type="number"> would change its value on scroll,
silently turning e.g. a 20000 salary into 19998. Blur number inputs
on wheel so the page scrolls instead of editing the value. Applied
at the Input primitive so all number fields are protected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(salary): auto-derive skattetabell and kolumn for employees

Replace the opaque manual "Skattetabell (29-42)" and "Kolumn (1-6)" inputs
on the employee form with a self-deriving flow: the user picks their
folkbokföringskommun from a searchable dropdown and the tax table fills
itself in, while the column derives from the personnummer we already collect.

- Add a searchable municipality picker (MunicipalityCombobox) backed by a
  new cached GET /api/salary/tax-tables/kommuner endpoint.
- Wrap the whole "Skatt" card in a self-contained EmployeeTaxCard used by
  both the create and edit pages, with InfoTooltips and named column options.
- deriveTaxColumn(): auto-select column 1 for under-66 employees; leave the
  ambiguous 66+ case (pension vs working senior) to a clearly-named manual
  choice.
- Fix fetchKommunTaxRates() to page through all ~1300 församling rows instead
  of a single 500-row page (which silently dropped ~200 kommuner, incl.
  Göteborg) and normalize the uppercase names to title case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(import): correct CSV amount-column guess and surface skipped rows

Manual CSV column-mapping auto-guess walked each data row right-to-left
and picked the first numeric cell as the amount, so on the common
...;Belopp;Saldo layout it grabbed the trailing running-balance column.
Extract the guess into a pure, tested suggestColumnMapping(): match
header labels first (belopp/amount -> amount, saldo/balance -> balance),
auto-fill the balance field, and fall back to value heuristics that skip
the balance column and prefer a column carrying negative values.

Also surface stats.skipped_rows + parse warnings in BankFileConfirmStep -
the manual-mapping path skips the preview step that was the only place
they showed, so skipped rows were silently dropped from view.

Add a unit test reproducing the Saldo-as-amount regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: add "Save as draft" functionality for invoices

- Implemented a new feature to allow users to save invoices as unnumbered drafts without generating an invoice number until finalized.
- Added a `save_as_draft` flag to the CreateInvoiceInput schema to handle draft saving logic.
- Updated the invoice creation API to skip number allocation when saving as a draft.
- Introduced a new endpoint for finalizing drafts, which allocates an invoice number and emits an `invoice.created` event.
- Enhanced the UI to include a "Save as draft" button, with loading states and tooltips.
- Updated tests to cover the new draft saving and finalization logic, including race conditions for concurrent modifications.
- Added relevant error handling for draft finalization and deletion scenarios.

* feat(employee): add employment start and end date fields to employee forms

* feat: enhance invoice and salary run handling with improved validation and event logging

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-05 17:26:40 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent bc61862e76
commit 0ca9c25aba
41 changed files with 2572 additions and 484 deletions
@@ -31,7 +31,7 @@ type Period = { id: string; period_start: string; period_end: string; is_closed:
function buildMockSupabase(options: {
user?: { id: string } | null
allPeriods?: Period[]
openPeriods?: Array<{ name: string; period_start: string; period_end: string }>
openPeriods?: Array<{ id?: string; name: string; period_start: string; period_end: string }>
bookkeepingLockedThrough?: string | null
overlapping?: Array<{ id: string; name: string }>
insertResult?: { data: unknown; error: unknown }
@@ -78,7 +78,7 @@ function buildMockSupabase(options: {
// For update (call 5): .update(...).eq(...).eq(...)
chainable.select = vi.fn().mockImplementation((sel: string) => {
if (sel.includes('name') && sel.includes('period_start') && !sel.includes('id')) {
if (sel.includes('name') && sel.includes('period_start')) {
// openPeriods query: .eq(company_id).eq(is_closed=false).is(locked_at, null).order(...)
return {
eq: vi.fn().mockReturnValue({
@@ -178,17 +178,22 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
expect(body.error).toMatch(/must start on 2026-01-01/)
})
it('rejects forward period when an unlocked open period exists and lists its name', async () => {
it('rejects forward period when an unlocked open period exists and returns it as a blocking period', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }],
openPeriods: [{ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }],
})
const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' })
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/unlocked period/)
expect(body.error).toMatch(/FY 2025 \(2025-01-01 – 2025-12-31\)/)
// Canonical envelope with a machine code + the blocking periods so the
// dialog can offer to lock them inline.
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
expect(body.error.message).toMatch(/låsa föregående räkenskapsår/)
expect(body.error.details.blockingPeriods).toEqual([
{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
])
})
// Regression: BFL 6 kap allows löpande bokföring of the new year in parallel
@@ -232,7 +237,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
it('rejects forward period creation when company-wide lock only partially covers prior period', async () => {
buildMockSupabase({
allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }],
openPeriods: [{ name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
openPeriods: [{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }],
bookkeepingLockedThrough: '2024-06-30',
overlapping: [],
})
@@ -240,7 +245,10 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const res = await POST(req)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error).toMatch(/FY 2024/)
expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS')
expect(body.error.details.blockingPeriods).toEqual([
{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' },
])
})
it('allows backward period creation', async () => {
@@ -337,7 +345,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const callNum = fpCallIndex
return {
select: vi.fn().mockImplementation((sel: string) => {
if (sel.includes('name') && sel.includes('period_start') && !sel.includes('id')) {
if (sel.includes('name') && sel.includes('period_start')) {
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
@@ -413,7 +421,7 @@ describe('POST /api/bookkeeping/fiscal-periods', () => {
const callNum = fpCallIndex
return {
select: vi.fn().mockImplementation((sel: string) => {
if (sel.includes('name') && sel.includes('period_start') && !sel.includes('id')) {
if (sel.includes('name') && sel.includes('period_start')) {
return {
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
+17 -10
View File
@@ -5,6 +5,10 @@ import { validateBody } from '@/lib/api/validate'
import { CreateFiscalPeriodSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
const log = createLogger('api/bookkeeping/fiscal-periods')
export async function GET() {
const supabase = await createClient()
@@ -109,7 +113,7 @@ export async function POST(request: Request) {
// must not block creating the next räkenskapsår.
const { data: openPeriods } = await supabase
.from('fiscal_periods')
.select('name, period_start, period_end')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.eq('is_closed', false)
.is('locked_at', null)
@@ -127,15 +131,18 @@ export async function POST(request: Request) {
)
if (trulyOpen.length > 0) {
const names = trulyOpen
.map((p) => `${p.name} (${p.period_start} – ${p.period_end})`)
.join(', ')
return NextResponse.json(
{
error: `Cannot create a new period while an unlocked period exists. Lock the following first: ${names}`,
},
{ status: 409 }
)
// Hand the blocking periods (id + name + dates) to the client so the
// "Skapa räkenskapsår" dialog can offer to lock them inline and retry,
// instead of dead-ending the user on a message they can't act on.
const blockingPeriods = trulyOpen.map((p) => ({
id: p.id,
name: p.name,
period_start: p.period_start,
period_end: p.period_end,
}))
return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, {
details: { blockingPeriods },
})
}
}
}
@@ -114,6 +114,48 @@ describe('POST /api/company/[id]/delete', () => {
expect(body).toHaveProperty('error')
})
it('accepts the settings display name when companies.name is stale', async () => {
// Regression: the UI shows company_settings.company_name (the dialog,
// label and the enable-gate all use it), but companies.name can be stale.
// The server must accept the displayed name, not just the raw column.
mockAuth('user-1')
mockService({
companies: [{ data: { id: 'c1', name: 'Gammalt namn', archived_at: null }, error: null }],
company_members: [{ data: { role: 'owner' }, error: null }],
company_settings: [{ data: { company_name: 'Nytt namn' }, error: null }],
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Nytt namn' },
})
const { status } = await parseJsonResponse(
await POST(req, createMockRouteParams({ id: 'c1' }))
)
expect(status).toBe(200)
})
it('rejects the stale companies.name when a settings name exists (only the displayed name is accepted)', async () => {
// Security: the UI only ever shows company_settings.company_name when set, so
// the server must not accept the stale companies.name as an alternative
// confirmation — that would be a delete path the user was never shown.
mockAuth('user-1')
mockService({
companies: [{ data: { id: 'c1', name: 'Gammalt namn', archived_at: null }, error: null }],
company_members: [{ data: { role: 'owner' }, error: null }],
company_settings: [{ data: { company_name: 'Nytt namn' }, error: null }],
})
const req = createMockRequest('/api/company/c1/delete', {
method: 'POST',
body: { confirm_name: 'Gammalt namn' },
})
const { status } = await parseJsonResponse(
await POST(req, createMockRouteParams({ id: 'c1' }))
)
expect(status).toBe(400)
})
it('returns 403 when caller is member but not owner', async () => {
mockAuth('user-1')
mockService({
+21 -3
View File
@@ -25,7 +25,10 @@ const DeleteCompanySchema = z.object({
*
* Rules:
* - Only callers with role='owner' in company_members may delete.
* - The body must include confirm_name matching companies.name exactly.
* - The body must include confirm_name matching the company's display name.
* The UI shows company_settings.company_name (companies.name may be stale),
* so we validate against that, falling back to companies.name. Either value
* is accepted so the confirm gate never blocks a legitimate deletion.
* - Already-archived companies return 404 (treated as not found).
*/
export async function POST(
@@ -89,8 +92,23 @@ export async function POST(
)
}
// 3. Confirm name matches exactly (case-sensitive trim)
if (confirm_name.trim() !== company.name.trim()) {
// 3. Confirm name matches the exact name the UI displays. The dashboard layout
// resolves the displayed name as `company_settings.company_name || companies.name`
// (companies.name may be stale) and CompanyDangerZone gates on that value, so
// the server must accept ONLY that single name. Accepting the stale
// companies.name as an alternative would open a confirmation path the user was
// never shown — weakening the gate on an irreversible action (ASVS V8.2.1).
// Case-sensitive trim, mirror of the client-side check.
const { data: companySettings } = await service
.from('company_settings')
.select('company_name')
.eq('company_id', companyId)
.maybeSingle()
const displayName = (companySettings?.company_name || company.name).trim()
const typed = confirm_name.trim()
if (typed !== displayName) {
return NextResponse.json(
{ error: 'Företagsnamnet stämmer inte överens.' },
{ status: 400 }
+35 -6
View File
@@ -5,12 +5,17 @@ import {
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
@@ -27,6 +32,7 @@ describe('DELETE /api/invoices/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -91,24 +97,47 @@ describe('DELETE /api/invoices/[id]', () => {
expect(body.data.invoice_number).toBe('F-2026001')
})
it('cancels an un-numbered draft (legacy null-number row)', async () => {
it('hard deletes an un-numbered draft (saved via "Spara som utkast") and emits an audit event', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
error: null,
})
// delete().select('id') returns the removed row
enqueue({ data: [{ id: 'inv-1' }], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{
data: { cancelled: boolean; invoice_number: string | null }
}>(response)
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response)
expect(status).toBe(200)
expect(body.data.cancelled).toBe(true)
expect(body.data.invoice_number).toBeNull()
expect(body.data.deleted).toBe(true)
// The hard delete leaves no journal trace, so an audit event must record it.
expect(emitSpy).toHaveBeenCalledWith({
type: 'invoice.draft_deleted',
payload: { invoiceId: 'inv-1', companyId: 'company-1', userId: 'user-1' },
})
})
it('returns 409 INVOICE_CANCEL_RACE when an un-numbered draft is finalized concurrently', async () => {
enqueue({
data: { id: 'inv-1', status: 'draft', invoice_number: null, user_id: 'user-1' },
error: null,
})
// delete matched 0 rows — the draft was finalized (numbered) in the meantime.
enqueue({ data: [], error: null })
const response = await DELETE(
createMockRequest('/api/invoices/inv-1', { method: 'DELETE' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_CANCEL_RACE')
})
it('returns 409 INVOICE_CANCEL_RACE when status flipped between fetch and update', async () => {
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
} from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { POST } from '../route'
describe('POST /api/invoices/[id]/finalize ("Granska & skapa")', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('finalizes an unnumbered draft: allocates a number and emits invoice.created', async () => {
const customer = makeCustomer({ id: 'cust-1' })
const draft = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'draft',
document_type: 'invoice',
})
// Fetch the draft
enqueue({ data: draft, error: null })
// ensureInvoiceNumber → generate_invoice_number RPC
enqueue({ data: '2026001', error: null })
// Fetch the complete invoice
enqueue({ data: { ...draft, invoice_number: '2026001', customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBe('2026001')
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.created' })
)
})
it('returns 404 INVOICE_NOT_FOUND when the invoice does not exist', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('INVOICE_NOT_FOUND')
})
it('rejects a draft that already has a number with INVOICE_FINALIZE_NOT_DRAFT', async () => {
const numbered = makeInvoice({
id: 'inv-1',
invoice_number: 'F-2026001',
status: 'draft',
document_type: 'invoice',
})
enqueue({ data: numbered, error: null })
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FINALIZE_NOT_DRAFT')
})
it('rejects a non-draft invoice with INVOICE_FINALIZE_NOT_DRAFT', async () => {
const sent = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'sent',
document_type: 'invoice',
})
enqueue({ data: sent, error: null })
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FINALIZE_NOT_DRAFT')
})
it('rejects a self-billed draft (counterparty document, no F-number allocation)', async () => {
const selfBilled = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'draft',
document_type: 'invoice',
is_self_billed: true,
})
enqueue({ data: selfBilled, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('INVOICE_FINALIZE_NOT_DRAFT')
expect(emitSpy).not.toHaveBeenCalled()
})
it('returns 500 INVOICE_FINALIZE_INCOMPLETE (and emits nothing) when the re-read fails after numbering', async () => {
const draft = makeInvoice({
id: 'inv-1',
invoice_number: null,
status: 'draft',
document_type: 'invoice',
})
enqueue({ data: draft, error: null }) // fetch draft
enqueue({ data: '2026001', error: null }) // number allocation
enqueue({ data: null, error: { message: 'transient db error' } }) // re-read fails
const emitSpy = vi.spyOn(eventBus, 'emit')
const response = await POST(
createMockRequest('/api/invoices/inv-1/finalize', { method: 'POST' }),
createMockRouteParams({ id: 'inv-1' })
)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(500)
expect(body.error.code).toBe('INVOICE_FINALIZE_INCOMPLETE')
// No invoice.created emitted with a hollow payload.
expect(emitSpy).not.toHaveBeenCalled()
})
})
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Invoice } from '@/types'
ensureInitialized() // Module-level — loads extensions so invoice.created handlers are wired.
/**
* POST /api/invoices/[id]/finalize — "Granska och skapa".
*
* Turns an unnumbered draft (saved via "Spara som utkast") into a real, issued
* invoice: allocates the F-series number and emits invoice.created (which drives
* webhooks + the audit log). After this the invoice behaves exactly like one
* created directly — it can be sent or cancelled (makulerad), but no longer
* hard-deleted, because the number now belongs to the gap-free series
* (ML 17 kap 24§).
*
* Only an unnumbered draft (status='draft', invoice_number IS NULL,
* document_type='invoice') may be finalized. Numbering is idempotent inside the
* generate_invoice_number RPC, but the explicit guard keeps the contract clear.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'invoice.finalize',
async (_request, { supabase, companyId, user, log, requestId }, { params }) => {
const { id } = await params
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, invoice_number, document_type, is_self_billed')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !invoice) {
return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId })
}
// Only an unnumbered draft can be finalized. Numbered drafts are already
// issued (cancel via makulering instead); sent/paid invoices are immutable.
// A null document_type means a plain invoice (older rows / default).
// Self-billed drafts are counterparty documents (självfakturering) and must
// not be allocated an F-series number through this flow even if a direct
// API call left one unnumbered.
const docType = invoice.document_type ?? 'invoice'
if (
invoice.status !== 'draft' ||
invoice.invoice_number ||
docType !== 'invoice' ||
invoice.is_self_billed
) {
return errorResponseFromCode('INVOICE_FINALIZE_NOT_DRAFT', log, { requestId })
}
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
} catch (err) {
log.error('failed to assign invoice number on finalize', err as Error, { invoiceId: id })
return errorResponseFromCode('INVOICE_CREATE_NUMBER_ASSIGN_FAILED', log, { requestId })
}
const { data: completeInvoice, error: refetchError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.single()
// The number was already allocated, so the invoice is finalized in the DB —
// but if the re-read fails we cannot emit invoice.created with a complete
// payload, which would silently drop the audit-log entry, webhooks, and any
// extension wired to the event. Surface it as a 500 rather than returning
// 200 with a null body and a hollow success toast. A reload shows the
// (correctly numbered) invoice; the failure is now visible in monitoring.
if (refetchError || !completeInvoice) {
log.error(
'finalize: number allocated but invoice re-read failed; invoice.created not emitted',
refetchError as Error,
{ invoiceId: id },
)
return errorResponseFromCode('INVOICE_FINALIZE_INCOMPLETE', log, { requestId })
}
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, companyId: companyId!, userId: user.id },
})
return NextResponse.json({ data: completeInvoice })
},
{ requireWrite: true },
)
+53 -8
View File
@@ -1,25 +1,30 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createLogger } from '@/lib/logger'
ensureInitialized() // Module-level — wires the audit-log handler for invoice.draft_deleted.
const log = createLogger('api.invoices.cancel')
/**
* DELETE /api/invoices/[id]
*
* Cancels (makulerar) a draft invoice. The row and its F-series number are
* retained — the invoice transitions to status='cancelled'. Keeping the row
* preserves the invoice-number sequence per ML 17 kap 24§ and BFNAR 2013:2,
* so the F-series stays gap-free without any voucher_gap_explanations entry.
* Removes a draft invoice. Behaviour depends on whether a number was issued:
*
* Only drafts may be cancelled this way. Sent / paid invoices are immutable
* per BFL and must be reversed via a credit note instead.
* - Unnumbered draft (saved via "Spara som utkast", never finalized): hard
* deleted. No F-series number was consumed, so there is no gap to document
* (ML 17 kap 24§). invoice_items cascade via the FK.
* - Numbered draft (created directly, or finalized via "Granska och skapa"):
* makulerad — the row and its number are retained and status flips to
* 'cancelled', keeping the F-series gap-free per ML 17 kap 24§ / BFNAR 2013:2.
*
* Old drafts predating allocate-on-save may have invoice_number = NULL; those
* still cancel (status flip) without consuming a number — no special-case path.
* Only drafts may be removed either way. Sent / paid invoices are immutable per
* BFL and must be reversed via a credit note instead.
*/
export async function DELETE(
request: Request,
@@ -54,6 +59,46 @@ export async function DELETE(
return errorResponseFromCode('INVOICE_DELETE_NOT_DRAFT', log)
}
// Unnumbered drafts (saved via "Spara som utkast", never finalized) are not
// yet issued invoices — no F-series number was consumed — so they can be hard
// deleted with no gap in the sequence (ML 17 kap 24§). invoice_items cascade
// via the FK (ON DELETE CASCADE); an un-finalized draft has no journal entry
// or linked document. The status='draft' + invoice_number IS NULL guard makes
// the delete a no-op if the row was finalized (numbered) concurrently.
if (!invoice.invoice_number) {
const { data: removed, error: removeError } = await supabase
.from('invoices')
.delete()
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.is('invoice_number', null)
.select('id')
if (removeError) {
return NextResponse.json({ error: removeError.message }, { status: 500 })
}
if (!removed || removed.length === 0) {
// Finalized between fetch and delete — refuse rather than fall through to
// makulering of a now-issued invoice.
return errorResponseFromCode('INVOICE_CANCEL_RACE', log)
}
// The row is gone, so there's no journal trace of the removal. Emit an
// audit event carrying the identifiers so the event log records who deleted
// which draft and when — the makulering path leaves a journal/status trail,
// a hard delete otherwise leaves none.
await eventBus.emit({
type: 'invoice.draft_deleted',
payload: { invoiceId: id, companyId, userId: user.id },
})
return NextResponse.json({ data: { deleted: true } })
}
// Numbered draft: retain the row and its number, flip to 'cancelled'
// (makulering) so the F-series stays gap-free.
// .select() returns the affected rows so we can detect a TOCTOU race where
// the status flipped between the fetch above and this update. With only the
// .eq('status','draft') guard, a 0-row update returns success and the user
+50
View File
@@ -219,6 +219,56 @@ describe('POST /api/invoices (create invoice)', () => {
)
})
it('saves an unnumbered draft without a number or event when save_as_draft is true', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
// Insert invoice (stays unnumbered — the allocation step is skipped)
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch complete invoice (still unnumbered; no generate_invoice_number RPC)
enqueue({ data: { ...createdInvoice, invoice_number: null, customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
save_as_draft: true,
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBeNull()
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.created' })
)
})
it('rolls back invoice when items insertion fails', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1' })
+12 -8
View File
@@ -317,12 +317,14 @@ export const POST = withRouteContext(
})
}
// Allocate F-series number on save (Fortnox-style). The user gets a numbered
// draft they can download and send manually without first lying about
// having sent it. Discarded numbered drafts become 'cancelled' rather than
// deleted, so the F-series stays gap-free per ML 17 kap 24§.
// Delivery notes already have their number from the insert above.
if (documentType === 'invoice' || documentType === 'proforma') {
// Allocate the F-series number on save (Fortnox-style) — UNLESS the caller
// asked to save as an unnumbered draft. A direct create gives the user a
// numbered draft they can download and send manually; "Spara som utkast"
// (save_as_draft) defers numbering to the explicit "Granska och skapa" step
// (POST /invoices/{id}/finalize) so the draft can be hard-deleted with no
// gap in the F-series per ML 17 kap 24§. Delivery notes are always numbered
// at insert above and ignore the flag.
if (!invoiceInput.save_as_draft && (documentType === 'invoice' || documentType === 'proforma')) {
try {
await ensureInvoiceNumber(supabase, companyId!, invoice as Invoice)
} catch (err) {
@@ -373,8 +375,10 @@ export const POST = withRouteContext(
.eq('id', invoice.id)
.single()
// Emit event only for real invoices (proformas / delivery notes / quotes are informational).
if (completeInvoice && documentType === 'invoice') {
// Emit event only for real, issued invoices. Unnumbered drafts (save_as_draft)
// are not issued yet — the invoice.created event (which drives webhooks and the
// audit log) fires when the user finalizes via "Granska och skapa".
if (completeInvoice && documentType === 'invoice' && !invoiceInput.save_as_draft) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, companyId: companyId!, userId: user.id },
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// ── Mocks ────────────────────────────────────────────────────
// The route is wrapped in withRouteContext, which resolves auth via
// requireAuth() (the only path that enforces MFA/AAL2 on hosted) and the active
// company via getActiveCompanyId(). Mock those, not createClient/getUser.
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
import { DELETE } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
// ── Test data ────────────────────────────────────────────────
const mockUser = { id: 'user-1', email: 'test@test.se' }
// ── Tests ────────────────────────────────────────────────────
describe('DELETE /api/salary/runs/[id]', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: {} as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when salary run not found', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: null, error: { message: 'Not found' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toContain('hittades inte')
})
it('returns 400 when the run is not a draft (booked must be storno-reversed)', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: { id: 'run-1', status: 'booked' } }, // salary_runs lookup
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('utkast')
})
it('deletes a draft run and returns success', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: supabase as never,
error: null,
})
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
{ data: null }, // salary_runs delete (cascade handles children)
])
const request = createMockRequest('/api/salary/runs/run-1', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(response)
expect(status).toBe(200)
expect(body.data).toEqual({ id: 'run-1', deleted: true })
})
})
+142 -107
View File
@@ -1,123 +1,158 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { withRouteContext } from '@/lib/api/with-route-context'
import { formatRedovisare } from '@/lib/skatteverket/format'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
ensureInitialized()
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.runs.get',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const companyId = await requireCompanyId(supabase, user.id)
const { data: run, error } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
const { data: run, error } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
// Load employees with line items
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.order('created_at')
// Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this
// in the run payload so the client doesn't need a second round-trip just to
// build extension URLs. Quietly null when the org number isn't set yet.
let arbetsgivare: string | null = null
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (settings?.org_number && settings?.entity_type) {
try {
arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
} catch {
arbetsgivare = null
if (error || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
}
return NextResponse.json({
data: {
...run,
arbetsgivare,
employees: (employees || []).map(emp => ({
...emp,
employee: emp.employee ? {
...emp.employee,
personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)),
} : null,
})),
},
})
}
// Load employees with line items
const { data: employees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.order('created_at')
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
// Only allow updates on draft runs
const { data: run, error: fetchError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
}
const body = await request.json()
const allowedFields = ['payment_date', 'voucher_series', 'notes']
const updates: Record<string, unknown> = {}
for (const field of allowedFields) {
if (body[field] !== undefined) {
updates[field] = body[field]
// Resolve Skatteverket arbetsgivare ID for AGI submission. We surface this
// in the run payload so the client doesn't need a second round-trip just to
// build extension URLs. Quietly null when the org number isn't set yet.
let arbetsgivare: string | null = null
const { data: settings } = await supabase
.from('company_settings')
.select('org_number, entity_type')
.eq('company_id', companyId)
.maybeSingle()
if (settings?.org_number && settings?.entity_type) {
try {
arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
} catch {
arbetsgivare = null
}
}
}
const { data: updated, error } = await supabase
.from('salary_runs')
.update(updates)
.eq('id', id)
.select()
.single()
return NextResponse.json({
data: {
...run,
arbetsgivare,
employees: (employees || []).map(emp => ({
...emp,
employee: emp.employee ? {
...emp.employee,
personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)),
} : null,
})),
},
})
},
)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.runs.update',
async (request, { supabase, companyId }, { params }) => {
const { id } = await params
return NextResponse.json({ data: updated })
}
// Only allow updates on draft runs
const { data: run, error: fetchError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
}
const body = await request.json()
const allowedFields = ['payment_date', 'voucher_series', 'notes']
const updates: Record<string, unknown> = {}
for (const field of allowedFields) {
if (body[field] !== undefined) {
updates[field] = body[field]
}
}
const { data: updated, error } = await supabase
.from('salary_runs')
.update(updates)
.eq('id', id)
.eq('company_id', companyId)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: updated })
},
{ requireWrite: true },
)
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.runs.delete',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
// Only draft runs can be deleted. Once a run reaches review/approved/paid/
// booked it carries compliance weight — a booked run created immutable
// verifikat (storno to undo, never delete). A draft has produced no journal
// entries and no AGI (the arbetsgivardeklaration is filed monthly from the
// booked/paid run, never from a draft), so removing it touches no posted
// accounting data.
const { data: run, error: fetchError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json(
{ error: 'Bara utkast kan raderas. En bokförd lönekörning måste vändas (storno).' },
{ status: 400 }
)
}
// salary_run_employees and their salary_line_items are removed via
// ON DELETE CASCADE. An agi_declarations row — never present on a draft —
// would block the delete via its RESTRICT FK, the safety net for the
// impossible case.
const { error } = await supabase
.from('salary_runs')
.delete()
.eq('id', id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { id, deleted: true } })
},
{ requireWrite: true },
)
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { parseJsonResponse } from '@/tests/helpers'
const mockSupabase = {
auth: { getUser: vi.fn() },
}
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/salary/tax-tables', () => ({
fetchKommunTaxRates: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { fetchKommunTaxRates } from '@/lib/salary/tax-tables'
const mockUser = { id: 'user-1', email: 'test@test.se' }
describe('GET /api/salary/tax-tables/kommuner', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
vi.mocked(fetchKommunTaxRates).mockResolvedValue([
{ kommun: 'Östersund', totalRate: 33.0, tableNumber: 33 },
{ kommun: 'Aronsjö', totalRate: 32.49, tableNumber: 32 },
{ kommun: 'Stockholm', totalRate: 29.82, tableNumber: 30 },
])
})
it('returns 401 when not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const request = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2030')
const response = await GET(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(401)
})
it('returns the kommun list sorted by Swedish collation', async () => {
const request = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2031')
const response = await GET(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
data: { year: number; kommuner: { kommun: string; tableNumber: number }[] }
}>(response)
expect(status).toBe(200)
expect(body.data.year).toBe(2031)
expect(body.data.kommuner.map((k) => k.kommun)).toEqual(['Aronsjö', 'Stockholm', 'Östersund'])
expect(body.data.kommuner[1]).toMatchObject({ kommun: 'Stockholm', tableNumber: 30 })
})
it('falls back to the current year for a non-numeric year (no NaN reaches Skatteverket/cache)', async () => {
const currentYear = new Date().getFullYear()
const request = new Request('http://localhost/api/salary/tax-tables/kommuner?year=not-a-year')
const response = await GET(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { year: number } }>(response)
expect(status).toBe(200)
expect(body.data.year).toBe(currentYear)
expect(fetchKommunTaxRates).toHaveBeenCalledWith(currentYear)
})
it('caches per year — a second request for the same year does not refetch', async () => {
const req1 = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2032')
await GET(req1, { params: Promise.resolve({}) })
const req2 = new Request('http://localhost/api/salary/tax-tables/kommuner?year=2032')
await GET(req2, { params: Promise.resolve({}) })
expect(fetchKommunTaxRates).toHaveBeenCalledTimes(1)
expect(fetchKommunTaxRates).toHaveBeenCalledWith(2032)
})
})
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { fetchKommunTaxRates } from '@/lib/salary/tax-tables'
/**
* GET /api/salary/tax-tables/kommuner?year=2026
*
* Returns every Swedish municipality mapped to its derived skattetabell number
* (29-42), sourced from Skatteverket's open-data API via fetchKommunTaxRates().
* Powers the folkbokföringskommun picker on the employee form so the user only
* has to choose their town — the tax table derives itself.
*
* The mapping changes once a year, so the result is cached in-process per year.
*/
interface KommunRate {
kommun: string
totalRate: number
tableNumber: number
}
const cache = new Map<number, KommunRate[]>()
export const GET = withRouteContext('salary.tax_tables.kommuner', async (request) => {
const { searchParams } = new URL(request.url)
// A non-numeric or out-of-range year would otherwise reach Skatteverket as
// 'år': 'NaN' and stick a NaN key in the module cache for the process
// lifetime. Clamp to a sane window and fall back to the current year.
const parsedYear = parseInt(searchParams.get('year') || '', 10)
const year =
Number.isFinite(parsedYear) && parsedYear >= 2000 && parsedYear <= 2100
? parsedYear
: new Date().getFullYear()
let kommuner = cache.get(year)
if (!kommuner) {
const list = await fetchKommunTaxRates(year)
list.sort((a, b) => a.kommun.localeCompare(b.kommun, 'sv'))
kommuner = list
cache.set(year, list)
}
return NextResponse.json({ data: { year, kommuner } })
})