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:
co-authored by
Claude Opus 4.8
parent
bc61862e76
commit
0ca9c25aba
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
@@ -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 } })
|
||||
})
|
||||
Reference in New Issue
Block a user