diff --git a/.gitattributes b/.gitattributes index e0ae0cd5..6672a4ff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,10 @@ docker/crontab.* text eol=lf # taxonomy:check` (CI); keep LF on Windows checkouts so the local check # matches what the generator writes. lib/bokslut/ixbrl/taxonomy/generated/*.json text eol=lf linguist-generated=true + +# The extension registry is rewritten with LF by `setup:extensions` on every +# dev/build, and Vitest rewrites snapshots with LF. With core.autocrlf=true +# git would expect CRLF and flag these as phantom modifications; pin LF so +# the working tree matches what the generators write. +lib/extensions/_generated/** text eol=lf linguist-generated=true +*.snap text eol=lf linguist-generated=true diff --git a/DECISIONS.md b/DECISIONS.md index d47f4603..af6430c6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -37,3 +37,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-07] Sjalvfaktura via the public invoice API (support request: "kan inte hitta det i docs"): exposed the RECEIVED self-billing invoice (mottagen sjalvfaktura, ML 17 kap 15: a SALE, Debit 1510 / Credit 30xx+26xx) on the public API as an OPTIONAL is_self_billed flag on the existing POST /api/v1/.../invoices endpoint (+ external_invoice_number, self_billing_agreement_ref, received_date), on Emil's explicit call ("configure sjalvfaktura when creating an invoice ... optional field") over a dedicated /invoices/self-billed v1 endpoint. First built the WRONG interpretation (issue a self-invoice on a SUPPLIER's behalf = a purchase, on the supplier-invoice stack, new SJ- series + PDF + MCP tool + migration 20260706130000); reverted all of it after Emil clarified the user meant the existing seller/received feature, which already existed internally (/api/invoices/self-billed, cookie-session) but was absent from the public API. No migration for B (is_self_billed/external_invoice_number/self_billing_agreement_ref columns already exist from 20260613100000). Extracted lib/invoices/self-billed-sale.ts (resolveSelfBilledSaleDraft + createSelfBilledSaleInvoice) as the single implementation and refactored the internal route to a thin wrapper over it, so the dashboard "Sjalvfaktura" tab and the API can't drift (internal route test still green, 8/8). Fields added as PLAIN optionals (no z.superRefine) so UpdateInvoiceSchema = CreateInvoiceSchema.omit() keeps working (superRefine turns it into ZodEffects, which has no .omit); "external_invoice_number + received_date required when is_self_billed" enforced in the route instead. Documented the flag in the invoices.create registerEndpoint (description + pitfall) since the whole ask was "can't find it in docs". No git touched; nothing deployed. [2026-07-07] Compliance-review sweep on add/api-and-invoice. FIXED: (1) recurring cron double-send window: replaced the read-only "already ran today" check with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly invocations can't both spawn from the same stale batch row; (2) recurring schedule edit dialog could PATCH auto_send=true for a customer with no email (disabled-but-checked box, async customer load after defaultValues): added a useEffect that forces auto_send=false whenever the effective customer lacks an email, mirroring the manual-select guard; (3) momsdeklaration manual-filing rows: switched Math.round -> Math.trunc (öretal faller bort per SFL 22 kap 1 §; in-repo swedish-sru-filing skill confirms öre are DROPPED not rounded, and this now matches the SRU income-tax path). This narrows the earlier [2026-07-06] "whole-krona rounding is deliberate" decision: whole-krona stays deliberate, but the öre handling is truncation, not round-to-nearest. DECLINED (with rationale): self-billed "Självfakturering" notation + own-voucher-number findings are misdirected: createSelfBilledSaleInvoice books a RECEIVED självfaktura (the counterparty issues the document, we render no PDF), and numberOverride only sets the human-readable verifikat description/tag, not the sequential verifikationsnummer (still assigned atomically by commit_journal_entry). Bank-account mod11 padStart padding is correct: leading zeros contribute 0 and preserve the right-aligned check-digit weighting (already tested vs a real Forex account). AGI/KU10 employer name kept as [2026-07-06] (särskilt-företagsnamn caveat already accepted). Livsmedel 12%->6% (April 2026) reactivation guard declined as a description-keyword food-detector: violates the determinism/never-guess principle, and the one-time pause-all-on-deploy already forces conscious reactivation as the natural checkpoint. [2026-07-07] bank_file_imports dedup key widened (user_id,file_hash) -> (company_id,file_hash), migration 20260707130000 applied to prod: the old key made a same-user re-import of the same file into a second company resolve the upsert onto the first company's row, which RLS rightly blocked (42501). Mirrors what 20260330130000 did for sie_imports; v1 route's BANK_IMPORT_DUPLICATE_OTHER_COMPANY pre-check removed as obsolete (structured-errors entry kept for API compat). +[2026-07-07] A1 route-auth campaign migrated 118/119 routes off hand-rolled supabase.auth.getUser() onto withRouteContext (enforces MFA/AAL2); ratchet baseline lowered 119->1. +[2026-07-07] mcp-oauth/authorize left on the raw-auth baseline (count 1, not 0): it renders an HTML consent page and issues 303 redirects, which withRouteContext (JSON envelopes + company-context gate) cannot express; MFA is enforced instead via a route-local requireAal2() step-up (AAL1 sessions redirect to /mfa/verify) since consent mints a long-lived API key that bypasses MFA thereafter. +[2026-07-07] Added { requireWrite: true } to POST /api/reports/vat-declaration/rc-basis-gaps/fix: it calls correctEntry() (storno of a posted entry) and was reachable by viewer-role members. +[2026-07-07] Two GET routes kept requireWrite (salary/runs payment bg-lb/pain001, skatteverket payment-file): they persist a *_file_generated_at stamp and previously gated viewers, so dropping the gate would regress write-protection. diff --git a/app/api/account/password/__tests__/route.test.ts b/app/api/account/password/__tests__/route.test.ts index aedd8234..aa985069 100644 --- a/app/api/account/password/__tests__/route.test.ts +++ b/app/api/account/password/__tests__/route.test.ts @@ -1,15 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse } from '@/tests/helpers' vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), createServiceClient: vi.fn(), })) -import { createClient, createServiceClient } from '@/lib/supabase/server' +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +import { createServiceClient } from '@/lib/supabase/server' import { POST } from '../route' -const mockCreateClient = vi.mocked(createClient) const mockCreateServiceClient = vi.mocked(createServiceClient) type AuthMetadata = Record @@ -23,13 +27,18 @@ function mockUserClient(opts: { error: opts.updateUserError ?? null, }) - mockCreateClient.mockResolvedValue({ - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }), - updateUser, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = { auth: { updateUser } } as any + + if (opts.user) { + requireAuthMock.mockResolvedValue({ user: opts.user, supabase, error: null }) + } else { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + } return { updateUser } } diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts index 7badedb7..62a3d599 100644 --- a/app/api/account/password/route.ts +++ b/app/api/account/password/route.ts @@ -1,6 +1,7 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' +import { requireAuth } from '@/lib/auth/require-auth' import { validateBody } from '@/lib/api/validate' import { createLogger } from '@/lib/logger' @@ -49,14 +50,8 @@ const SetPasswordSchema = z.object({ * more time, but a retry will re-flip the flag. */ export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { user, supabase, error: authError } = await requireAuth() + if (authError) return authError const result = await validateBody(request, SetPasswordSchema) if (!result.success) return result.response diff --git a/app/api/agent/invoke/route.ts b/app/api/agent/invoke/route.ts index 380d7ce0..b85300a1 100644 --- a/app/api/agent/invoke/route.ts +++ b/app/api/agent/invoke/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' import { ensureInitialized } from '@/lib/init' +import { requireAuth } from '@/lib/auth/require-auth' import { getActiveCompanyId } from '@/lib/company/context' import { getIntent } from '@/lib/agent/intents/registry' import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent' @@ -68,9 +69,8 @@ const BodySchema = z.object({ // // Plan ref: dev_docs/specialized-agent-plan.md §9 (chat loop). export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, supabase, error } = await requireAuth() + if (error) return error // Generous per-user rate limit: bounds runaway Bedrock spend (loop-firing // sessions). Fails open on infra error. diff --git a/app/api/agent/onboarding/stream/route.ts b/app/api/agent/onboarding/stream/route.ts index 92d492dd..943115bb 100644 --- a/app/api/agent/onboarding/stream/route.ts +++ b/app/api/agent/onboarding/stream/route.ts @@ -1,6 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' +import { requireAuth } from '@/lib/auth/require-auth' import { getActiveCompanyId } from '@/lib/company/context' import { guardSandbox } from '@/lib/sandbox/guard' import { requireCapability } from '@/lib/entitlements/has-capability' @@ -64,9 +64,8 @@ interface ProfilePayload { // // Response: application/x-ndjson, one JSON event per line. export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, supabase, error } = await requireAuth() + if (error) return error // Generous per-user rate limit: bounds reload-spam of the onboarding build // (each run fires 2 LLM calls). Fails open on infra error. diff --git a/app/api/documents/[id]/__tests__/route.test.ts b/app/api/documents/[id]/__tests__/route.test.ts index 6924eda3..e26df263 100644 --- a/app/api/documents/[id]/__tests__/route.test.ts +++ b/app/api/documents/[id]/__tests__/route.test.ts @@ -7,8 +7,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -34,7 +36,7 @@ beforeEach(() => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) // Reset write-permission mock to default ok vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) }) @@ -45,7 +47,11 @@ function makeReq() { describe('DELETE /api/documents/[id]', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' })) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) diff --git a/app/api/documents/[id]/extraction-status/route.ts b/app/api/documents/[id]/extraction-status/route.ts index e2cc189e..8849c517 100644 --- a/app/api/documents/[id]/extraction-status/route.ts +++ b/app/api/documents/[id]/extraction-status/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' // GET /api/documents/:id/extraction-status // @@ -18,48 +17,43 @@ import { requireCompanyId } from '@/lib/company/context' // stays untouched indefinitely). Client times out and shows // a quiet fallback. We don't distinguish this from running // server-side: the client decides based on elapsed time. -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - 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 }> }>( + 'document.extraction_status', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const companyId = await requireCompanyId(supabase, user.id) - const { id } = await params + const { data, error } = await supabase + .from('document_attachments') + .select('id, extracted_at, extracted_data, extraction_model') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() - const { data, error } = await supabase - .from('document_attachments') - .select('id, extracted_at, extracted_data, extraction_model') - .eq('id', id) - .eq('company_id', companyId) - .maybeSingle() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const extractedAt = data.extracted_at as string | null + const extractedData = data.extracted_data as Record | null + const model = data.extraction_model as string | null - const extractedAt = data.extracted_at as string | null - const extractedData = data.extracted_data as Record | null - const model = data.extraction_model as string | null + let status: 'running' | 'succeeded' | 'failed' | 'unsupported' + if (!extractedAt) { + status = 'running' + } else if (extractedData) { + status = 'succeeded' + } else if (model?.startsWith('skipped:')) { + status = 'unsupported' + } else { + status = 'failed' + } - let status: 'running' | 'succeeded' | 'failed' | 'unsupported' - if (!extractedAt) { - status = 'running' - } else if (extractedData) { - status = 'succeeded' - } else if (model?.startsWith('skipped:')) { - status = 'unsupported' - } else { - status = 'failed' + return NextResponse.json({ + data: { + id: data.id, + status, + extracted_at: extractedAt, + extraction_model: model, + }, + }) } - - return NextResponse.json({ - data: { - id: data.id, - status, - extracted_at: extractedAt, - extraction_model: model, - }, - }) -} +) diff --git a/app/api/documents/[id]/route.ts b/app/api/documents/[id]/route.ts index c3f649dd..d3067773 100644 --- a/app/api/documents/[id]/route.ts +++ b/app/api/documents/[id]/route.ts @@ -1,8 +1,6 @@ -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 { deleteDocument } from '@/lib/core/documents/document-service' import { eventBus } from '@/lib/events' @@ -12,62 +10,52 @@ ensureInitialized() * GET /api/documents/:id * Fetch document metadata + signed download URL (60 min expiry) */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.get', + async (_request, { supabase, companyId, user }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + // Fetch document record + const { data: doc, error: docError } = await supabase + .from('document_attachments') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (docError || !doc) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } + + // Create signed download URL (60 minutes) + const { data: signedUrl, error: signError } = await supabase.storage + .from('documents') + .createSignedUrl(doc.storage_path, 3600) + + if (signError) { + return NextResponse.json( + { error: `Failed to create download URL: ${signError.message}` }, + { status: 500 } + ) + } + + await eventBus.emit({ + type: 'document.accessed', + payload: { + document: { id: doc.id, file_name: doc.file_name }, + userId: user.id, + companyId, + }, + }) + + return NextResponse.json({ + data: { + ...doc, + download_url: signedUrl.signedUrl, + }, + }) } - - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - // Fetch document record - const { data: doc, error: docError } = await supabase - .from('document_attachments') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (docError || !doc) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - // Create signed download URL (60 minutes) - const { data: signedUrl, error: signError } = await supabase.storage - .from('documents') - .createSignedUrl(doc.storage_path, 3600) - - if (signError) { - return NextResponse.json( - { error: `Failed to create download URL: ${signError.message}` }, - { status: 500 } - ) - } - - await eventBus.emit({ - type: 'document.accessed', - payload: { - document: { id: doc.id, file_name: doc.file_name }, - userId: user.id, - companyId, - }, - }) - - return NextResponse.json({ - data: { - ...doc, - download_url: signedUrl.signedUrl, - }, - }) -} +) /** * DELETE /api/documents/:id @@ -76,37 +64,26 @@ export async function GET( * BFL 7 kap 2§ and must be retained for 7 years. For linked docs the caller * should use POST /api/documents/:id/versions to supersede via a new version. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.delete', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const result = await deleteDocument(supabase, companyId, id) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!result.ok) { + return NextResponse.json({ error: result.message }, { status: result.status }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - try { - const result = await deleteDocument(supabase, companyId, id) - - if (!result.ok) { - return NextResponse.json({ error: result.message }, { status: result.status }) + return NextResponse.json({ data: { id: result.document.id, deleted: true } }) + } catch (error) { + console.error('[documents/DELETE] Failed to delete document:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete document' }, + { status: 500 } + ) } - - return NextResponse.json({ data: { id: result.document.id, deleted: true } }) - } catch (error) { - console.error('[documents/DELETE] Failed to delete document:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete document' }, - { status: 500 } - ) - } -} + }, + { requireWrite: true } +) diff --git a/app/api/documents/[id]/verify/__tests__/route.test.ts b/app/api/documents/[id]/verify/__tests__/route.test.ts new file mode 100644 index 00000000..08aef473 --- /dev/null +++ b/app/api/documents/[id]/verify/__tests__/route.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +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 }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const mockVerifyIntegrity = vi.fn() +vi.mock('@/lib/core/documents/document-service', () => ({ + verifyIntegrity: (...args: unknown[]) => mockVerifyIntegrity(...args), +})) + +import { POST } from '../route' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function makeReq() { + return new Request('http://localhost/api/documents/doc-1/verify', { method: 'POST' }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) +}) + +describe('POST /api/documents/[id]/verify', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 403 when caller has read-only role', async () => { + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json( + { error: 'Du har endast läsbehörighet i detta företag.' }, + { status: 403 }, + ), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(mockVerifyIntegrity).not.toHaveBeenCalled() + }) + + it('returns the integrity result on success', async () => { + mockVerifyIntegrity.mockResolvedValue({ verified: true, hash_matches: true }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status, body } = await parseJsonResponse<{ data: { verified: boolean } }>(res) + expect(status).toBe(200) + expect(body.data.verified).toBe(true) + expect(mockVerifyIntegrity).toHaveBeenCalledWith(mockSupabase, 'company-1', 'doc-1') + }) +}) diff --git a/app/api/documents/[id]/verify/route.ts b/app/api/documents/[id]/verify/route.ts index eef09825..e1da1c3e 100644 --- a/app/api/documents/[id]/verify/route.ts +++ b/app/api/documents/[id]/verify/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { verifyIntegrity } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -11,34 +9,22 @@ ensureInitialized() * POST /api/documents/:id/verify * Verify document integrity by re-computing SHA-256 and comparing */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.verify', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + try { + const result = await verifyIntegrity(supabase, companyId, id) - 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) - - const { id } = await params - - try { - const result = await verifyIntegrity(supabase, companyId, id) - - return NextResponse.json({ data: result }) - } catch (error) { - console.error('[documents/verify/POST] Verification failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Verification failed' }, - { status: 500 } - ) - } -} + return NextResponse.json({ data: result }) + } catch (error) { + console.error('[documents/verify/POST] Verification failed:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Verification failed' }, + { status: 500 } + ) + } + }, + { requireWrite: true } +) diff --git a/app/api/documents/[id]/versions/__tests__/route.test.ts b/app/api/documents/[id]/versions/__tests__/route.test.ts new file mode 100644 index 00000000..332f2063 --- /dev/null +++ b/app/api/documents/[id]/versions/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +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 }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const mockCreateNewVersion = vi.fn() +const mockValidateDocumentFile = vi.fn() +vi.mock('@/lib/core/documents/document-service', () => ({ + createNewVersion: (...args: unknown[]) => mockCreateNewVersion(...args), + validateDocumentFile: (...args: unknown[]) => mockValidateDocumentFile(...args), +})) + +import { POST } from '../route' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function makeReq(withFile = true) { + const form = new FormData() + if (withFile) { + form.append('file', new File(['content'], 'kvitto.pdf', { type: 'application/pdf' })) + } + return new Request('http://localhost/api/documents/doc-1/versions', { + method: 'POST', + body: form, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) + mockValidateDocumentFile.mockReturnValue(null) +}) + +describe('POST /api/documents/[id]/versions', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 403 when caller has read-only role', async () => { + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json( + { error: 'Du har endast läsbehörighet i detta företag.' }, + { status: 403 }, + ), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(mockCreateNewVersion).not.toHaveBeenCalled() + }) + + it('returns 400 when no file is provided', async () => { + const res = await POST(makeReq(false), createMockRouteParams({ id: 'doc-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(400) + expect(body.error).toBe('No file provided') + }) + + it('creates a new version on the happy path', async () => { + mockCreateNewVersion.mockResolvedValue({ id: 'doc-2', version: 2 }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status, body } = await parseJsonResponse<{ data: { id: string; version: number } }>(res) + expect(status).toBe(200) + expect(body.data).toEqual({ id: 'doc-2', version: 2 }) + expect(mockCreateNewVersion).toHaveBeenCalledWith( + mockSupabase, + 'user-1', + 'doc-1', + expect.objectContaining({ name: 'kvitto.pdf', type: 'application/pdf' }), + ) + }) +}) diff --git a/app/api/documents/[id]/versions/route.ts b/app/api/documents/[id]/versions/route.ts index c8a2d4cc..ab3bdeab 100644 --- a/app/api/documents/[id]/versions/route.ts +++ b/app/api/documents/[id]/versions/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -14,102 +12,80 @@ ensureInitialized() * Accepts multipart/form-data with: * - file: The new version file */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.versions.create', + async (request, { supabase, user }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + try { + const formData = await request.formData() + const file = formData.get('file') as File | null - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validationError = validateDocumentFile({ size: file.size, type: file.type }) + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const buffer = await file.arrayBuffer() - const { id } = await params + const newVersion = await createNewVersion(supabase, user.id, id, { + name: file.name, + buffer, + type: file.type, + }) - try { - const formData = await request.formData() - const file = formData.get('file') as File | null - - if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + return NextResponse.json({ data: newVersion }) + } catch (error) { + console.error('[documents/versions/POST] Version creation failed:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Version creation failed' }, + { status: 500 } + ) } - - const validationError = validateDocumentFile({ size: file.size, type: file.type }) - if (validationError) { - return NextResponse.json({ error: validationError }, { status: 400 }) - } - - const buffer = await file.arrayBuffer() - - const newVersion = await createNewVersion(supabase, user.id, id, { - name: file.name, - buffer, - type: file.type, - }) - - return NextResponse.json({ data: newVersion }) - } catch (error) { - console.error('[documents/versions/POST] Version creation failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Version creation failed' }, - { status: 500 } - ) - } -} + }, + { requireWrite: true } +) /** * GET /api/documents/:id/versions * List all versions in the document chain */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.versions.list', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + // First, check if the document belongs to the company + const { data: doc, error: docError } = await supabase + .from('document_attachments') + .select('id, original_id') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (docError || !doc) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } + + // The root document is either the original_id or the document itself + const rootId = doc.original_id || doc.id + + // Fetch all versions in the chain + const { data: versions, error: versionsError } = await supabase + .from('document_attachments') + .select('*') + .eq('company_id', companyId) + .or(`id.eq.${rootId},original_id.eq.${rootId}`) + .order('version', { ascending: true }) + + if (versionsError) { + return NextResponse.json({ error: versionsError.message }, { status: 500 }) + } + + return NextResponse.json({ data: versions }) } - - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - // First, check if the document belongs to the company - const { data: doc, error: docError } = await supabase - .from('document_attachments') - .select('id, original_id') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (docError || !doc) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - // The root document is either the original_id or the document itself - const rootId = doc.original_id || doc.id - - // Fetch all versions in the chain - const { data: versions, error: versionsError } = await supabase - .from('document_attachments') - .select('*') - .eq('company_id', companyId) - .or(`id.eq.${rootId},original_id.eq.${rootId}`) - .order('version', { ascending: true }) - - if (versionsError) { - return NextResponse.json({ error: versionsError.message }, { status: 500 }) - } - - return NextResponse.json({ data: versions }) -} +) diff --git a/app/api/events/__tests__/route.test.ts b/app/api/events/__tests__/route.test.ts index a56ef766..b2ee0adf 100644 --- a/app/api/events/__tests__/route.test.ts +++ b/app/api/events/__tests__/route.test.ts @@ -1,14 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, createQueuedMockSupabase, } from '@/tests/helpers' -// Mock supabase server (session auth) +// Mock session auth (requireAuth enforces MFA; returns the request-scoped client) const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -52,12 +55,16 @@ describe('GET /api/events', () => { vi.clearAllMocks() reset() mockExtractBearerToken.mockReturnValue(null) - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) it('returns 401 when not authenticated', async () => { mockExtractBearerToken.mockReturnValue(null) - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/events') const response = await GET(request) diff --git a/app/api/events/route.ts b/app/api/events/route.ts index f96a79d4..f7b1fd97 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,5 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' import { validateQuery } from '@/lib/api/validate' import { EventsQuerySchema } from '@/lib/api/schemas' @@ -39,12 +39,12 @@ export async function GET(request: Request) { keyCompanyId = authResult.companyId supabase = createServiceClientNoCookies() } else { - supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - userId = user.id + // Session auth: requireAuth enforces MFA (AAL2) on hosted, unlike a bare + // getUser call which skips the assurance-level check. + const auth = await requireAuth() + if (auth.error) return auth.error + supabase = auth.supabase + userId = auth.user.id } // Session auth resolves the active company; API-key auth uses the key's bound company. diff --git a/app/api/extensions/[sector]/[slug]/data/__tests__/route.test.ts b/app/api/extensions/[sector]/[slug]/data/__tests__/route.test.ts new file mode 100644 index 00000000..05292ffa --- /dev/null +++ b/app/api/extensions/[sector]/[slug]/data/__tests__/route.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, POST, DELETE } from '../route' + +const params = () => createMockRouteParams({ sector: 'general', slug: 'demo' }) + +describe('/api/extensions/[sector]/[slug]/data', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('GET lists extension data', async () => { + enqueue({ data: [{ key: 'a', value: 1 }] }) + + const response = await GET(createMockRequest('/api/extensions/general/demo/data'), params()) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + }) + + it('POST returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { key: 'k', value: 1 } }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('POST returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { key: 'k', value: 1 } }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('POST returns 400 when key is missing', async () => { + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { value: 1 } }), + params(), + ) + expect(response.status).toBe(400) + }) + + it('POST upserts a key/value (happy path)', async () => { + enqueue({ data: { key: 'k', value: 42 }, error: null }) + + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { key: 'k', value: 42 } }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { value: number } }>(response) + + expect(status).toBe(200) + expect(body.data.value).toBe(42) + }) + + it('DELETE returns 400 without a key query param', async () => { + const response = await DELETE( + createMockRequest('/api/extensions/general/demo/data', { method: 'DELETE' }), + params(), + ) + expect(response.status).toBe(400) + }) + + it('DELETE removes a key (happy path)', async () => { + enqueue({ data: null, error: null }) + + const response = await DELETE( + createMockRequest('/api/extensions/general/demo/data', { + method: 'DELETE', + searchParams: { key: 'k' }, + }), + params(), + ) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/app/api/extensions/[sector]/[slug]/data/route.ts b/app/api/extensions/[sector]/[slug]/data/route.ts index 117d8271..3c05fd7f 100644 --- a/app/api/extensions/[sector]/[slug]/data/route.ts +++ b/app/api/extensions/[sector]/[slug]/data/route.ts @@ -1,21 +1,10 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.data.get', + async (request, { supabase, companyId }, { params }) => { const { sector, slug } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) const extensionId = `${sector}/${slug}` @@ -43,24 +32,13 @@ export async function GET( } return NextResponse.json({ data }) -} + }, +) -export async function POST( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.data.set', + async (request, { supabase, user, companyId }, { params }) => { const { sector, slug } = 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) const body = await request.json() const { key, value } = body @@ -91,24 +69,14 @@ export async function POST( } return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) -export async function DELETE( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const DELETE = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.data.delete', + async (request, { supabase, companyId }, { params }) => { const { sector, slug } = 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) const { searchParams } = new URL(request.url) const key = searchParams.get('key') @@ -131,4 +99,6 @@ export async function DELETE( } return NextResponse.json({ success: true }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/extensions/[sector]/[slug]/settings/__tests__/route.test.ts b/app/api/extensions/[sector]/[slug]/settings/__tests__/route.test.ts new file mode 100644 index 00000000..84c2bc0d --- /dev/null +++ b/app/api/extensions/[sector]/[slug]/settings/__tests__/route.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, PATCH } from '../route' + +const params = () => createMockRouteParams({ sector: 'general', slug: 'demo' }) + +describe('/api/extensions/[sector]/[slug]/settings', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('GET returns the stored settings value', async () => { + enqueue({ data: { value: { theme: 'dark' } } }) + + const response = await GET(createMockRequest('/api/extensions/general/demo/settings'), params()) + const { status, body } = await parseJsonResponse<{ data: { theme: string } }>(response) + + expect(status).toBe(200) + expect(body.data.theme).toBe('dark') + }) + + it('PATCH returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await PATCH( + createMockRequest('/api/extensions/general/demo/settings', { method: 'PATCH', body: { b: 2 } }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('PATCH returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await PATCH( + createMockRequest('/api/extensions/general/demo/settings', { method: 'PATCH', body: { b: 2 } }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('PATCH merges and upserts settings (happy path)', async () => { + enqueue({ data: { value: { a: 1 } } }) // existing settings + enqueue({ data: { value: { a: 1, b: 2 } }, error: null }) // upsert + + const response = await PATCH( + createMockRequest('/api/extensions/general/demo/settings', { method: 'PATCH', body: { b: 2 } }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { a: number; b: number } }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ a: 1, b: 2 }) + }) +}) diff --git a/app/api/extensions/[sector]/[slug]/settings/route.ts b/app/api/extensions/[sector]/[slug]/settings/route.ts index 1d8d5c7c..fcf30146 100644 --- a/app/api/extensions/[sector]/[slug]/settings/route.ts +++ b/app/api/extensions/[sector]/[slug]/settings/route.ts @@ -1,21 +1,10 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET( - _request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.settings.get', + async (_request, { supabase, companyId }, { params }) => { const { sector, slug } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) const extensionId = `${sector}/${slug}` @@ -28,24 +17,13 @@ export async function GET( .single() return NextResponse.json({ data: data?.value ?? {} }) -} + }, +) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const PATCH = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.settings.update', + async (request, { supabase, user, companyId }, { params }) => { const { sector, slug } = 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) const body = await request.json() const extensionId = `${sector}/${slug}` @@ -81,4 +59,6 @@ export async function PATCH( } return NextResponse.json({ data: data.value }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/extensions/skatteverket/skattekonto/drift/route.ts b/app/api/extensions/skatteverket/skattekonto/drift/route.ts index 0659da3f..3d5ec084 100644 --- a/app/api/extensions/skatteverket/skattekonto/drift/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/drift/route.ts @@ -1,15 +1,11 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { createExtensionContext } from '@/lib/extensions/context-factory' import { computeSkattekontoDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift' -import { createLogger } from '@/lib/logger' ensureInitialized() -const log = createLogger('skattekonto-drift-route') - /** * GET /api/extensions/skatteverket/skattekonto/drift * @@ -23,22 +19,15 @@ const log = createLogger('skattekonto-drift-route') * log line gives an auditable record without overrunning the 30-day event * log retention (SOC 2 CC8.1, ISO 27001 A.8.15). */ -export async function GET(_request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'skatteverket.skattekonto.drift', + async (_request, { supabase, user, companyId, log, requestId }) => { + const ctx = createExtensionContext(supabase, user.id, companyId, 'skatteverket', requestId) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - const ctx = createExtensionContext(supabase, user.id, companyId, 'skatteverket') - - const drift = await computeSkattekontoDrift(ctx) - log.info('skattekonto drift snapshot accessed', { - userId: user.id, - companyId, - hasDrift: drift !== null, - }) - return NextResponse.json({ data: drift }) -} + const drift = await computeSkattekontoDrift(ctx) + log.info('skattekonto drift snapshot accessed', { + hasDrift: drift !== null, + }) + return NextResponse.json({ data: drift }) + }, +) diff --git a/app/api/import/sie/[id]/__tests__/route.test.ts b/app/api/import/sie/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..5ed39617 --- /dev/null +++ b/app/api/import/sie/[id]/__tests__/route.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for GET/DELETE /api/import/sie/[id]. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, the completed-import guard, and the + * happy-path delete of a failed import. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, DELETE } from '../route' + +const routeParams = () => createMockRouteParams({ id: 'import-1' }) + +describe('GET/DELETE /api/import/sie/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('DELETE returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(401) + }) + + it('DELETE returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(403) + }) + + it('DELETE refuses to delete a completed import (BFL retention)', async () => { + enqueue({ data: { status: 'completed' } }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(403) + expect(body.error).toContain('BFL 7 kap') + }) + + it('DELETE removes a failed import', async () => { + // 1st DB hit: status lookup. 2nd DB hit: the delete itself. + enqueue({ data: { status: 'failed' } }) + enqueue({ data: null }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) + + it('GET returns the import record', async () => { + enqueue({ data: { id: 'import-1', status: 'pending' } }) + + const response = await GET( + createMockRequest('/api/import/sie/import-1'), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('import-1') + }) +}) diff --git a/app/api/import/sie/[id]/route.ts b/app/api/import/sie/[id]/route.ts index dae70085..e00bd5e0 100644 --- a/app/api/import/sie/[id]/route.ts +++ b/app/api/import/sie/[id]/route.ts @@ -1,46 +1,33 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/import/sie/[id] * Get details of a specific SIE import */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'sie_import.get', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { - data: { user }, - } = await supabase.auth.getUser() + const { data, error } = await supabase + .from('sie_imports') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) + if (!data) { + return NextResponse.json({ error: 'Import not found' }, { status: 404 }) + } - const { data, error } = await supabase - .from('sie_imports') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - if (!data) { - return NextResponse.json({ error: 'Import not found' }, { status: 404 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) /** * DELETE /api/import/sie/[id] @@ -51,53 +38,40 @@ export async function GET( * without reversing entries would leave orphaned bookkeeping data, and deleting * both is prohibited under BFL 7 kap (7-year retention). */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'sie_import.delete', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { - data: { user }, - } = await supabase.auth.getUser() + // Check current status before deleting + const { data: importRecord } = await supabase + .from('sie_imports') + .select('status') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!importRecord) { + return NextResponse.json({ error: 'Import not found' }, { status: 404 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (importRecord.status === 'completed') { + return NextResponse.json({ + error: 'Slutförd import kan inte raderas. Importerade verifikationer ingår i räkenskapsinformationen (BFL 7 kap).', + }, { status: 403 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const { error } = await supabase + .from('sie_imports') + .delete() + .eq('id', id) + .eq('company_id', companyId) - // Check current status before deleting - const { data: importRecord } = await supabase - .from('sie_imports') - .select('status') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (!importRecord) { - return NextResponse.json({ error: 'Import not found' }, { status: 404 }) - } - - if (importRecord.status === 'completed') { - return NextResponse.json({ - error: 'Slutförd import kan inte raderas. Importerade verifikationer ingår i räkenskapsinformationen (BFL 7 kap).', - }, { status: 403 }) - } - - const { error } = await supabase - .from('sie_imports') - .delete() - .eq('id', id) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/create-accounts/__tests__/route.test.ts b/app/api/import/sie/create-accounts/__tests__/route.test.ts new file mode 100644 index 00000000..a045f92b --- /dev/null +++ b/app/api/import/sie/create-accounts/__tests__/route.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for POST /api/import/sie/create-accounts. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, empty-body validation (400), and the + * happy-path batch upsert. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +describe('POST /api/import/sie/create-accounts', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { accounts: [{ number: '1930', name: 'Företagskonto' }] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { accounts: [{ number: '1930', name: 'Företagskonto' }] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects an empty account list with 400', async () => { + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { accounts: [] }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Inga konton att skapa.') + }) + + it('upserts the accounts and reports how many were created', async () => { + // Single batch upsert returning the inserted account numbers. + enqueue({ data: [{ account_number: '1930' }, { account_number: '3001' }] }) + + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { + accounts: [ + { number: '1930', name: 'Företagskonto' }, + { number: '3001', name: 'Försäljning tjänster 25%' }, + ], + }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ success: boolean; created: number }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.created).toBe(2) + }) +}) diff --git a/app/api/import/sie/create-accounts/route.ts b/app/api/import/sie/create-accounts/route.ts index 86cc9464..de7f77ad 100644 --- a/app/api/import/sie/create-accounts/route.ts +++ b/app/api/import/sie/create-accounts/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import type { SIEAccount } from '@/lib/import/types' /** @@ -54,92 +52,81 @@ function getNormalBalance(accountType: string): 'debit' | 'credit' { * POST /api/import/sie/create-accounts * Create missing accounts from SIE file definitions */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'sie_import.create_accounts', + async (request, { supabase, user, companyId }) => { + try { + const body = await request.json() + const accounts: SIEAccount[] = body.accounts - 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) - - try { - const body = await request.json() - const accounts: SIEAccount[] = body.accounts - - if (!accounts || !Array.isArray(accounts) || accounts.length === 0) { - return NextResponse.json({ error: 'Inga konton att skapa.' }, { status: 400 }) - } - - // Prepare accounts for upsert (idempotent, safe to retry) - const accountsToUpsert = accounts.map(account => { - const accountClass = parseInt(account.number.charAt(0), 10) || 1 - const accountGroup = account.number.substring(0, 2) - const accountType = getAccountType(account.number) - const normalBalance = getNormalBalance(accountType) - - return { - user_id: user.id, - company_id: companyId, - account_number: account.number, - account_name: account.name, - account_class: accountClass, - account_group: accountGroup, - account_type: accountType, - normal_balance: normalBalance, - plan_type: 'full_bas', - is_active: true, - is_system_account: false, // User-created via import - sort_order: parseInt(account.number, 10) || 0, - } - }) - - // Upsert in batches of 100 to avoid timeout - // ignoreDuplicates skips rows that already exist (no update) - const batchSize = 100 - let totalCreated = 0 - - for (let i = 0; i < accountsToUpsert.length; i += batchSize) { - const batch = accountsToUpsert.slice(i, i + batchSize) - - const { data: upserted, error } = await supabase - .from('chart_of_accounts') - .upsert(batch, { - onConflict: 'company_id,account_number', - ignoreDuplicates: true, - count: 'exact', - }) - .select('account_number') - - if (error) { - console.error('Error upserting accounts batch:', error) - return NextResponse.json({ - error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`, - created: totalCreated, - }, { status: 500 }) + if (!accounts || !Array.isArray(accounts) || accounts.length === 0) { + return NextResponse.json({ error: 'Inga konton att skapa.' }, { status: 400 }) } - totalCreated += upserted?.length ?? batch.length + // Prepare accounts for upsert (idempotent, safe to retry) + const accountsToUpsert = accounts.map(account => { + const accountClass = parseInt(account.number.charAt(0), 10) || 1 + const accountGroup = account.number.substring(0, 2) + const accountType = getAccountType(account.number) + const normalBalance = getNormalBalance(accountType) + + return { + user_id: user.id, + company_id: companyId, + account_number: account.number, + account_name: account.name, + account_class: accountClass, + account_group: accountGroup, + account_type: accountType, + normal_balance: normalBalance, + plan_type: 'full_bas', + is_active: true, + is_system_account: false, // User-created via import + sort_order: parseInt(account.number, 10) || 0, + } + }) + + // Upsert in batches of 100 to avoid timeout + // ignoreDuplicates skips rows that already exist (no update) + const batchSize = 100 + let totalCreated = 0 + + for (let i = 0; i < accountsToUpsert.length; i += batchSize) { + const batch = accountsToUpsert.slice(i, i + batchSize) + + const { data: upserted, error } = await supabase + .from('chart_of_accounts') + .upsert(batch, { + onConflict: 'company_id,account_number', + ignoreDuplicates: true, + count: 'exact', + }) + .select('account_number') + + if (error) { + console.error('Error upserting accounts batch:', error) + return NextResponse.json({ + error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`, + created: totalCreated, + }, { status: 500 }) + } + + totalCreated += upserted?.length ?? batch.length + } + + return NextResponse.json({ + success: true, + created: totalCreated, + message: `Created ${totalCreated} new accounts`, + }) + + } catch (error) { + console.error('Create accounts error:', error) + return NextResponse.json( + { error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` }, + { status: 500 } + ) } - - return NextResponse.json({ - success: true, - created: totalCreated, - message: `Created ${totalCreated} new accounts`, - }) - - } catch (error) { - console.error('Create accounts error:', error) - return NextResponse.json( - { error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` }, - { status: 500 } - ) - } -} + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/mappings/__tests__/route.test.ts b/app/api/import/sie/mappings/__tests__/route.test.ts new file mode 100644 index 00000000..b882f13e --- /dev/null +++ b/app/api/import/sie/mappings/__tests__/route.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for GET/POST/PUT/DELETE /api/import/sie/mappings. + * + * Exercises the routes through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, validation (400), and happy paths. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const saveMappingsMock = vi.fn() +vi.mock('@/lib/import/sie-import', () => ({ + saveMappings: (...args: unknown[]) => saveMappingsMock(...args), +})) + +import { GET, POST, PUT, DELETE } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +describe('/api/import/sie/mappings', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + saveMappingsMock.mockResolvedValue(undefined) + }) + + it('POST returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings: [] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('POST returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings: [] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('POST rejects a non-array mappings payload with 400', async () => { + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings: 'not-an-array' }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Invalid mappings data') + }) + + it('POST saves the mappings', async () => { + const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }] + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'user-1', mappings) + }) + + it('GET lists the saved mappings', async () => { + enqueue({ data: [{ source_account: '1920', target_account: '1930' }] }) + + const response = await GET(createMockRequest('/api/import/sie/mappings'), emptyParams) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + }) + + it('PUT rejects a body missing targetAccount with 400', async () => { + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1920' }, + }) + + const response = await PUT(request, emptyParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('PUT upserts a single mapping', async () => { + enqueue({ data: { source_account: '1920', target_account: '1930' } }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1920', targetAccount: '1930' }, + }) + + const response = await PUT(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { target_account: string } }>(response) + + expect(status).toBe(200) + expect(body.data.target_account).toBe('1930') + }) + + it('DELETE returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/import/sie/mappings', { method: 'DELETE' }) + + const response = await DELETE(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('DELETE removes a specific mapping', async () => { + enqueue({ data: null }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'DELETE', + searchParams: { sourceAccount: '1920' }, + }) + + const response = await DELETE(request, emptyParams) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/app/api/import/sie/mappings/route.ts b/app/api/import/sie/mappings/route.ts index 3ffb70a0..4a38401a 100644 --- a/app/api/import/sie/mappings/route.ts +++ b/app/api/import/sie/mappings/route.ts @@ -1,175 +1,131 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { saveMappings } from '@/lib/import/sie-import' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { AccountMapping } from '@/lib/import/types' /** * GET /api/import/sie/mappings * Get all saved account mappings for the user */ -export async function GET() { - const supabase = await createClient() +export const GET = withRouteContext( + 'sie_import.mappings.list', + async (_request, { supabase, companyId }) => { + const { data, error } = await supabase + .from('sie_account_mappings') + .select('*') + .eq('company_id', companyId) + .order('source_account') - const { - data: { user }, - } = await supabase.auth.getUser() + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('sie_account_mappings') - .select('*') - .eq('company_id', companyId) - .order('source_account') - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) /** * POST /api/import/sie/mappings * Save account mappings (bulk upsert) */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'sie_import.mappings.save', + async (request, { supabase, user }) => { + const body = await request.json() + const mappings: AccountMapping[] = body.mappings - const { - data: { user }, - } = await supabase.auth.getUser() + if (!mappings || !Array.isArray(mappings)) { + return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 }) + } - 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) - - const body = await request.json() - const mappings: AccountMapping[] = body.mappings - - if (!mappings || !Array.isArray(mappings)) { - return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 }) - } - - try { - await saveMappings(supabase, user.id, mappings) - return NextResponse.json({ success: true }) - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to save mappings' }, - { status: 500 } - ) - } -} + try { + await saveMappings(supabase, user.id, mappings) + return NextResponse.json({ success: true }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to save mappings' }, + { status: 500 } + ) + } + }, + { requireWrite: true }, +) /** * PUT /api/import/sie/mappings * Update a single mapping */ -export async function PUT(request: Request) { - const supabase = await createClient() +export const PUT = withRouteContext( + 'sie_import.mappings.update', + async (request, { supabase, user, companyId }) => { + const body = await request.json() + const { sourceAccount, targetAccount } = body - const { - data: { user }, - } = await supabase.auth.getUser() + if (!sourceAccount || !targetAccount) { + return NextResponse.json( + { error: 'sourceAccount and targetAccount are required' }, + { status: 400 } + ) + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data, error } = await supabase + .from('sie_account_mappings') + .upsert({ + user_id: user.id, + company_id: companyId, + source_account: sourceAccount, + target_account: targetAccount, + confidence: 1.0, + match_type: 'manual', + }, { + onConflict: 'user_id,source_account', + }) + .select() + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const body = await request.json() - const { sourceAccount, targetAccount } = body - - if (!sourceAccount || !targetAccount) { - return NextResponse.json( - { error: 'sourceAccount and targetAccount are required' }, - { status: 400 } - ) - } - - const { data, error } = await supabase - .from('sie_account_mappings') - .upsert({ - user_id: user.id, - company_id: companyId, - source_account: sourceAccount, - target_account: targetAccount, - confidence: 1.0, - match_type: 'manual', - }, { - onConflict: 'user_id,source_account', - }) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) /** * DELETE /api/import/sie/mappings * Delete a specific mapping or all mappings */ -export async function DELETE(request: Request) { - const supabase = await createClient() +export const DELETE = withRouteContext( + 'sie_import.mappings.delete', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const sourceAccount = searchParams.get('sourceAccount') - const { - data: { user }, - } = await supabase.auth.getUser() + if (sourceAccount) { + // Delete specific mapping + const { error } = await supabase + .from('sie_account_mappings') + .delete() + .eq('company_id', companyId) + .eq('source_account', sourceAccount) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + } else { + // Delete all mappings + const { error } = await supabase + .from('sie_account_mappings') + .delete() + .eq('company_id', companyId) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const sourceAccount = searchParams.get('sourceAccount') - - if (sourceAccount) { - // Delete specific mapping - const { error } = await supabase - .from('sie_account_mappings') - .delete() - .eq('company_id', companyId) - .eq('source_account', sourceAccount) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } } - } else { - // Delete all mappings - const { error } = await supabase - .from('sie_account_mappings') - .delete() - .eq('company_id', companyId) - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/route.ts b/app/api/import/sie/route.ts index 52cc4beb..3fe1d965 100644 --- a/app/api/import/sie/route.ts +++ b/app/api/import/sie/route.ts @@ -1,51 +1,41 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/import/sie * List all SIE imports for the user */ -export async function GET(request: Request) { - const supabase = await createClient() +export const GET = withRouteContext( + 'sie_import.list', + async (request, { supabase, companyId }) => { + // Parse query params + const { searchParams } = new URL(request.url) + const limit = parseInt(searchParams.get('limit') || '20', 10) + const offset = parseInt(searchParams.get('offset') || '0', 10) + const status = searchParams.get('status') - const { - data: { user }, - } = await supabase.auth.getUser() + let query = supabase + .from('sie_imports') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (status) { + query = query.eq('status', status) + } - const companyId = await requireCompanyId(supabase, user.id) + const { data, error, count } = await query - // Parse query params - const { searchParams } = new URL(request.url) - const limit = parseInt(searchParams.get('limit') || '20', 10) - const offset = parseInt(searchParams.get('offset') || '0', 10) - const status = searchParams.get('status') + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - let query = supabase - .from('sie_imports') - .select('*', { count: 'exact' }) - .eq('company_id', companyId) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1) - - if (status) { - query = query.eq('status', status) - } - - const { data, error, count } = await query - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ - data, - count, - limit, - offset, - }) -} + return NextResponse.json({ + data, + count, + limit, + offset, + }) + }, +) diff --git a/app/api/invoices/[id]/convert/__tests__/route.test.ts b/app/api/invoices/[id]/convert/__tests__/route.test.ts index 40f202d4..29bed643 100644 --- a/app/api/invoices/[id]/convert/__tests__/route.test.ts +++ b/app/api/invoices/[id]/convert/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams, @@ -8,8 +9,10 @@ import { import { eventBus } from '@/lib/events' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -17,6 +20,7 @@ vi.mock('@/lib/init', () => ({ })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) @@ -67,11 +71,15 @@ describe('POST /api/invoices/[id]/convert', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const response = await POST( createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }), diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts index 83367a4a..eff07f0e 100644 --- a/app/api/invoices/[id]/convert/route.ts +++ b/app/api/invoices/[id]/convert/route.ts @@ -1,9 +1,7 @@ -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 { withRouteContext } from '@/lib/api/with-route-context' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import type { Invoice } from '@/types' @@ -20,23 +18,10 @@ ensureInitialized() * cancelled, so a partial failure in any earlier step rolls back the orphan * row without leaking a number. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.convert', + async (request, { supabase, user, companyId }, { params }) => { 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) const { data: proforma, error: proformaError } = await supabase .from('invoices') @@ -168,4 +153,6 @@ export async function POST( } return NextResponse.json({ data: completeInvoice }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index e5eaf702..09000e56 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -10,8 +10,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -89,7 +91,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) }) diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 2d8b06c2..b01b7a0c 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -1,16 +1,13 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { uploadDocument } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -import { createLogger } from '@/lib/logger' import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' ensureInitialized() @@ -22,24 +19,10 @@ ensureInitialized() * Under faktureringsmetoden (accrual): creates the journal entry (Debit 1510, Credit 30xx/26xx). * Under kontantmetoden (cash): no journal entry; booking happens at payment. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.mark_sent', + async (request, { supabase, user, companyId, log }, { params }) => { 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) - const log = createLogger('invoice.mark-sent', { companyId, invoiceId: id }) // Fetch invoice const { data: invoice, error: invoiceError } = await supabase @@ -208,4 +191,6 @@ export async function POST( status: 'sent', journal_entry_id: journalEntryId, }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index d397efc9..e4f7f519 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -1,25 +1,14 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' +import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' -import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.pdf', + async (request, { supabase, companyId }, { params }) => { 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 companyId = await requireCompanyId(supabase, user.id) // Fetch invoice with customer and items const { data: invoice, error: invoiceError } = await supabase @@ -108,4 +97,5 @@ export async function GET( { status: 500 } ) } -} + }, +) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 06e04e71..522c3580 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -1,10 +1,9 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' +import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getVatRules } from '@/lib/invoices/vat-rules' -import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' /** @@ -13,17 +12,7 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy * Generates a preview PDF from form data without creating an invoice. * Returns the PDF as an inline blob for display in a new browser tab. */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const POST = withRouteContext('invoice.preview_pdf', async (request, { supabase, user, companyId }) => { const body = await request.json() const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number } = body @@ -206,4 +195,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/kpi/preferences/__tests__/route.test.ts b/app/api/kpi/preferences/__tests__/route.test.ts new file mode 100644 index 00000000..c3428e4c --- /dev/null +++ b/app/api/kpi/preferences/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +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 }), +})) + +// mergeWithDefaults is exercised for real; it just fills defaults on the input. +import { GET, PUT } from '../route' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) +}) + +describe('GET /api/kpi/preferences', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/api/kpi/preferences'), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns merged preferences', async () => { + enqueue({ data: { value: {} } }) + const res = await GET(createMockRequest('/api/kpi/preferences'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: unknown }>(res) + expect(status).toBe(200) + expect(body.data).toBeDefined() + }) +}) + +describe('PUT /api/kpi/preferences', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const req = createMockRequest('/api/kpi/preferences', { method: 'PUT', body: {} }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 403 when caller has read-only role', async () => { + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json( + { error: 'Du har endast läsbehörighet i detta företag.' }, + { status: 403 }, + ), + }) + const req = createMockRequest('/api/kpi/preferences', { method: 'PUT', body: {} }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(res) + expect(status).toBe(403) + }) + + it('rejects an account override that is not a 4-digit string', async () => { + const req = createMockRequest('/api/kpi/preferences', { + method: 'PUT', + body: { accountOverrides: { some_kpi: ['abc'] } }, + }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(400) + expect(body.error).toContain('4 digits') + }) + + it('upserts and returns the stored value on the happy path', async () => { + enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } }) + const req = createMockRequest('/api/kpi/preferences', { + method: 'PUT', + body: { accountOverrides: { some_kpi: ['3001'] } }, + }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { accountOverrides: Record } }>(res) + expect(status).toBe(200) + expect(body.data.accountOverrides.some_kpi).toEqual(['3001']) + }) +}) diff --git a/app/api/kpi/preferences/route.ts b/app/api/kpi/preferences/route.ts index 75b50f80..5936bf6f 100644 --- a/app/api/kpi/preferences/route.ts +++ b/app/api/kpi/preferences/route.ts @@ -1,20 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { KPIPreferences } from '@/types' const EXTENSION_ID = 'core/kpi' const KEY = 'preferences' -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('kpi.preferences.get', async (_request, { supabase, companyId }) => { const { data } = await supabase .from('extension_data') .select('value') @@ -25,67 +17,62 @@ export async function GET() { const preferences = mergeWithDefaults((data?.value as Partial) ?? {}) return NextResponse.json({ data: preferences }) -} +}) -export async function PUT(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PUT = withRouteContext( + 'kpi.preferences.update', + async (request, { supabase, companyId, user }) => { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const prefs = body as Partial - const companyId = await requireCompanyId(supabase, user.id) - - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) - } - - const prefs = body as Partial - - // Validate account overrides: must be 4-digit numeric strings - if (prefs.accountOverrides) { - for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) { - if (!Array.isArray(accounts)) { - return NextResponse.json( - { error: `accountOverrides.${kpiId} must be an array` }, - { status: 400 } - ) - } - for (const acc of accounts) { - if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) { + // Validate account overrides: must be 4-digit numeric strings + if (prefs.accountOverrides) { + for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) { + if (!Array.isArray(accounts)) { return NextResponse.json( - { error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` }, + { error: `accountOverrides.${kpiId} must be an array` }, { status: 400 } ) } + for (const acc of accounts) { + if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) { + return NextResponse.json( + { error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` }, + { status: 400 } + ) + } + } } } - } - const merged = mergeWithDefaults(prefs) + const merged = mergeWithDefaults(prefs) - const { data, error } = await supabase - .from('extension_data') - .upsert( - { - user_id: user.id, - company_id: companyId, - extension_id: EXTENSION_ID, - key: KEY, - value: merged, - }, - { onConflict: 'user_id,extension_id,key' } - ) - .select() - .single() + const { data, error } = await supabase + .from('extension_data') + .upsert( + { + user_id: user.id, + company_id: companyId, + extension_id: EXTENSION_ID, + key: KEY, + value: merged, + }, + { onConflict: 'user_id,extension_id,key' } + ) + .select() + .single() - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - return NextResponse.json({ data: data.value }) -} + return NextResponse.json({ data: data.value }) + }, + { requireWrite: true } +) diff --git a/app/api/mcp-oauth/authorize/__tests__/route.test.ts b/app/api/mcp-oauth/authorize/__tests__/route.test.ts index 69270598..051c57d4 100644 --- a/app/api/mcp-oauth/authorize/__tests__/route.test.ts +++ b/app/api/mcp-oauth/authorize/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const mocks = vi.hoisted(() => ({ createClient: vi.fn(), @@ -23,7 +23,7 @@ vi.mock('@/lib/branding/service', () => ({ getBranding: () => mocks.getBranding(), })) -import { GET } from '../route' +import { GET, POST } from '../route' function buildAuthorizeUrl(params: Record): string { const url = new URL('http://localhost/api/mcp-oauth/authorize') @@ -31,9 +31,18 @@ function buildAuthorizeUrl(params: Record): string { return url.toString() } -function buildSupabase(user: { id: string } | null, companyName = 'Test AB') { +function buildSupabase( + user: { id: string } | null, + companyName = 'Test AB', + aal: { currentLevel: string; nextLevel: string } = { currentLevel: 'aal2', nextLevel: 'aal2' }, +) { return { - auth: { getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }) }, + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }), + mfa: { + getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: aal, error: null }), + }, + }, from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ @@ -187,3 +196,92 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => { // of truth for which origins can land at this endpoint. }) }) + +describe('MFA step-up on /api/mcp-oauth/authorize', () => { + // Consent here ultimately mints a long-lived API key that bypasses MFA on + // every subsequent request, so an AAL1 (password-only) session must never + // reach the consent page or approve it. The middleware MFA gate exempts + // /api/mcp-oauth/*, making the route responsible for its own step-up. + const authorizeParams = { + response_type: 'code', + redirect_uri: 'https://claude.ai/api/mcp/auth_callback', + code_challenge: 'abc', + code_challenge_method: 'S256', + scope: 'mcp', + state: 'xyz', + } + + beforeEach(() => { + vi.clearAllMocks() + process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key' + vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true') + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') + mocks.isAllowedRedirectUri.mockResolvedValue(true) + mocks.requireCompanyId.mockResolvedValue('company-1') + mocks.getBranding.mockReturnValue({ appName: 'gnubok' }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('GET redirects an AAL1 session to /mfa/verify with returnTo', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }), + ) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + + expect(response.status).toBeGreaterThanOrEqual(300) + expect(response.status).toBeLessThan(400) + const location = new URL(response.headers.get('location')!) + expect(location.pathname).toBe('/mfa/verify') + const returnTo = new URL(location.searchParams.get('returnTo')!, location.origin) + expect(returnTo.pathname).toBe('/api/mcp-oauth/authorize') + expect(returnTo.searchParams.get('state')).toBe('xyz') + }) + + it('POST rejects an AAL1 session even when the consent form is forged', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }), + ) + + const formData = new FormData() + formData.set('consent', 'allow') + const response = await POST( + new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }), + ) + + expect(response.status).toBeGreaterThanOrEqual(300) + expect(response.status).toBeLessThan(400) + expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify') + // No auth code must be minted: the redirect target is the step-up page, + // never the client callback. + expect(response.headers.get('location')).not.toContain('code=') + }) + + it('GET renders consent for an AAL2 session', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal2', nextLevel: 'aal2' }), + ) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + expect(response.status).toBe(200) + }) + + it('GET skips step-up for BankID-linked users (inherently 2FA)', async () => { + const supabase = buildSupabase( + { id: 'user-1' }, + 'Test AB', + { currentLevel: 'aal1', nextLevel: 'aal2' }, + ) + ;(supabase.auth.getUser as ReturnType).mockResolvedValue({ + data: { user: { id: 'user-1', app_metadata: { bankid_linked: true } } }, + error: null, + }) + mocks.createClient.mockResolvedValue(supabase) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + expect(response.status).toBe(200) + }) +}) diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index fd3a20c5..802f45ff 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -1,7 +1,9 @@ import crypto from 'crypto' import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import type { SupabaseClient, User } from '@supabase/supabase-js' import { createAuthCode } from '@/lib/auth/oauth-codes' +import { shouldEnforceMfa } from '@/lib/auth/mfa' import { requireCompanyId } from '@/lib/company/context' import { getBranding } from '@/lib/branding/service' import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist' @@ -105,6 +107,31 @@ function buildLoginRedirect(request: Request): Response { ) } +/** + * Consent here mints a long-lived API key at /token, and that key bypasses + * MFA on every subsequent call: so the consent session itself must be AAL2. + * The middleware MFA gate deliberately exempts /api/mcp-oauth/* (the token + * endpoint is Bearer-only), which makes this route responsible for its own + * step-up. Returns null when the session is AAL2 (or MFA isn't required), + * otherwise a redirect to /mfa/verify that returns to this authorize URL. + */ +async function requireAal2( + supabase: SupabaseClient, + user: User, + request: Request, +): Promise { + if (!shouldEnforceMfa(user)) return null + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') { + const url = new URL(request.url) + const returnTo = `${url.pathname}${url.search}` + return NextResponse.redirect( + new URL(`/mfa/verify?returnTo=${encodeURIComponent(returnTo)}`, url.origin), + ) + } + return null +} + function errorRedirect(redirectUri: string, state: string | null, error: string, desc: string): Response { const url = new URL(redirectUri) url.searchParams.set('error', error) @@ -165,6 +192,9 @@ export async function GET(request: Request) { return buildLoginRedirect(request) } + const mfaRedirect = await requireAal2(supabase, user, request) + if (mfaRedirect) return mfaRedirect + // Validate redirect_uri against allowlist (prevents open redirect). Passing // the authenticated client makes the trust boundary explicit (SOC 2 CC6.1). if (!(await isAllowedRedirectUri(redirectUri, supabase))) { @@ -631,6 +661,12 @@ export async function POST(request: Request) { return buildLoginRedirect(request) } + // An AAL1 session must not be able to approve consent (the GET step-up can + // be bypassed by POSTing the form directly). The redirect lands back on the + // GET consent page after verification. + const mfaRedirect = await requireAal2(supabase, user, request) + if (mfaRedirect) return mfaRedirect + // Pass the authenticated client so the lookup is bound to the same session // that the consent display ran under (SOC 2 CC6.1). if (!(await isAllowedRedirectUri(redirectUri, supabase))) { diff --git a/app/api/pending-operations/[id]/__tests__/route.test.ts b/app/api/pending-operations/[id]/__tests__/route.test.ts index f5d21448..9d068e02 100644 --- a/app/api/pending-operations/[id]/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/__tests__/route.test.ts @@ -7,13 +7,19 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +// The route runs through the real withRouteContext wrapper: mock its auth, +// company-resolution and write-permission dependencies (getActiveCompanyId, +// not requireCompanyId, is what the wrapper calls) and inject the queued +// Supabase mock via requireAuth so the route's own queries stay in sequence. +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) -const requireCompanyIdMock = vi.fn() vi.mock('@/lib/company/context', () => ({ - requireCompanyId: (...args: unknown[]) => requireCompanyIdMock(...args), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) const requireWritePermissionMock = vi.fn() @@ -42,9 +48,8 @@ const mockUser = { id: 'user-1' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) requireWritePermissionMock.mockResolvedValue({ ok: true }) - requireCompanyIdMock.mockResolvedValue('company-1') mappingMock.mockReturnValue({ debit_account: '5410', credit_account: '1930', @@ -62,7 +67,12 @@ beforeEach(() => { describe('PATCH /api/pending-operations/[id]', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const { NextResponse } = await import('next/server') + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await PATCH( createMockRequest('/api/pending-operations/op-1', { method: 'PATCH', diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index aa3dd41b..f3ef0432 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -1,74 +1,62 @@ -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 { commitPendingOperation } from '@/lib/pending-operations/commit' import { bookkeepingErrorResponse, AccountsNotInChartError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors' import type { PendingOperation } from '@/types' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'pending_operation.commit', + async (_request, { supabase, user, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: op, error: fetchError } = await supabase + .from('pending_operations') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { data: op, error: fetchError } = await supabase - .from('pending_operations') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !op) { - return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) - } - - try { - const result = await commitPendingOperation( - supabase, - user.id, - companyId, - op as PendingOperation, - { - userEmail: user.email, - commitMethod: 'user_accept', - actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, - } - ) - - if (result.status === 'committed') { - return NextResponse.json({ data: result.data }) + if (fetchError || !op) { + return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) } - // Recoverable accounts-not-in-chart: return the structured envelope (code + - // account_numbers) so the client can offer activation and retry the still- - // pending op, instead of leaking the raw error string into the chat. - if (result.code === ACCOUNTS_NOT_IN_CHART && result.account_numbers?.length) { - const structured = bookkeepingErrorResponse( - new AccountsNotInChartError(result.account_numbers) + + try { + const result = await commitPendingOperation( + supabase, + user.id, + companyId, + op as PendingOperation, + { + userEmail: user.email, + commitMethod: 'user_accept', + actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, + } ) - if (structured) return structured + + if (result.status === 'committed') { + return NextResponse.json({ data: result.data }) + } + // Recoverable accounts-not-in-chart: return the structured envelope (code + + // account_numbers) so the client can offer activation and retry the still- + // pending op, instead of leaking the raw error string into the chat. + if (result.code === ACCOUNTS_NOT_IN_CHART && result.account_numbers?.length) { + const structured = bookkeepingErrorResponse( + new AccountsNotInChartError(result.account_numbers) + ) + if (structured) return structured + } + return NextResponse.json( + { error: result.error }, + { status: result.http_status ?? 500 } + ) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + throw err } - return NextResponse.json( - { error: result.error }, - { status: result.http_status ?? 500 } - ) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - throw err - } -} + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/[id]/reject/__tests__/route.test.ts b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts index 53662d05..6f15ae8d 100644 --- a/app/api/pending-operations/[id]/reject/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams, @@ -7,33 +8,40 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) import { POST } from '../../reject/route' describe('POST /api/pending-operations/:id/reject', () => { - const mockUser = { id: 'user-1', email: 'test@test.se' } const routeParams = createMockRouteParams({ id: 'op-1' }) beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' }) const response = await POST(request, routeParams) @@ -42,6 +50,19 @@ describe('POST /api/pending-operations/:id/reject', () => { expect(status).toBe(401) }) + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' }) + const response = await POST(request, routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + it('returns 404 when not found', async () => { enqueue({ data: null, error: { message: 'not found' } }) diff --git a/app/api/pending-operations/[id]/reject/route.ts b/app/api/pending-operations/[id]/reject/route.ts index 4e1e409c..b509860f 100644 --- a/app/api/pending-operations/[id]/reject/route.ts +++ b/app/api/pending-operations/[id]/reject/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' const RejectBodySchema = z.object({ rejection_category: z @@ -20,91 +18,81 @@ const RejectBodySchema = z.object({ * and learn from "no". The body is optional: bodyless POSTs from older * clients still mark the op rejected with NULL category/reason. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'pending_operation.reject', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params - 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) - - // Body is optional: accept empty/missing body without rejecting the request. - // Old clients posted no body; the UI dialog will now post a body, but we - // keep accepting both shapes to avoid coupling the API to the UI version. - let rejectionCategory: string | undefined - let rejectionReason: string | undefined - const contentLength = request.headers.get('content-length') - if (contentLength && contentLength !== '0') { - try { - const raw = await request.json() - const parsed = RejectBodySchema.safeParse(raw) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues.map((i) => i.message).join('; ') }, - { status: 400 }, - ) + // Body is optional: accept empty/missing body without rejecting the request. + // Old clients posted no body; the UI dialog will now post a body, but we + // keep accepting both shapes to avoid coupling the API to the UI version. + let rejectionCategory: string | undefined + let rejectionReason: string | undefined + const contentLength = request.headers.get('content-length') + if (contentLength && contentLength !== '0') { + try { + const raw = await request.json() + const parsed = RejectBodySchema.safeParse(raw) + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join('; ') }, + { status: 400 }, + ) + } + rejectionCategory = parsed.data.rejection_category + rejectionReason = parsed.data.rejection_reason?.trim() || undefined + } catch { + // Body present but unparseable: fail closed. + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - rejectionCategory = parsed.data.rejection_category - rejectionReason = parsed.data.rejection_reason?.trim() || undefined - } catch { - // Body present but unparseable: fail closed. - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - } - const { data: op, error: fetchError } = await supabase - .from('pending_operations') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + const { data: op, error: fetchError } = await supabase + .from('pending_operations') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (fetchError || !op) { - return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) - } + if (fetchError || !op) { + return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) + } - if (op.status !== 'pending') { - // There is no auto-commit path (removed in 20260505190027), so a non-pending - // status here means the op was resolved explicitly: almost always the user - // pressed Godkänn in the /pending (Att göra) UI in parallel, or another - // client already rejected it. Spell that out so an agent doesn't read the - // generic 409 as "the system committed it behind my back". - const explained = - op.status === 'rejected' - ? 'Operation already rejected.' - : op.status === 'expired' - ? 'Operation already expired and can no longer be rejected.' - : `Operation already ${op.status}: it was approved explicitly (most likely via the ` + - 'Att göra / pending UI in parallel), not auto-committed. It can no longer be rejected; ' + - 'reverse or correct the resulting verifikat instead.' - return NextResponse.json( - { error: explained, status: op.status }, - { status: 409 } - ) - } + if (op.status !== 'pending') { + // There is no auto-commit path (removed in 20260505190027), so a non-pending + // status here means the op was resolved explicitly: almost always the user + // pressed Godkänn in the /pending (Att göra) UI in parallel, or another + // client already rejected it. Spell that out so an agent doesn't read the + // generic 409 as "the system committed it behind my back". + const explained = + op.status === 'rejected' + ? 'Operation already rejected.' + : op.status === 'expired' + ? 'Operation already expired and can no longer be rejected.' + : `Operation already ${op.status}: it was approved explicitly (most likely via the ` + + 'Att göra / pending UI in parallel), not auto-committed. It can no longer be rejected; ' + + 'reverse or correct the resulting verifikat instead.' + return NextResponse.json( + { error: explained, status: op.status }, + { status: 409 } + ) + } - const { error: updateError } = await supabase - .from('pending_operations') - .update({ - status: 'rejected', - resolved_at: new Date().toISOString(), - ...(rejectionCategory ? { rejection_category: rejectionCategory } : {}), - ...(rejectionReason ? { rejection_reason: rejectionReason } : {}), - }) - .eq('id', id) + const { error: updateError } = await supabase + .from('pending_operations') + .update({ + status: 'rejected', + resolved_at: new Date().toISOString(), + ...(rejectionCategory ? { rejection_category: rejectionCategory } : {}), + ...(rejectionReason ? { rejection_reason: rejectionReason } : {}), + }) + .eq('id', id) - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } - return NextResponse.json({ data: { id, status: 'rejected' } }) -} + return NextResponse.json({ data: { id, status: 'rejected' } }) + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts index 0311d11b..5612d85b 100644 --- a/app/api/pending-operations/[id]/route.ts +++ b/app/api/pending-operations/[id]/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' 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 { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping' import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries' import { getVatRate } from '@/lib/bookkeeping/vat-entries' @@ -48,180 +46,173 @@ const PatchSchema = z { message: 'Nothing to update' }, ) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'pending_operation.update', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - const { id } = await params - - let body: z.infer - try { - body = PatchSchema.parse(await request.json()) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, - { status: 400 }, - ) - } - - const { data: op } = await supabase - .from('pending_operations') - .select('id, company_id, operation_type, status, params, preview_data, title') - .eq('id', id) - .eq('company_id', companyId) - .maybeSingle() - if (!op) return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) - - if (op.status !== 'pending') { - return NextResponse.json( - { error: `Operation already ${op.status}: cannot edit.` }, - { status: 409 }, - ) - } - - if (op.operation_type !== 'categorize_transaction') { - return NextResponse.json( - { error: `Editing ${op.operation_type} is not supported.` }, - { status: 400 }, - ) - } - - const oldParams = (op.params as Record) ?? {} - const newCategory = - body.category ?? (oldParams.category as TransactionCategory | undefined) - const newVatTreatment = - body.vat_treatment !== undefined - ? (body.vat_treatment ?? undefined) - : (oldParams.vat_treatment as VatTreatment | undefined) - - if (!newCategory) { - return NextResponse.json({ error: 'category is required' }, { status: 400 }) - } - - const txId = oldParams.transaction_id as string | undefined - if (!txId) { - return NextResponse.json( - { error: 'Operation has no transaction_id; cannot re-derive.' }, - { status: 500 }, - ) - } - - // Re-derive the preview using the same mapping engine the commit path uses. - const { data: tx } = await supabase - .from('transactions') - .select('*') - .eq('id', txId) - .eq('company_id', companyId) - .maybeSingle() - if (!tx) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - const { data: settings } = await supabase - .from('company_settings') - .select('entity_type') - .eq('company_id', companyId) - .maybeSingle() - const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma') - - const isBusiness = newCategory !== 'private' - - // Resolve whether the (possibly defaulted) treatment carries a rate-based - // VAT line: only then can a vat_amount override survive. An explicit - // override on a VAT-less treatment is a caller error; a preserved one from - // before the edit is simply stale and gets dropped. - const probe = getCategoryAccountMapping( - newCategory, (tx as Transaction).amount, isBusiness, entityType, newVatTreatment, - ) - const carriesRateVat = - isBusiness && - probe.vatTreatment !== null && - probe.vatTreatment !== 'reverse_charge' && - getVatRate(probe.vatTreatment as VatTreatment) > 0 - - let newVatAmount: number | null - if (body.vat_amount !== undefined) { - if (body.vat_amount !== null && !carriesRateVat) { + let body: z.infer + try { + body = PatchSchema.parse(await request.json()) + } catch (err) { return NextResponse.json( - { error: 'vat_amount kräver en momspliktig vat_treatment (standard_25, reduced_12 eller reduced_6).' }, + { error: err instanceof Error ? err.message : 'Invalid body' }, { status: 400 }, ) } - newVatAmount = body.vat_amount - } else { - const previous = typeof oldParams.vat_amount === 'number' ? oldParams.vat_amount : null - newVatAmount = carriesRateVat ? previous : null - } - let mapping - try { - mapping = buildMappingResultFromCategory( - newCategory, - tx as Transaction, - isBusiness, - entityType, - newVatTreatment, - newVatAmount, + const { data: op } = await supabase + .from('pending_operations') + .select('id, company_id, operation_type, status, params, preview_data, title') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + if (!op) return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) + + if (op.status !== 'pending') { + return NextResponse.json( + { error: `Operation already ${op.status}: cannot edit.` }, + { status: 409 }, + ) + } + + if (op.operation_type !== 'categorize_transaction') { + return NextResponse.json( + { error: `Editing ${op.operation_type} is not supported.` }, + { status: 400 }, + ) + } + + const oldParams = (op.params as Record) ?? {} + const newCategory = + body.category ?? (oldParams.category as TransactionCategory | undefined) + const newVatTreatment = + body.vat_treatment !== undefined + ? (body.vat_treatment ?? undefined) + : (oldParams.vat_treatment as VatTreatment | undefined) + + if (!newCategory) { + return NextResponse.json({ error: 'category is required' }, { status: 400 }) + } + + const txId = oldParams.transaction_id as string | undefined + if (!txId) { + return NextResponse.json( + { error: 'Operation has no transaction_id; cannot re-derive.' }, + { status: 500 }, + ) + } + + // Re-derive the preview using the same mapping engine the commit path uses. + const { data: tx } = await supabase + .from('transactions') + .select('*') + .eq('id', txId) + .eq('company_id', companyId) + .maybeSingle() + if (!tx) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .maybeSingle() + const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma') + + const isBusiness = newCategory !== 'private' + + // Resolve whether the (possibly defaulted) treatment carries a rate-based + // VAT line: only then can a vat_amount override survive. An explicit + // override on a VAT-less treatment is a caller error; a preserved one from + // before the edit is simply stale and gets dropped. + const probe = getCategoryAccountMapping( + newCategory, (tx as Transaction).amount, isBusiness, entityType, newVatTreatment, ) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Ogiltig momsjustering' }, - { status: 400 }, - ) - } + const carriesRateVat = + isBusiness && + probe.vatTreatment !== null && + probe.vatTreatment !== 'reverse_charge' && + getVatRate(probe.vatTreatment as VatTreatment) > 0 - if (!mapping.debit_account || !mapping.credit_account) { - return NextResponse.json( - { error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` }, - { status: 400 }, - ) - } + let newVatAmount: number | null + if (body.vat_amount !== undefined) { + if (body.vat_amount !== null && !carriesRateVat) { + return NextResponse.json( + { error: 'vat_amount kräver en momspliktig vat_treatment (standard_25, reduced_12 eller reduced_6).' }, + { status: 400 }, + ) + } + newVatAmount = body.vat_amount + } else { + const previous = typeof oldParams.vat_amount === 'number' ? oldParams.vat_amount : null + newVatAmount = carriesRateVat ? previous : null + } - const oldPreview = (op.preview_data as Record) ?? {} - const newPreview = { - ...oldPreview, - debit_account: mapping.debit_account, - credit_account: mapping.credit_account, - amount: Math.abs((tx as Transaction).amount), - currency: (tx as Transaction).currency, - // Re-derive the exact journal lines (net cost line, VAT, gross bank) — - // spreading oldPreview would otherwise leave stale lines from staging. - lines: buildTransactionEntryLines(tx as Transaction, mapping).map((l) => ({ - account_number: l.account_number, - debit_amount: l.debit_amount, - credit_amount: l.credit_amount, - description: l.line_description ?? '', - })), - vat_lines: (mapping.vat_lines ?? []).map((v) => ({ - account: v.account_number, - amount: v.debit_amount || v.credit_amount, - })), - category: newCategory, - } + let mapping + try { + mapping = buildMappingResultFromCategory( + newCategory, + tx as Transaction, + isBusiness, + entityType, + newVatTreatment, + newVatAmount, + ) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Ogiltig momsjustering' }, + { status: 400 }, + ) + } - const newParams = { - ...oldParams, - category: newCategory, - vat_treatment: newVatTreatment ?? null, - vat_amount: newVatAmount, - } + if (!mapping.debit_account || !mapping.credit_account) { + return NextResponse.json( + { error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` }, + { status: 400 }, + ) + } - const { data: updated, error } = await supabase - .from('pending_operations') - .update({ params: newParams, preview_data: newPreview }) - .eq('id', id) - .eq('company_id', companyId) - .select('id, params, preview_data, title, status') - .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + const oldPreview = (op.preview_data as Record) ?? {} + const newPreview = { + ...oldPreview, + debit_account: mapping.debit_account, + credit_account: mapping.credit_account, + amount: Math.abs((tx as Transaction).amount), + currency: (tx as Transaction).currency, + // Re-derive the exact journal lines (net cost line, VAT, gross bank): + // spreading oldPreview would otherwise leave stale lines from staging. + lines: buildTransactionEntryLines(tx as Transaction, mapping).map((l) => ({ + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + description: l.line_description ?? '', + })), + vat_lines: (mapping.vat_lines ?? []).map((v) => ({ + account: v.account_number, + amount: v.debit_amount || v.credit_amount, + })), + category: newCategory, + } - return NextResponse.json({ data: updated }) -} + const newParams = { + ...oldParams, + category: newCategory, + vat_treatment: newVatTreatment ?? null, + vat_amount: newVatAmount, + } + + const { data: updated, error } = await supabase + .from('pending_operations') + .update({ params: newParams, preview_data: newPreview }) + .eq('id', id) + .eq('company_id', companyId) + .select('id, params, preview_data, title, status') + .single() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts index c80ba20d..f6676fc8 100644 --- a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts +++ b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -7,9 +8,12 @@ import { import { eventBus } from '@/lib/events/bus' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) + vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) vi.mock('@/lib/company/context', () => ({ @@ -17,8 +21,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) const mockCommit = vi.fn() @@ -56,11 +61,16 @@ describe('POST /api/pending-operations/bulk-commit', () => { vi.clearAllMocks() eventBus.clear() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/pending-operations/bulk-commit', { method: 'POST', @@ -73,6 +83,22 @@ describe('POST /api/pending-operations/bulk-commit', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + it('returns 400 when ids array is empty', async () => { const request = createMockRequest('/api/pending-operations/bulk-commit', { method: 'POST', diff --git a/app/api/pending-operations/bulk-commit/route.ts b/app/api/pending-operations/bulk-commit/route.ts index f2955021..61dd9718 100644 --- a/app/api/pending-operations/bulk-commit/route.ts +++ b/app/api/pending-operations/bulk-commit/route.ts @@ -1,8 +1,6 @@ -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 { validateBody } from '@/lib/api/validate' import { PendingOperationsBulkSchema } from '@/lib/api/schemas' import { commitPendingOperation } from '@/lib/pending-operations/commit' @@ -16,76 +14,68 @@ interface BulkCommitItemResult { error?: string } -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'pending_operation.bulk_commit', + async (request, { user, supabase, companyId }) => { + const validated = await validateBody(request, PendingOperationsBulkSchema) + if (!validated.success) return validated.response + const { ids } = validated.data - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: ops, error: fetchError } = await supabase + .from('pending_operations') + .select('*') + .in('id', ids) + .eq('company_id', companyId) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const validated = await validateBody(request, PendingOperationsBulkSchema) - if (!validated.success) return validated.response - const { ids } = validated.data - - const companyId = await requireCompanyId(supabase, user.id) - - const { data: ops, error: fetchError } = await supabase - .from('pending_operations') - .select('*') - .in('id', ids) - .eq('company_id', companyId) - - if (fetchError) { - return NextResponse.json({ error: fetchError.message }, { status: 500 }) - } - - const opsById = new Map((ops ?? []).map((op) => [op.id, op as PendingOperation])) - const results: BulkCommitItemResult[] = [] - - for (const id of ids) { - const op = opsById.get(id) - if (!op) { - results.push({ id, status: 'failed', error: 'Operation not found' }) - continue + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }) } - if (op.status !== 'pending') { - results.push({ id, status: 'skipped', error: `Already ${op.status}` }) - continue - } - if (op.risk_level === 'high') { - results.push({ - id, - status: 'skipped', - error: 'Hög risk: kräver individuellt godkännande', + + const opsById = new Map((ops ?? []).map((op) => [op.id, op as PendingOperation])) + const results: BulkCommitItemResult[] = [] + + for (const id of ids) { + const op = opsById.get(id) + if (!op) { + results.push({ id, status: 'failed', error: 'Operation not found' }) + continue + } + if (op.status !== 'pending') { + results.push({ id, status: 'skipped', error: `Already ${op.status}` }) + continue + } + if (op.risk_level === 'high') { + results.push({ + id, + status: 'skipped', + error: 'Hög risk: kräver individuellt godkännande', + }) + continue + } + + const result = await commitPendingOperation(supabase, user.id, companyId, op, { + userEmail: user.email, + commitMethod: 'bulk_accept', + actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, }) - continue + if (result.status === 'committed') { + results.push({ id, status: 'committed' }) + } else if (result.status === 'rejected' && result.auto_rejected) { + results.push({ id, status: 'rejected', error: result.error ?? 'Avvisad' }) + } else { + results.push({ id, status: 'failed', error: result.error ?? 'Misslyckades' }) + } } - const result = await commitPendingOperation(supabase, user.id, companyId, op, { - userEmail: user.email, - commitMethod: 'bulk_accept', - actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, - }) - if (result.status === 'committed') { - results.push({ id, status: 'committed' }) - } else if (result.status === 'rejected' && result.auto_rejected) { - results.push({ id, status: 'rejected', error: result.error ?? 'Avvisad' }) - } else { - results.push({ id, status: 'failed', error: result.error ?? 'Misslyckades' }) + const summary = { + total: results.length, + committed: results.filter((r) => r.status === 'committed').length, + failed: results.filter((r) => r.status === 'failed').length, + skipped: results.filter((r) => r.status === 'skipped').length, + rejected: results.filter((r) => r.status === 'rejected').length, } - } - const summary = { - total: results.length, - committed: results.filter((r) => r.status === 'committed').length, - failed: results.filter((r) => r.status === 'failed').length, - skipped: results.filter((r) => r.status === 'skipped').length, - rejected: results.filter((r) => r.status === 'rejected').length, - } - - return NextResponse.json({ data: { results, summary } }) -} + return NextResponse.json({ data: { results, summary } }) + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/route.ts b/app/api/pending-operations/route.ts index 4a22e7be..688e4fd0 100644 --- a/app/api/pending-operations/route.ts +++ b/app/api/pending-operations/route.ts @@ -1,8 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateQuery } from '@/lib/api/validate' import { PendingOperationsQuerySchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' /** * GET /api/pending-operations @@ -10,36 +9,31 @@ import { requireCompanyId } from '@/lib/company/context' * List pending operations for the authenticated user. * Query params: status (default: pending), limit, offset */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteContext( + 'pending_operation.list', + async (request, { supabase, companyId }) => { + const result = validateQuery(request, PendingOperationsQuerySchema) + if (!result.success) return result.response + const { status, limit, offset } = result.data - const companyId = await requireCompanyId(supabase, user.id) + // Terminal tabs (Godkända/Avvisade) order by when the op was RESOLVED, not + // created: auto-expired ops are ≥30 days old by construction, so a + // created_at ordering would bury a fresh expiry sweep below a month of + // newer rejections and the "Utgick automatiskt" context would never be seen. + const orderColumn = status === 'pending' ? 'created_at' : 'resolved_at' - const result = validateQuery(request, PendingOperationsQuerySchema) - if (!result.success) return result.response - const { status, limit, offset } = result.data + const { data, error, count } = await supabase + .from('pending_operations') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .eq('status', status) + .order(orderColumn, { ascending: false, nullsFirst: false }) + .range(offset, offset + limit - 1) - // Terminal tabs (Godkända/Avvisade) order by when the op was RESOLVED, not - // created: auto-expired ops are ≥30 days old by construction, so a - // created_at ordering would bury a fresh expiry sweep below a month of - // newer rejections and the "Utgick automatiskt" context would never be seen. - const orderColumn = status === 'pending' ? 'created_at' : 'resolved_at' + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const { data, error, count } = await supabase - .from('pending_operations') - .select('*', { count: 'exact' }) - .eq('company_id', companyId) - .eq('status', status) - .order(orderColumn, { ascending: false, nullsFirst: false }) - .range(offset, offset + limit - 1) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: data ?? [], count }) -} + return NextResponse.json({ data: data ?? [], count }) + }, +) diff --git a/app/api/reconciliation/bank/link/__tests__/route.test.ts b/app/api/reconciliation/bank/link/__tests__/route.test.ts new file mode 100644 index 00000000..42c1fc17 --- /dev/null +++ b/app/api/reconciliation/bank/link/__tests__/route.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for POST /api/reconciliation/bank/link. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies plus the manualLink service. Covers: + * 401, 403 viewer, validation (400), service failure (400), and happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const manualLinkMock = vi.fn() +vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ + manualLink: (...args: unknown[]) => manualLinkMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const TX_ID = '11111111-1111-4111-8111-111111111111' +const JE_ID = '22222222-2222-4222-8222-222222222222' + +describe('POST /api/reconciliation/bank/link', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + manualLinkMock.mockResolvedValue({ success: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-uuid transaction_id with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: 'not-a-uuid', journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + expect(manualLinkMock).not.toHaveBeenCalled() + }) + + it('surfaces a manualLink failure as 400 with the service error', async () => { + manualLinkMock.mockResolvedValue({ + success: false, + error: 'Transaktionen är redan kopplad till en verifikation.', + }) + + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Transaktionen är redan kopplad till en verifikation.') + }) + + it('links the transaction, defaulting the account to 1930', async () => { + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(manualLinkMock).toHaveBeenCalledWith( + supabase, + 'company-1', + TX_ID, + JE_ID, + 'user-1', + '1930', + ) + }) +}) diff --git a/app/api/reconciliation/bank/link/route.ts b/app/api/reconciliation/bank/link/route.ts index 91854780..7fe25b1c 100644 --- a/app/api/reconciliation/bank/link/route.ts +++ b/app/api/reconciliation/bank/link/route.ts @@ -1,43 +1,33 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { manualLink } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { BankLinkSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.link', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, BankLinkSchema) + if (!validation.success) return validation.response + const { transaction_id, journal_entry_id, account_number } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const result = await manualLink( + supabase, + companyId, + transaction_id, + journal_entry_id, + user.id, + account_number ?? '1930', + ) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, BankLinkSchema) - if (!validation.success) return validation.response - const { transaction_id, journal_entry_id, account_number } = validation.data - - const result = await manualLink( - supabase, - companyId, - transaction_id, - journal_entry_id, - user.id, - account_number ?? '1930', - ) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/mark-opening-balance/__tests__/route.test.ts b/app/api/reconciliation/bank/mark-opening-balance/__tests__/route.test.ts new file mode 100644 index 00000000..18106058 --- /dev/null +++ b/app/api/reconciliation/bank/mark-opening-balance/__tests__/route.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for POST /api/reconciliation/bank/mark-opening-balance. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, validation (400), the RPC error + * translation to Swedish, and the happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const JE_ID = '33333333-3333-4333-8333-333333333333' + +describe('POST /api/reconciliation/bank/mark-opening-balance', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-uuid journal_entry_id with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: 'nope' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + }) + + it('translates a locked-period RPC error to Swedish', async () => { + enqueue({ error: { message: 'Cannot modify entries in a locked fiscal period' } }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Perioden är låst. Lås upp perioden innan du ändrar verifikationen.') + }) + + it('marks the entry as opening balance via the RPC', async () => { + enqueue({ data: { updated: true } }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { updated: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.updated).toBe(true) + expect(supabase.rpc).toHaveBeenCalledWith('mark_entry_as_opening_balance', { + p_company_id: 'company-1', + p_entry_id: JE_ID, + }) + }) +}) diff --git a/app/api/reconciliation/bank/mark-opening-balance/route.ts b/app/api/reconciliation/bank/mark-opening-balance/route.ts index 295ffd50..c73ff751 100644 --- a/app/api/reconciliation/bank/mark-opening-balance/route.ts +++ b/app/api/reconciliation/bank/mark-opening-balance/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { MarkOpeningBalanceSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -15,48 +13,40 @@ ensureInitialized() * enforces owner/admin role, the manual/import precondition, a bank-line check, * and the period lock. We only translate its errors to Swedish here. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.mark_opening_balance', + async (request, { supabase, companyId }) => { + const validation = await validateBody(request, MarkOpeningBalanceSchema) + if (!validation.success) return validation.response + const { journal_entry_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data, error } = await supabase.rpc('mark_entry_as_opening_balance', { + p_company_id: companyId, + p_entry_id: journal_entry_id, + }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, MarkOpeningBalanceSchema) - if (!validation.success) return validation.response - const { journal_entry_id } = validation.data - - const { data, error } = await supabase.rpc('mark_entry_as_opening_balance', { - p_company_id: companyId, - p_entry_id: journal_entry_id, - }) - - if (error) { - const raw = error.message || '' - let message = 'Kunde inte markera verifikationen som ingående balans.' - if (/owners and admins/i.test(raw)) { - message = 'Endast ägare och administratörer kan markera en ingående balans.' - } else if (/not found/i.test(raw)) { - message = 'Verifikationen kunde inte hittas.' - } else if (/manual\/import/i.test(raw)) { - message = 'Bara manuellt eller importerat bokförda verifikationer kan markeras som ingående balans.' - } else if (/posted entries/i.test(raw)) { - message = 'Bara bokförda verifikationer kan markeras som ingående balans.' - } else if (/bank\/cash account/i.test(raw)) { - message = 'Verifikationen saknar rad på ett bankkonto (19xx) och kan inte vara en ingående balans.' - } else if (/closed fiscal period/i.test(raw)) { - message = 'Perioden är stängd. Öppna perioden innan du ändrar verifikationen.' - } else if (/locked fiscal period/i.test(raw)) { - message = 'Perioden är låst. Lås upp perioden innan du ändrar verifikationen.' + if (error) { + const raw = error.message || '' + let message = 'Kunde inte markera verifikationen som ingående balans.' + if (/owners and admins/i.test(raw)) { + message = 'Endast ägare och administratörer kan markera en ingående balans.' + } else if (/not found/i.test(raw)) { + message = 'Verifikationen kunde inte hittas.' + } else if (/manual\/import/i.test(raw)) { + message = 'Bara manuellt eller importerat bokförda verifikationer kan markeras som ingående balans.' + } else if (/posted entries/i.test(raw)) { + message = 'Bara bokförda verifikationer kan markeras som ingående balans.' + } else if (/bank\/cash account/i.test(raw)) { + message = 'Verifikationen saknar rad på ett bankkonto (19xx) och kan inte vara en ingående balans.' + } else if (/closed fiscal period/i.test(raw)) { + message = 'Perioden är stängd. Öppna perioden innan du ändrar verifikationen.' + } else if (/locked fiscal period/i.test(raw)) { + message = 'Perioden är låst. Lås upp perioden innan du ändrar verifikationen.' + } + return NextResponse.json({ error: message }, { status: 400 }) } - return NextResponse.json({ error: message }, { status: 400 }) - } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/run/__tests__/route.test.ts b/app/api/reconciliation/bank/run/__tests__/route.test.ts new file mode 100644 index 00000000..526ff36a --- /dev/null +++ b/app/api/reconciliation/bank/run/__tests__/route.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for POST /api/reconciliation/bank/run. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies plus the runReconciliation service. + * Covers: 401, 403 viewer, unknown cash account (400), and the happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const runReconciliationMock = vi.fn() +vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ + runReconciliation: (...args: unknown[]) => runReconciliationMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +describe('POST /api/reconciliation/bank/run', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + runReconciliationMock.mockResolvedValue({ matches: [], applied: 0, errors: [] }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { dry_run: true }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { dry_run: true }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-default account with no cash_accounts row', async () => { + // cash_accounts lookup finds nothing for 1932. + enqueue({ data: null }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { account_number: '1932', dry_run: true }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Okänt kassakonto för det här företaget') + expect(runReconciliationMock).not.toHaveBeenCalled() + }) + + it('runs reconciliation on the default 1930 account even without a cash_accounts row', async () => { + // cash_accounts lookup: no row, but '1930' is exempt. + enqueue({ data: null }) + runReconciliationMock.mockResolvedValue({ + matches: [ + { + transaction: { id: 't-1', date: '2024-06-15', description: 'Betalning', amount: 1250 }, + glLine: { + journal_entry_id: 'je-1', + voucher_number: 12, + voucher_series: 'A', + entry_date: '2024-06-15', + entry_description: 'Kundfaktura', + }, + method: 'exact', + confidence: 1, + }, + ], + applied: 1, + errors: [], + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { date_from: '2024-06-01', date_to: '2024-06-30' }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ + data: { matches: { transaction_id: string }[]; applied: number; dry_run: boolean } + }>(response) + + expect(status).toBe(200) + expect(body.data.applied).toBe(1) + expect(body.data.dry_run).toBe(false) + expect(body.data.matches[0].transaction_id).toBe('t-1') + expect(runReconciliationMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + expect.objectContaining({ accountNumber: '1930', currency: 'SEK', dryRun: false }), + ) + }) +}) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index 1a6c2c78..8af01ba1 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -1,88 +1,78 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { RunReconciliationSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.run', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, RunReconciliationSchema) + if (!validation.success) return validation.response + const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const accountNumber = account_number ?? '1930' - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Defense-in-depth: reject a non-default account the company hasn't + // registered as a cash account. The default '1930' is exempt: when no + // cash_accounts row exists it falls back to currency-only scoping + // (cashAccountId undefined), so a company reconciling its primary SEK account + // without a row behaves exactly as before this feature. Matches the status + // endpoint, which is likewise lenient for '1930'. + const { data: cashAccount } = await supabase + .from('cash_accounts') + .select('id, currency, is_primary') + .eq('company_id', companyId) + .eq('ledger_account', accountNumber) + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (!cashAccount && accountNumber !== '1930') { + return NextResponse.json( + { error: 'Okänt kassakonto för det här företaget' }, + { status: 400 }, + ) + } + const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' - const validation = await validateBody(request, RunReconciliationSchema) - if (!validation.success) return validation.response - const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data - - const accountNumber = account_number ?? '1930' - - // Defense-in-depth: reject a non-default account the company hasn't - // registered as a cash account. The default '1930' is exempt: when no - // cash_accounts row exists it falls back to currency-only scoping - // (cashAccountId undefined), so a company reconciling its primary SEK account - // without a row behaves exactly as before this feature. Matches the status - // endpoint, which is likewise lenient for '1930'. - const { data: cashAccount } = await supabase - .from('cash_accounts') - .select('id, currency, is_primary') - .eq('company_id', companyId) - .eq('ledger_account', accountNumber) - .maybeSingle() - - if (!cashAccount && accountNumber !== '1930') { - return NextResponse.json( - { error: 'Okänt kassakonto för det här företaget' }, - { status: 400 }, - ) - } - const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' - - const result = await runReconciliation(supabase, companyId, user.id, { - dateFrom: date_from, - dateTo: date_to, - accountNumber, - currency, - cashAccountId: cashAccount?.id as string | undefined, - // Only the primary account claims unassigned (NULL cash_account_id) rows: - // a secondary same-currency account must scope strictly to its own id. - includeUnassigned: Boolean(cashAccount?.is_primary), - dryRun: dry_run ?? false, - applyOnly: selected_matches?.map((m) => ({ - transactionId: m.transaction_id, - journalEntryId: m.journal_entry_id, - })), - }) - - return NextResponse.json({ - data: { - matches: result.matches.map((m) => ({ - transaction_id: m.transaction.id, - transaction_date: m.transaction.date, - transaction_description: m.transaction.description, - transaction_amount: m.transaction.amount, - journal_entry_id: m.glLine.journal_entry_id, - voucher_number: m.glLine.voucher_number, - voucher_series: m.glLine.voucher_series, - entry_date: m.glLine.entry_date, - entry_description: m.glLine.entry_description, - method: m.method, - confidence: m.confidence, + const result = await runReconciliation(supabase, companyId, user.id, { + dateFrom: date_from, + dateTo: date_to, + accountNumber, + currency, + cashAccountId: cashAccount?.id as string | undefined, + // Only the primary account claims unassigned (NULL cash_account_id) rows: + // a secondary same-currency account must scope strictly to its own id. + includeUnassigned: Boolean(cashAccount?.is_primary), + dryRun: dry_run ?? false, + applyOnly: selected_matches?.map((m) => ({ + transactionId: m.transaction_id, + journalEntryId: m.journal_entry_id, })), - applied: result.applied, - errors: result.errors, - dry_run: dry_run ?? false, - }, - }) -} + }) + + return NextResponse.json({ + data: { + matches: result.matches.map((m) => ({ + transaction_id: m.transaction.id, + transaction_date: m.transaction.date, + transaction_description: m.transaction.description, + transaction_amount: m.transaction.amount, + journal_entry_id: m.glLine.journal_entry_id, + voucher_number: m.glLine.voucher_number, + voucher_series: m.glLine.voucher_series, + entry_date: m.glLine.entry_date, + entry_description: m.glLine.entry_description, + method: m.method, + confidence: m.confidence, + })), + applied: result.applied, + errors: result.errors, + dry_run: dry_run ?? false, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/status/route.ts b/app/api/reconciliation/bank/status/route.ts index 5be29ad7..ce7c9285 100644 --- a/app/api/reconciliation/bank/status/route.ts +++ b/app/api/reconciliation/bank/status/route.ts @@ -1,57 +1,50 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' -import { requireCompanyId } from '@/lib/company/context' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'reconciliation.bank.status', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const dateFrom = searchParams.get('date_from') || undefined + const dateTo = searchParams.get('date_to') || undefined + const accountNumber = searchParams.get('account_number') || '1930' - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Look up the cash account so we can pair the bank account with the right + // currency. Comparing EUR GL movements against SEK transactions silently + // produces nonsense. + const { data: cashAccount } = await supabase + .from('cash_accounts') + .select('id, currency, is_primary') + .eq('company_id', companyId) + .eq('ledger_account', accountNumber) + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (!cashAccount && accountNumber !== '1930') { + return NextResponse.json( + { error: 'Okänt kassakonto för det här företaget' }, + { status: 400 }, + ) + } - const { searchParams } = new URL(request.url) - const dateFrom = searchParams.get('date_from') || undefined - const dateTo = searchParams.get('date_to') || undefined - const accountNumber = searchParams.get('account_number') || '1930' + const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' + const cashAccountId = cashAccount?.id as string | undefined + // Only the primary account claims unassigned (NULL cash_account_id) rows. + // A secondary same-currency account (e.g. a 1931 savings account) must not, or + // 1930's unassigned rows inflate its bank total and show a bogus difference. + const includeUnassigned = Boolean(cashAccount?.is_primary) - // Look up the cash account so we can pair the bank account with the right - // currency. Comparing EUR GL movements against SEK transactions silently - // produces nonsense. - const { data: cashAccount } = await supabase - .from('cash_accounts') - .select('id, currency, is_primary') - .eq('company_id', companyId) - .eq('ledger_account', accountNumber) - .maybeSingle() - - if (!cashAccount && accountNumber !== '1930') { - return NextResponse.json( - { error: 'Okänt kassakonto för det här företaget' }, - { status: 400 }, + const status = await getReconciliationStatus( + supabase, + companyId, + dateFrom, + dateTo, + accountNumber, + currency, + cashAccountId, + includeUnassigned, ) - } - const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' - const cashAccountId = cashAccount?.id as string | undefined - // Only the primary account claims unassigned (NULL cash_account_id) rows. - // A secondary same-currency account (e.g. a 1931 savings account) must not, or - // 1930's unassigned rows inflate its bank total and show a bogus difference. - const includeUnassigned = Boolean(cashAccount?.is_primary) - - const status = await getReconciliationStatus( - supabase, - companyId, - dateFrom, - dateTo, - accountNumber, - currency, - cashAccountId, - includeUnassigned, - ) - - return NextResponse.json({ data: status }) -} + return NextResponse.json({ data: status }) + }, +) diff --git a/app/api/reconciliation/bank/unlink/__tests__/route.test.ts b/app/api/reconciliation/bank/unlink/__tests__/route.test.ts new file mode 100644 index 00000000..bee120f8 --- /dev/null +++ b/app/api/reconciliation/bank/unlink/__tests__/route.test.ts @@ -0,0 +1,122 @@ +/** + * Tests for POST /api/reconciliation/bank/unlink. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies plus the unlinkReconciliation service. + * Covers: 401, 403 viewer, validation (400), service failure (400), and the + * happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const unlinkMock = vi.fn() +vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ + unlinkReconciliation: (...args: unknown[]) => unlinkMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const TX_ID = '44444444-4444-4444-8444-444444444444' + +describe('POST /api/reconciliation/bank/unlink', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + unlinkMock.mockResolvedValue({ success: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-uuid transaction_id with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: 'not-a-uuid' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + expect(unlinkMock).not.toHaveBeenCalled() + }) + + it('surfaces an unlink failure as 400 with the service error', async () => { + unlinkMock.mockResolvedValue({ + success: false, + error: 'Cannot unlink a categorization-created entry. Use storno to reverse it instead.', + }) + + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('storno') + }) + + it('unlinks the transaction', async () => { + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(unlinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_ID, 'user-1') + }) +}) diff --git a/app/api/reconciliation/bank/unlink/route.ts b/app/api/reconciliation/bank/unlink/route.ts index dde836a9..9401bf80 100644 --- a/app/api/reconciliation/bank/unlink/route.ts +++ b/app/api/reconciliation/bank/unlink/route.ts @@ -1,33 +1,23 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { BankUnlinkSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.unlink', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, BankUnlinkSchema) + if (!validation.success) return validation.response + const { transaction_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, BankUnlinkSchema) - if (!validation.success) return validation.response - const { transaction_id } = validation.data - - const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/unmatched-entries/route.ts b/app/api/reconciliation/bank/unmatched-entries/route.ts index c46a1fa3..f595394f 100644 --- a/app/api/reconciliation/bank/unmatched-entries/route.ts +++ b/app/api/reconciliation/bank/unmatched-entries/route.ts @@ -1,93 +1,86 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation' -import { requireCompanyId } from '@/lib/company/context' import type { Transaction } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'reconciliation.bank.unmatched_entries', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const dateFrom = searchParams.get('date_from') || undefined + const dateTo = searchParams.get('date_to') || undefined + const accountNumber = searchParams.get('account_number') || '1930' + // Optional: when set, rank the returned candidates for this specific bank + // transaction (used by the Transactions-page "Matcha mot befintlig + // verifikation" dialog). Ranking happens server-side on purpose: + // lib/reconciliation/bank-reconciliation pulls in server-only deps (event + // bus, match-log) and must never reach the client bundle. + const transactionId = searchParams.get('transaction_id') || undefined + // When true, also return vouchers already matched to a bank transaction (each + // carries linked_transaction_count) so the user can attach a second/third + // transaction to the same verifikat, the N:1 "lägga på flera" case. Default + // false keeps the list to unmatched candidates only. + const includeMatched = searchParams.get('include_matched') === 'true' - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const dateFrom = searchParams.get('date_from') || undefined - const dateTo = searchParams.get('date_to') || undefined - const accountNumber = searchParams.get('account_number') || '1930' - // Optional: when set, rank the returned candidates for this specific bank - // transaction (used by the Transactions-page "Matcha mot befintlig - // verifikation" dialog). Ranking happens server-side on purpose: - // lib/reconciliation/bank-reconciliation pulls in server-only deps (event - // bus, match-log) and must never reach the client bundle. - const transactionId = searchParams.get('transaction_id') || undefined - // When true, also return vouchers already matched to a bank transaction (each - // carries linked_transaction_count) so the user can attach a second/third - // transaction to the same verifikat, the N:1 "lägga på flera" case. Default - // false keeps the list to unmatched candidates only. - const includeMatched = searchParams.get('include_matched') === 'true' - - // Defense-in-depth: only allow account numbers that the company has actually - // registered as a cash account. Without this, a curious caller could probe - // arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly - // including '1930': the cash_accounts backfill seeds 1930 for every company - // that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new - // companies on first connection. - const { data: cashAccount } = await supabase - .from('cash_accounts') - .select('id') - .eq('company_id', companyId) - .eq('ledger_account', accountNumber) - .maybeSingle() - - if (!cashAccount) { - return NextResponse.json( - { error: 'Okänt kassakonto för det här företaget' }, - { status: 400 }, - ) - } - - const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched) - - if (transactionId) { - // company-scoped fetch (defense-in-depth). A malformed/foreign id yields no - // row → we fall through to the unranked list rather than erroring. - const { data: tx } = await supabase - .from('transactions') - .select('id, amount, date, currency, reference') - .eq('id', transactionId) + // Defense-in-depth: only allow account numbers that the company has actually + // registered as a cash account. Without this, a curious caller could probe + // arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly + // including '1930': the cash_accounts backfill seeds 1930 for every company + // that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new + // companies on first connection. + const { data: cashAccount } = await supabase + .from('cash_accounts') + .select('id') .eq('company_id', companyId) + .eq('ledger_account', accountNumber) .maybeSingle() - if (!tx) { - // transaction_id was supplied but doesn't resolve to a row in the - // caller's company: the ranking context is invalid. Return no candidates - // rather than silently falling back to the full unranked list, so a - // fabricated or foreign id can never yield a broader result set. - return NextResponse.json({ data: [] }) + if (!cashAccount) { + return NextResponse.json( + { error: 'Okänt kassakonto för det här företaget' }, + { status: 400 }, + ) } - const txCurrency = (tx.currency as string | null) ?? 'SEK' - const txDate = tx.date as string - const ranked = lines - .map((line) => { - // Score each line in isolation; confidence 0 means "no auto-match - // rule fired": the line still appears so the user can pick it - // manually (e.g. a salary or Fortnox voucher with a tweaked date). - const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency) - return { ...line, confidence: match?.confidence ?? 0 } - }) - .sort((a, b) => { - if (b.confidence !== a.confidence) return b.confidence - a.confidence - const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime()) - const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime()) - return da - db - }) - return NextResponse.json({ data: ranked }) - } + const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched) - return NextResponse.json({ data: lines }) -} + if (transactionId) { + // company-scoped fetch (defense-in-depth). A malformed/foreign id yields no + // row → we fall through to the unranked list rather than erroring. + const { data: tx } = await supabase + .from('transactions') + .select('id, amount, date, currency, reference') + .eq('id', transactionId) + .eq('company_id', companyId) + .maybeSingle() + + if (!tx) { + // transaction_id was supplied but doesn't resolve to a row in the + // caller's company: the ranking context is invalid. Return no candidates + // rather than silently falling back to the full unranked list, so a + // fabricated or foreign id can never yield a broader result set. + return NextResponse.json({ data: [] }) + } + + const txCurrency = (tx.currency as string | null) ?? 'SEK' + const txDate = tx.date as string + const ranked = lines + .map((line) => { + // Score each line in isolation; confidence 0 means "no auto-match + // rule fired": the line still appears so the user can pick it + // manually (e.g. a salary or Fortnox voucher with a tweaked date). + const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency) + return { ...line, confidence: match?.confidence ?? 0 } + }) + .sort((a, b) => { + if (b.confidence !== a.confidence) return b.confidence - a.confidence + const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime()) + const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime()) + return da - db + }) + return NextResponse.json({ data: ranked }) + } + + return NextResponse.json({ data: lines }) + }, +) diff --git a/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts b/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts index f3af3850..e1e398bd 100644 --- a/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts +++ b/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) @@ -13,28 +16,19 @@ vi.mock('@/lib/bookkeeping/currency-utils', () => ({ resolveSekAmount: vi.fn((amount: number) => amount), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) - interface QueryResult { data: unknown error: unknown } function buildSupabase( - user: { id: string } | null, customer: { id: string; name: string } | null, invoicesResult: QueryResult, entriesResult: QueryResult ) { - let invoiceCallNum = 0 - let entryCallNum = 0 return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation((table: string) => { if (table === 'customers') { return { @@ -44,7 +38,6 @@ function buildSupabase( } } if (table === 'invoices') { - invoiceCallNum += 1 return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -55,7 +48,6 @@ function buildSupabase( } } // journal_entries - entryCallNum += 1 return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -63,19 +55,28 @@ function buildSupabase( then: (resolve: (v: QueryResult) => void) => resolve(entriesResult), } }), - _stats: () => ({ invoiceCallNum, entryCallNum }), } } +function authWith(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthed() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/reports/ar-ledger/customer/[customerId]/invoices', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, null, { data: [], error: null }, { data: [], error: null }) as never - ) + unauthed() const req = createMockRequest( '/api/reports/ar-ledger/customer/cust-1/invoices' ) @@ -84,8 +85,8 @@ describe('GET /api/reports/ar-ledger/customer/[customerId]/invoices', () => { }) it('returns 404 when customer is unknown', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }, { data: [], error: null }) as never + authWith( + buildSupabase(null, { data: [], error: null }, { data: [], error: null }) ) const req = createMockRequest( '/api/reports/ar-ledger/customer/cust-1/invoices' @@ -118,13 +119,12 @@ describe('GET /api/reports/ar-ledger/customer/[customerId]/invoices', () => { source_id: 'inv-1', }, ] - mockCreateClient.mockResolvedValue( + authWith( buildSupabase( - { id: 'user-1' }, { id: 'cust-1', name: 'Acme AB' }, { data: invoices, error: null }, { data: entries, error: null } - ) as never + ) ) const req = createMockRequest( '/api/reports/ar-ledger/customer/cust-1/invoices' diff --git a/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts index ccb166a2..c2ae075e 100644 --- a/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts +++ b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -16,18 +15,9 @@ import type { ReportSourceLine } from '@/lib/reports/source-lines' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ customerId: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ customerId: string }> }>( + 'report.ar_ledger.customer_invoices', + async (request, { supabase, companyId }, { params }) => { const { customerId } = await params // Verify customer belongs to the company. @@ -149,4 +139,4 @@ export async function GET( next_cursor: null, }, }) -} +}) diff --git a/app/api/reports/ar-ledger/route.ts b/app/api/reports/ar-ledger/route.ts index de1492f4..bf783987 100644 --- a/app/api/reports/ar-ledger/route.ts +++ b/app/api/reports/ar-ledger/route.ts @@ -1,20 +1,9 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateARReconciliation } from '@/lib/reports/ar-reconciliation' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +import { withRouteContext } from '@/lib/api/with-route-context' +export const GET = withRouteContext('report.ar_ledger', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined @@ -32,4 +21,4 @@ export async function GET(request: Request) { reconciliation, }, }) -} +}) diff --git a/app/api/reports/ar-ledger/xlsx/route.ts b/app/api/reports/ar-ledger/xlsx/route.ts index 49f67a24..6df52578 100644 --- a/app/api/reports/ar-ledger/xlsx/route.ts +++ b/app/api/reports/ar-ledger/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateARLedger } from '@/lib/reports/ar-ledger' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { reportToWorkbook, textColumn, @@ -40,16 +39,7 @@ function toDate(s: string): Date | null { return isNaN(d.getTime()) ? null : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.ar_ledger.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined @@ -160,4 +150,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/audit-trail/__tests__/route.test.ts b/app/api/reports/audit-trail/__tests__/route.test.ts index 675b6521..3fc1058c 100644 --- a/app/api/reports/audit-trail/__tests__/route.test.ts +++ b/app/api/reports/audit-trail/__tests__/route.test.ts @@ -1,35 +1,38 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createMockRequest } from '@/tests/helpers' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const { supabase } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/core/audit/audit-service', () => ({ getAuditLog: vi.fn(), })) -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), - getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), -})) - -import { createClient } from '@/lib/supabase/server' import { getAuditLog } from '@/lib/core/audit/audit-service' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) const mockGetAuditLog = vi.mocked(getAuditLog) -function mockAuth(userId: string | null) { - mockCreateClient.mockResolvedValue({ - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - }), - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) +function authed() { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthed() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) } const sampleEntries = [ @@ -61,18 +64,18 @@ const sampleEntries = [ beforeEach(() => { vi.clearAllMocks() + authed() }) describe('GET /api/reports/audit-trail', () => { it('returns 401 when not authenticated', async () => { - mockAuth(null) + unauthed() const req = createMockRequest('/api/reports/audit-trail') const res = await GET(req) expect(res.status).toBe(401) }) it('returns CSV format with correct headers', async () => { - mockAuth('user-1') // eslint-disable-next-line @typescript-eslint/no-explicit-any mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 }) @@ -94,7 +97,6 @@ describe('GET /api/reports/audit-trail', () => { }) it('returns JSON format as downloadable file', async () => { - mockAuth('user-1') // eslint-disable-next-line @typescript-eslint/no-explicit-any mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 }) @@ -113,8 +115,6 @@ describe('GET /api/reports/audit-trail', () => { }) it('paginates through all entries', async () => { - mockAuth('user-1') - // First call returns 500 entries (full page), second returns 100 (last page) const bigPage = Array.from({ length: 500 }, (_, i) => ({ ...sampleEntries[0], diff --git a/app/api/reports/audit-trail/route.ts b/app/api/reports/audit-trail/route.ts index fe733535..905b1fa7 100644 --- a/app/api/reports/audit-trail/route.ts +++ b/app/api/reports/audit-trail/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getAuditLog } from '@/lib/core/audit/audit-service' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import type { AuditLogEntry, AuditAction } from '@/types' const CSV_HEADERS = 'timestamp,action,table_name,record_id,description,old_state,new_state' @@ -27,16 +26,7 @@ function entryToCSVRow(entry: AuditLogEntry): string { ].join(',') } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.audit_trail', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const format = searchParams.get('format') || 'json' @@ -94,4 +84,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/avgifter-basis/route.ts b/app/api/reports/avgifter-basis/route.ts index 903a8300..04bd0847 100644 --- a/app/api/reports/avgifter-basis/route.ts +++ b/app/api/reports/avgifter-basis/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateAvgifterBasis } from '@/lib/reports/avgifter-basis' /** @@ -8,13 +7,7 @@ import { generateAvgifterBasis } from '@/lib/reports/avgifter-basis' * Monthly breakdown by avgifter rate category for AGI reconciliation. * Per BFL: Part of räkenskapsinformation, 7-year retention. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.avgifter_basis', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) @@ -25,4 +18,4 @@ export async function GET(request: Request) { const message = err instanceof Error ? err.message : 'Kunde inte generera avgiftsunderlag' return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/app/api/reports/balance-sheet/pdf/route.ts b/app/api/reports/balance-sheet/pdf/route.ts index a3a5fd47..32a8bcdb 100644 --- a/app/api/reports/balance-sheet/pdf/route.ts +++ b/app/api/reports/balance-sheet/pdf/route.ts @@ -1,22 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balance_sheet.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -121,4 +111,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balance-sheet/xlsx/route.ts b/app/api/reports/balance-sheet/xlsx/route.ts index 22dcdc9b..d46d877d 100644 --- a/app/api/reports/balance-sheet/xlsx/route.ts +++ b/app/api/reports/balance-sheet/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import { reportToWorkbook, @@ -18,16 +17,7 @@ interface FlatRow { isSubtotal: boolean } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balance_sheet.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -158,4 +148,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balansrapport/pdf/route.ts b/app/api/reports/balansrapport/pdf/route.ts index 9b138f06..2d8d89c9 100644 --- a/app/api/reports/balansrapport/pdf/route.ts +++ b/app/api/reports/balansrapport/pdf/route.ts @@ -1,22 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateBalansrapport } from '@/lib/reports/balansrapport' import { BalansrapportPDF } from '@/lib/reports/operational-report-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balansrapport.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -82,4 +72,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balansrapport/route.ts b/app/api/reports/balansrapport/route.ts index 3d850659..64f13e16 100644 --- a/app/api/reports/balansrapport/route.ts +++ b/app/api/reports/balansrapport/route.ts @@ -1,19 +1,9 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalansrapport } from '@/lib/reports/balansrapport' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balansrapport', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -46,4 +36,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balansrapport/xlsx/route.ts b/app/api/reports/balansrapport/xlsx/route.ts index fe891859..ef38e223 100644 --- a/app/api/reports/balansrapport/xlsx/route.ts +++ b/app/api/reports/balansrapport/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalansrapport } from '@/lib/reports/balansrapport' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import { reportToWorkbook, @@ -19,16 +18,7 @@ interface FlatRow { ub: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balansrapport.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -132,4 +122,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/continuity-check/route.ts b/app/api/reports/continuity-check/route.ts index 54e0fa15..4f1548f3 100644 --- a/app/api/reports/continuity-check/route.ts +++ b/app/api/reports/continuity-check/route.ts @@ -1,22 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBalanceContinuity } from '@/lib/reports/continuity-check' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET: Validate IB/UB continuity for a fiscal period. * Query param: period_id (required) */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.continuity_check', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -33,4 +23,4 @@ export async function GET(request: Request) { { status: 400 } ) } -} +}) diff --git a/app/api/reports/full-archive/__tests__/route.test.ts b/app/api/reports/full-archive/__tests__/route.test.ts index c9827654..c69c1ef6 100644 --- a/app/api/reports/full-archive/__tests__/route.test.ts +++ b/app/api/reports/full-archive/__tests__/route.test.ts @@ -1,14 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const { supabase } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/reports/full-archive-export', () => ({ @@ -16,34 +20,35 @@ vi.mock('@/lib/reports/full-archive-export', () => ({ estimateArchiveSize: vi.fn(), })) -import { createClient } from '@/lib/supabase/server' import { generateFullArchive, estimateArchiveSize, } from '@/lib/reports/full-archive-export' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) const mockGenerate = vi.mocked(generateFullArchive) const mockEstimate = vi.mocked(estimateArchiveSize) -function mockAuth(userId: string | null) { - mockCreateClient.mockResolvedValue({ - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - }), - }, - } as any) +function authed() { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthed() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) } beforeEach(() => { vi.clearAllMocks() + authed() }) describe('GET /api/reports/full-archive', () => { it('returns 401 when not authenticated', async () => { - mockAuth(null) + unauthed() const { status, body } = await parseJsonResponse( await GET(createMockRequest('/api/reports/full-archive')) ) @@ -52,7 +57,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns estimate-only response when ?estimate=1', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 10_000_000, document_bytes: 5_000_000, @@ -80,7 +84,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns 413 archive_too_large when estimate exceeds limit', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 200 * 1024 * 1024, document_bytes: 195 * 1024 * 1024, @@ -106,7 +109,6 @@ describe('GET /api/reports/full-archive', () => { }) it('skips 413 when include_documents=false', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 200 * 1024 * 1024, document_bytes: 195 * 1024 * 1024, @@ -130,7 +132,6 @@ describe('GET /api/reports/full-archive', () => { }) it('defaults to scope=all when no params given', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 1_000_000, document_bytes: 500_000, @@ -149,7 +150,6 @@ describe('GET /api/reports/full-archive', () => { }) it('uses scope=period when period_id is provided without explicit scope', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 1_000_000, document_bytes: 500_000, @@ -172,7 +172,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns 400 when scope=period without period_id', async () => { - mockAuth('user-1') const { status, body } = await parseJsonResponse( await GET( createMockRequest('/api/reports/full-archive', { @@ -187,7 +186,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns 404 when generate throws "not found"', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 1_000_000, document_bytes: 500_000, diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts index 69f4e3bc..d8e1a60e 100644 --- a/app/api/reports/full-archive/route.ts +++ b/app/api/reports/full-archive/route.ts @@ -1,27 +1,17 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateFullArchive, estimateArchiveSize, type ArchiveScope, } from '@/lib/reports/full-archive-export' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' export const runtime = 'nodejs' export const maxDuration = 300 const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.full_archive', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const scopeParam = searchParams.get('scope') const periodId = searchParams.get('period_id') @@ -93,7 +83,7 @@ export async function GET(request: Request) { const status = message.includes('not found') ? 404 : 500 return NextResponse.json({ error: message }, { status }) } -} +}) function formatDateStamp(d: Date): string { const y = d.getUTCFullYear() diff --git a/app/api/reports/general-ledger/xlsx/route.ts b/app/api/reports/general-ledger/xlsx/route.ts index fbb3c3d3..53e47822 100644 --- a/app/api/reports/general-ledger/xlsx/route.ts +++ b/app/api/reports/general-ledger/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateGeneralLedger } from '@/lib/reports/general-ledger' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' import { reportToWorkbook, @@ -30,16 +29,7 @@ function toDate(s: string): Date | string { return isNaN(d.getTime()) ? s : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.general_ledger.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const accountFrom = searchParams.get('account_from') || undefined @@ -167,4 +157,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/income-statement/pdf/route.ts b/app/api/reports/income-statement/pdf/route.ts index d0cd2d3c..1adbf673 100644 --- a/app/api/reports/income-statement/pdf/route.ts +++ b/app/api/reports/income-statement/pdf/route.ts @@ -1,9 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStatementSection, type FinancialStatementSummaryRow } from '@/lib/reports/financial-statement-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' @@ -30,16 +29,7 @@ function sectionPrefix(section: FinancialStatementSection, prefixes: string[]): return prefixes.some((p) => acc.startsWith(p)) } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.income_statement.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -233,4 +223,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/income-statement/xlsx/route.ts b/app/api/reports/income-statement/xlsx/route.ts index 549ee5d9..edd54240 100644 --- a/app/api/reports/income-statement/xlsx/route.ts +++ b/app/api/reports/income-statement/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import { reportToWorkbook, @@ -51,16 +50,7 @@ function flatten( return rows } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.income_statement.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -185,4 +175,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/journal-register/route.ts b/app/api/reports/journal-register/route.ts index ad025158..74530163 100644 --- a/app/api/reports/journal-register/route.ts +++ b/app/api/reports/journal-register/route.ts @@ -1,19 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateJournalRegister } from '@/lib/reports/journal-register' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +import { withRouteContext } from '@/lib/api/with-route-context' +export const GET = withRouteContext('report.journal_register', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -24,4 +13,4 @@ export async function GET(request: Request) { const data = await generateJournalRegister(supabase, companyId, periodId) return NextResponse.json({ data }) -} +}) diff --git a/app/api/reports/journal-register/xlsx/route.ts b/app/api/reports/journal-register/xlsx/route.ts index be62bbec..633f1e43 100644 --- a/app/api/reports/journal-register/xlsx/route.ts +++ b/app/api/reports/journal-register/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateJournalRegister } from '@/lib/reports/journal-register' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { reportToWorkbook, textColumn, @@ -28,16 +27,7 @@ function toDate(s: string): Date | null { return isNaN(d.getTime()) ? null : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.journal_register.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -116,4 +106,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/kassaflodesanalys/pdf/route.ts b/app/api/reports/kassaflodesanalys/pdf/route.ts index cc861860..f6068b0c 100644 --- a/app/api/reports/kassaflodesanalys/pdf/route.ts +++ b/app/api/reports/kassaflodesanalys/pdf/route.ts @@ -1,21 +1,11 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys' import { KassaflodesanalysPDF } from '@/lib/reports/kassaflodesanalys-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import type { CompanySettings } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.kassaflodesanalys.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -77,4 +67,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/kassaflodesanalys/route.ts b/app/api/reports/kassaflodesanalys/route.ts index cdc07c99..cc9134b2 100644 --- a/app/api/reports/kassaflodesanalys/route.ts +++ b/app/api/reports/kassaflodesanalys/route.ts @@ -1,18 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +import { withRouteContext } from '@/lib/api/with-route-context' +export const GET = withRouteContext('report.kassaflodesanalys', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -29,4 +19,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index 08c95b40..e8f2a96f 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateTrialBalance } from '@/lib/reports/trial-balance' @@ -12,17 +12,10 @@ import { calculateVatLiability, } from '@/lib/reports/kpi' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' -import { requireCompanyId } from '@/lib/company/context' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' import type { KPIReport, KPIPreferences } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.kpi', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') if (!periodId) { @@ -201,4 +194,4 @@ export async function GET(request: Request) { } return NextResponse.json({ data: report }) -} +}) diff --git a/app/api/reports/kpi/xlsx/route.ts b/app/api/reports/kpi/xlsx/route.ts index 081683fc..f07f041f 100644 --- a/app/api/reports/kpi/xlsx/route.ts +++ b/app/api/reports/kpi/xlsx/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateTrialBalance } from '@/lib/reports/trial-balance' @@ -11,7 +11,6 @@ import { calculateAvgPaymentDays, calculateVatLiability, } from '@/lib/reports/kpi' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -43,13 +42,7 @@ interface SupplierRow { total: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') if (!periodId) { @@ -242,7 +235,7 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) function scaleToFraction(value: number | null): number | null { return value === null ? null : Math.round(value) / 100 diff --git a/app/api/reports/monthly-breakdown/route.ts b/app/api/reports/monthly-breakdown/route.ts index 92a88772..dd014f20 100644 --- a/app/api/reports/monthly-breakdown/route.ts +++ b/app/api/reports/monthly-breakdown/route.ts @@ -1,19 +1,9 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' -import { requireCompanyId } from '@/lib/company/context' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.monthly_breakdown', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -34,4 +24,4 @@ export async function GET(request: Request) { } catch { return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 }) } -} +}) diff --git a/app/api/reports/monthly-breakdown/xlsx/route.ts b/app/api/reports/monthly-breakdown/xlsx/route.ts index 89618a15..048a451b 100644 --- a/app/api/reports/monthly-breakdown/xlsx/route.ts +++ b/app/api/reports/monthly-breakdown/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -9,16 +8,7 @@ import { xlsxFilename, } from '@/lib/reports/xlsx-export' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.monthly_breakdown.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -74,4 +64,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/resultatrapport/pdf/route.ts b/app/api/reports/resultatrapport/pdf/route.ts index 015a1bf4..5db9a08d 100644 --- a/app/api/reports/resultatrapport/pdf/route.ts +++ b/app/api/reports/resultatrapport/pdf/route.ts @@ -1,23 +1,13 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateResultatrapport } from '@/lib/reports/resultatrapport' import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-template' -import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.resultatrapport.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -91,4 +81,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/resultatrapport/route.ts b/app/api/reports/resultatrapport/route.ts index 0e28218e..ab345fe8 100644 --- a/app/api/reports/resultatrapport/route.ts +++ b/app/api/reports/resultatrapport/route.ts @@ -1,20 +1,10 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateResultatrapport } from '@/lib/reports/resultatrapport' -import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.resultatrapport', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -55,4 +45,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/resultatrapport/xlsx/route.ts b/app/api/reports/resultatrapport/xlsx/route.ts index 4546f31a..ad35aac0 100644 --- a/app/api/reports/resultatrapport/xlsx/route.ts +++ b/app/api/reports/resultatrapport/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateResultatrapport } from '@/lib/reports/resultatrapport' -import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' import { @@ -19,16 +18,7 @@ interface FlatRow { prior_period: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.resultatrapport.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -148,4 +138,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/salary-journal/route.ts b/app/api/reports/salary-journal/route.ts index e347ddcd..0f71c23c 100644 --- a/app/api/reports/salary-journal/route.ts +++ b/app/api/reports/salary-journal/route.ts @@ -1,19 +1,12 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { generateSalaryJournal } from '@/lib/reports/salary-journal' /** * Lönejournal report, per BFNAR 2013:2 behandlingshistorik requirement. * Monthly/annual per-employee salary register for AGI reconciliation. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.salary_journal', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) const monthFrom = searchParams.get('month_from') ? parseInt(searchParams.get('month_from')!) : undefined @@ -26,4 +19,4 @@ export async function GET(request: Request) { const message = err instanceof Error ? err.message : 'Kunde inte generera lönejournal' return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/app/api/reports/salary-journal/xlsx/route.ts b/app/api/reports/salary-journal/xlsx/route.ts index 95775c6d..5f5802e7 100644 --- a/app/api/reports/salary-journal/xlsx/route.ts +++ b/app/api/reports/salary-journal/xlsx/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { generateSalaryJournal } from '@/lib/reports/salary-journal' import { reportToWorkbook, @@ -17,16 +16,7 @@ function toDate(s: string): Date | null { return isNaN(d.getTime()) ? null : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.salary_journal.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) const monthFrom = searchParams.get('month_from') ? parseInt(searchParams.get('month_from')!) : undefined @@ -110,4 +100,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/supplier-ledger/route.ts b/app/api/reports/supplier-ledger/route.ts index 6bae0496..173593dc 100644 --- a/app/api/reports/supplier-ledger/route.ts +++ b/app/api/reports/supplier-ledger/route.ts @@ -1,20 +1,9 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { generateReconciliation } from '@/lib/reports/supplier-reconciliation' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('report.supplier_ledger', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined @@ -32,4 +21,4 @@ export async function GET(request: Request) { reconciliation, }, }) -} +}) diff --git a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts index d985dc04..a1301ea2 100644 --- a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts +++ b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) @@ -13,26 +16,19 @@ vi.mock('@/lib/bookkeeping/currency-utils', () => ({ resolveSekAmount: vi.fn((amount: number) => amount), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) - interface QueryResult { data: unknown error: unknown } function buildSupabase( - user: { id: string } | null, supplier: { id: string; name: string } | null, invoicesResult: QueryResult, entriesResult: QueryResult ) { return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation((table: string) => { if (table === 'suppliers') { return { @@ -68,9 +64,11 @@ beforeEach(() => { describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, null, { data: [], error: null }, { data: [], error: null }) as never - ) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: buildSupabase(null, { data: [], error: null }, { data: [], error: null }), + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const req = createMockRequest( '/api/reports/supplier-ledger/supplier/sup-1/invoices' ) @@ -79,9 +77,11 @@ describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () = }) it('returns 404 when supplier is unknown', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }, { data: [], error: null }) as never - ) + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: buildSupabase(null, { data: [], error: null }, { data: [], error: null }), + error: null, + }) const req = createMockRequest( '/api/reports/supplier-ledger/supplier/sup-1/invoices' ) @@ -113,14 +113,15 @@ describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () = entry_date: '2026-05-10', }, ] - mockCreateClient.mockResolvedValue( - buildSupabase( - { id: 'user-1' }, + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: buildSupabase( { id: 'sup-1', name: 'Office Supply AB' }, { data: invoices, error: null }, { data: entries, error: null } - ) as never - ) + ), + error: null, + }) const req = createMockRequest( '/api/reports/supplier-ledger/supplier/sup-1/invoices' ) diff --git a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts index 62e898a5..31de9d52 100644 --- a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts +++ b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -13,18 +12,9 @@ import type { ReportSourceLine } from '@/lib/reports/source-lines' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ supplierId: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>( + 'report.supplier_ledger.invoices', + async (request, { supabase, companyId }, { params }) => { const { supplierId } = await params const { data: supplier } = await supabase @@ -140,4 +130,4 @@ export async function GET( next_cursor: null, }, }) -} +}) diff --git a/app/api/reports/supplier-ledger/xlsx/route.ts b/app/api/reports/supplier-ledger/xlsx/route.ts index dc7df499..b3c572e0 100644 --- a/app/api/reports/supplier-ledger/xlsx/route.ts +++ b/app/api/reports/supplier-ledger/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -19,16 +18,7 @@ interface AgingRow { total_outstanding: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.supplier_ledger.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined @@ -93,4 +83,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts index fa3277f2..95223f22 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts @@ -1,33 +1,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) - -interface AuthShape { - auth: { getUser: ReturnType } +interface SupabaseShape { from: ReturnType } function buildSupabase( - user: { id: string } | null, account: { account_number: string; account_name: string } | null, linesResult: { data: unknown; error: unknown } -): AuthShape { +): SupabaseShape { return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation((table: string) => { if (table === 'chart_of_accounts') { const chain = { @@ -57,15 +52,25 @@ function buildSupabase( } } +function authOk(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function authFail(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, null, { data: [], error: null }) as never - ) + authFail(buildSupabase(null, { data: [], error: null })) const req = createMockRequest( '/api/reports/trial-balance/account/1930/sources', { searchParams: { fiscal_period_id: 'period-1' } } @@ -75,9 +80,7 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }) it('returns 400 when fiscal_period_id is missing', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }) as never - ) + authOk(buildSupabase(null, { data: [], error: null })) const req = createMockRequest( '/api/reports/trial-balance/account/1930/sources' ) @@ -86,9 +89,7 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }) it('returns 404 when account is unknown for the company', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }) as never - ) + authOk(buildSupabase(null, { data: [], error: null })) const req = createMockRequest( '/api/reports/trial-balance/account/9999/sources', { searchParams: { fiscal_period_id: 'period-1' } } @@ -100,12 +101,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => it('returns 400 when the cursor date component is not a structural ISO date', async () => { // Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a // malformed date component must still be rejected structurally. - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: [], error: null } - ) as never + ) ) const req = createMockRequest( '/api/reports/trial-balance/account/1930/sources', @@ -148,12 +148,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }, }, ] - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: linesData, error: null } - ) as never + ) ) const req = createMockRequest( @@ -233,12 +232,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }, ] - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: linesData, error: null } - ) as never + ) ) const req = createMockRequest( @@ -292,12 +290,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }, ] - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: linesData, error: null } - ) as never + ) ) const req = createMockRequest( @@ -345,12 +342,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => a.journal_entry_id < b.journal_entry_id ? 1 : -1 ) - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '3001', account_name: 'Försäljning' }, { data: shuffled, error: null } - ) as never + ) ) const req = createMockRequest( diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts index 0b9b1d80..7783dce6 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -17,18 +16,9 @@ import type { ReportSourceLine } from '@/lib/reports/source-lines' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ accountNumber: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ accountNumber: string }> }>( + 'report.trial_balance.account_sources', + async (request, { supabase, companyId }, { params }) => { const { accountNumber } = await params const { searchParams } = new URL(request.url) @@ -179,4 +169,4 @@ export async function GET( next_cursor, }, }) -} +}) diff --git a/app/api/reports/trial-balance/route.ts b/app/api/reports/trial-balance/route.ts index 5aeee60e..02d2b377 100644 --- a/app/api/reports/trial-balance/route.ts +++ b/app/api/reports/trial-balance/route.ts @@ -1,18 +1,8 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateTrialBalance } from '@/lib/reports/trial-balance' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('report.trial_balance', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -29,4 +19,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/trial-balance/xlsx/route.ts b/app/api/reports/trial-balance/xlsx/route.ts index d12bd758..c1581764 100644 --- a/app/api/reports/trial-balance/xlsx/route.ts +++ b/app/api/reports/trial-balance/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateTrialBalance } from '@/lib/reports/trial-balance' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -11,16 +10,7 @@ import { } from '@/lib/reports/xlsx-export' import type { TrialBalanceRow } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.trial_balance.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -91,4 +81,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/vacation-liability/route.ts b/app/api/reports/vacation-liability/route.ts index ccad42ef..72011893 100644 --- a/app/api/reports/vacation-liability/route.ts +++ b/app/api/reports/vacation-liability/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { generateVacationLiability } from '@/lib/reports/vacation-liability' /** @@ -8,13 +7,7 @@ import { generateVacationLiability } from '@/lib/reports/vacation-liability' * Per-employee vacation liability (accounts 2920 + 2940). * Required for year-end closing. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.vacation_liability', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) @@ -25,4 +18,4 @@ export async function GET(request: Request) { const message = err instanceof Error ? err.message : 'Kunde inte generera semesterlöneskuld' return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/app/api/reports/vat-declaration/eskd/__tests__/route.test.ts b/app/api/reports/vat-declaration/eskd/__tests__/route.test.ts new file mode 100644 index 00000000..3e7e0947 --- /dev/null +++ b/app/api/reports/vat-declaration/eskd/__tests__/route.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' + +const mockSupabase = { + auth: { getUser: vi.fn() }, + from: 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/reports/vat-declaration', () => ({ + calculateVatDeclaration: vi.fn(), +})) + +import { GET } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { calculateVatDeclaration } from '@/lib/reports/vat-declaration' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function companySettingsQuery(data: unknown) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data, error: null }), + } +} + +function makeDeclaration() { + return { + period: { start: '2026-07-01', end: '2026-09-30' }, + rutor: { + ruta05: 100000, ruta06: 0, ruta07: 0, ruta08: 0, + ruta10: 25000, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 0, ruta31: 0, ruta32: 0, + ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0, + ruta48: 3200, + ruta49: 21800, + ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0, + }, + } +} + +describe('GET /api/reports/vat-declaration/eskd', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: mockSupabase as never, + error: null, + }) + mockSupabase.from.mockReturnValue( + companySettingsQuery({ + company_name: 'Testbolaget AB', + org_number: '5566778899', + vat_number: 'SE556677889901', + accounting_method: 'accrual', + }), + ) + vi.mocked(calculateVatDeclaration).mockResolvedValue(makeDeclaration() as never) + }) + + 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 req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(401) + }) + + it('returns 400 when period params are missing', async () => { + const req = new Request('http://localhost/api/reports/vat-declaration/eskd') + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when company settings are missing', async () => { + mockSupabase.from.mockReturnValue(companySettingsQuery(null)) + const req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(404) + }) + + it('returns 400 when the org number is missing or invalid', async () => { + mockSupabase.from.mockReturnValue( + companySettingsQuery({ + company_name: 'Testbolaget AB', + org_number: null, + accounting_method: 'accrual', + }), + ) + const req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(400) + expect(calculateVatDeclaration).not.toHaveBeenCalled() + }) + + it('happy path: returns an eSKD XML attachment', async () => { + const req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/xml; charset=ISO-8859-1') + expect(res.headers.get('Content-Disposition')).toContain( + 'momsdeklaration-2026-07-01--2026-09-30.xml', + ) + const body = await res.text() + expect(body).toContain('') + expect(body).toContain('556677-8899') + expect(body).toContain('202609') + expect(body).toContain('21800') + expect(calculateVatDeclaration).toHaveBeenCalledOnce() + }) +}) diff --git a/app/api/reports/vat-declaration/eskd/route.ts b/app/api/reports/vat-declaration/eskd/route.ts new file mode 100644 index 00000000..3d8cbe33 --- /dev/null +++ b/app/api/reports/vat-declaration/eskd/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { calculateVatDeclaration } from '@/lib/reports/vat-declaration' +import { buildESkdFile } from '@/lib/reports/vat-eskd-file' +import type { VatPeriodType, AccountingMethod } from '@/types' + +/** + * Momsdeklaration eSKDUpload (v6.0) XML file for filing at skatteverket.se via + * "Deklarera via fil". Unlike the PDF sibling route (a read/record copy), this + * is a real submission artifact the user uploads, reviews, signs and sends. The + * declaration is computed purely from the bookkeeping, so no Skatteverket + * connection is required. See lib/reports/vat-eskd-file.ts. + */ +export const GET = withRouteContext( + 'reports.vat-declaration.eskd', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const periodType = searchParams.get('periodType') as VatPeriodType | null + const yearStr = searchParams.get('year') + const periodStr = searchParams.get('period') + // Yearly = räkenskapsår (see the main vat-declaration route); ignored for + // monthly/quarterly. + const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined + + if (!periodType || !yearStr || !periodStr) { + return NextResponse.json( + { error: 'periodType, year, and period are required' }, + { status: 400 }, + ) + } + if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { + return NextResponse.json({ error: 'Invalid periodType' }, { status: 400 }) + } + const year = parseInt(yearStr, 10) + const period = parseInt(periodStr, 10) + if (isNaN(year) || isNaN(period)) { + return NextResponse.json({ error: 'Invalid year or period' }, { status: 400 }) + } + + const { data: companyRow } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single() + + if (!companyRow) { + return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) + } + + // The eSKD header requires a valid 10-digit OrgNr; without it the file is an + // "avvisande fel" Skatteverket rejects, so fail honestly up front instead of + // handing the user a file that bounces at upload. 12-digit century-prefixed + // values are fine: the builder strips the prefix (settings rows predating + // org-number normalization hold them, and settings PUT can no longer fix + // org_number after onboarding, so rejecting 12 digits would be a dead end). + const orgDigits = (companyRow.org_number ?? '').replace(/\D/g, '') + if (orgDigits.length !== 10 && orgDigits.length !== 12) { + return NextResponse.json( + { + error: + 'Organisationsnummer saknas eller är ogiltigt. Ange ett giltigt organisationsnummer i företagsinställningarna för att skapa momsdeklarationsfilen.', + }, + { status: 400 }, + ) + } + + const accountingMethod = (companyRow.accounting_method as AccountingMethod) || 'accrual' + + const declaration = await calculateVatDeclaration( + supabase, + companyId, + periodType, + year, + period, + accountingMethod, + { fiscalPeriodId }, + ) + + const xml = buildESkdFile(declaration.rutor, { + orgNumber: companyRow.org_number, + periodEnd: declaration.period.end, + }) + + const filename = `momsdeklaration-${declaration.period.start}--${declaration.period.end}.xml` + return new Response(new Uint8Array(Buffer.from(xml, 'latin1')), { + headers: { + 'Content-Type': 'application/xml; charset=ISO-8859-1', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + }, +) diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/__tests__/fix.test.ts b/app/api/reports/vat-declaration/rc-basis-gaps/__tests__/fix.test.ts new file mode 100644 index 00000000..eb59945a --- /dev/null +++ b/app/api/reports/vat-declaration/rc-basis-gaps/__tests__/fix.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for POST /api/reports/vat-declaration/rc-basis-gaps/fix. + * + * Exercises the route through the real withRouteContext wrapper, mocking its + * auth/company/write dependencies and correctEntry(). Covers: 401, viewer 403 + * (the route corrects posted entries, so requireWrite must gate it), + * validation 400, entry 404, and the happy-path correction. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const correctEntryMock = vi.fn() +vi.mock('@/lib/core/bookkeeping/storno-service', () => ({ + correctEntry: (...args: unknown[]) => correctEntryMock(...args), +})) + +import { POST } from '../fix/route' + +const ENTRY_ID = '11111111-1111-4111-8111-111111111111' + +function fixRequest(body: Record) { + return createMockRequest('/api/reports/vat-declaration/rc-basis-gaps/fix', { + method: 'POST', + body, + }) +} + +const validBody = { + entryId: ENTRY_ID, + supplierType: 'eu_business', + supplyType: 'service', +} + +describe('POST /api/reports/vat-declaration/rc-basis-gaps/fix', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer-role member (requireWrite gate)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + expect(response.status).toBe(403) + expect(correctEntryMock).not.toHaveBeenCalled() + }) + + it('returns 400 for an invalid body (bad supplierType)', async () => { + const response = await POST( + fixRequest({ ...validBody, supplierType: 'martian_business' }), + { params: Promise.resolve({}) }, + ) + const { status } = await parseJsonResponse(response) + expect(status).toBe(400) + }) + + it('returns 404 when the entry does not exist in the company', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_FOUND') + }) + + it('corrects a posted entry with missing basis lines (happy path)', async () => { + enqueue({ + data: { + id: ENTRY_ID, + status: 'posted', + lines: [ + { + account_number: '2614', + debit_amount: 0, + credit_amount: 2500, + line_description: 'Beräknad utgående moms EU-tjänst', + }, + { + account_number: '2645', + debit_amount: 2500, + credit_amount: 0, + line_description: 'Beräknad ingående moms', + }, + ], + }, + }) + correctEntryMock.mockResolvedValue({ + reversal: { id: 'rev-1' }, + corrected: { id: 'cor-1' }, + }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + data: { reversalId: string; correctedId: string; basisAccount: string; basisAmount: number } + }>(response) + + expect(status).toBe(200) + expect(body.data.reversalId).toBe('rev-1') + expect(body.data.correctedId).toBe('cor-1') + expect(correctEntryMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + ENTRY_ID, + expect.any(Array), + ) + }) +}) diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts index b49166ea..2a4d5781 100644 --- a/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts +++ b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts @@ -179,4 +179,5 @@ export const POST = withRouteContext( }) } }, + { requireWrite: true }, ) diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts index 45d13bc9..14dd71c5 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts @@ -1,27 +1,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) +interface SupabaseShape { + from: ReturnType +} function buildSupabase( - user: { id: string } | null, linesResult: { data: unknown; error: unknown } -) { +): SupabaseShape { return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation(() => ({ select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -39,15 +39,25 @@ function buildSupabase( } } +function authOk(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function authFail(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, { data: [], error: null }) as never - ) + authFail(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', { searchParams: { periodType: 'monthly', year: '2026', period: '5' } } @@ -57,9 +67,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }) it('returns 400 when period params are missing', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never - ) + authOk(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources' ) @@ -68,9 +76,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }) it('returns 404 when ruta has no underlying BAS accounts', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never - ) + authOk(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/99/sources', { searchParams: { periodType: 'monthly', year: '2026', period: '5' } } @@ -96,9 +102,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }, }, ] - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never - ) + authOk(buildSupabase({ data: linesData, error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', @@ -123,9 +127,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { it('returns 400 when the cursor date component is not a structural ISO date', async () => { // Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a // malformed date component must still be rejected structurally. - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never - ) + authOk(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', { @@ -189,9 +191,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }, }, ] - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never - ) + authOk(buildSupabase({ data: linesData, error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts index 5a146e4a..5593e768 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { ACCOUNT_RUTA, @@ -24,18 +23,9 @@ import type { VatDeclarationRutor, VatPeriodType } from '@/types' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ ruta: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>( + 'report.vat_declaration.ruta_sources', + async (request, { supabase, companyId }, { params }) => { const { ruta: rutaParam } = await params const { searchParams } = new URL(request.url) @@ -193,4 +183,4 @@ export async function GET( next_cursor, }, }) -} +}) diff --git a/app/api/reports/vat-declaration/xlsx/route.ts b/app/api/reports/vat-declaration/xlsx/route.ts index d015604a..20d790c0 100644 --- a/app/api/reports/vat-declaration/xlsx/route.ts +++ b/app/api/reports/vat-declaration/xlsx/route.ts @@ -1,10 +1,9 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { calculateVatDeclaration, formatPeriodLabel, } from '@/lib/reports/vat-declaration' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -24,16 +23,7 @@ interface RutaRow { amount: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.vat_declaration.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodType = searchParams.get('periodType') as VatPeriodType | null const yearStr = searchParams.get('year') @@ -116,4 +106,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/salary/employees/[id]/__tests__/route.test.ts b/app/api/salary/employees/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..7873f068 --- /dev/null +++ b/app/api/salary/employees/[id]/__tests__/route.test.ts @@ -0,0 +1,75 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id] (GET/PATCH/DELETE). + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers 401 (unauth), 403 (viewer role), and a DELETE happy path + * (soft delete, BFL retention). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getCompanyEntityType: vi.fn().mockResolvedValue('aktiebolag'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { DELETE } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +describe('DELETE /api/salary/employees/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE(createMockRequest('/api/salary/employees/emp-1', { method: 'DELETE' }), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE(createMockRequest('/api/salary/employees/emp-1', { method: 'DELETE' }), params) + expect(response.status).toBe(403) + }) + + it('soft-deletes the employee (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) + + const response = await DELETE(createMockRequest('/api/salary/employees/emp-1', { method: 'DELETE' }), params) + const { status, body } = await parseJsonResponse<{ data: { id: string; is_active: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ id: 'emp-1', is_active: false }) + }) +}) diff --git a/app/api/salary/employees/[id]/absence/__tests__/route.test.ts b/app/api/salary/employees/[id]/absence/__tests__/route.test.ts new file mode 100644 index 00000000..cebecbbc --- /dev/null +++ b/app/api/salary/employees/[id]/absence/__tests__/route.test.ts @@ -0,0 +1,86 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/absence (POST upsert). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/absence', { method: 'POST', body }) +} + +describe('POST /api/salary/employees/[id]/absence', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + expect(response.status).toBe(403) + }) + + it('upserts an absence day (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) // loadEmployee + enqueue({ data: null }) // delete existing + enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // insert + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('abs-1') + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // loadEmployee → not found + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/absence/route.ts b/app/api/salary/employees/[id]/absence/route.ts index 8a8f9d44..09c04ef8 100644 --- a/app/api/salary/employees/[id]/absence/route.ts +++ b/app/api/salary/employees/[id]/absence/route.ts @@ -1,22 +1,21 @@ import { z } from 'zod' -import { createClient } from '@/lib/supabase/server' +import type { SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody, validateQuery } from '@/lib/api/validate' import { UpsertAbsenceDaySchema, AbsenceRangeQuerySchema, AbsenceTypeSchema, } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) ensureInitialized() async function loadEmployee( - supabase: Awaited>, + supabase: SupabaseClient, employeeId: string, companyId: string, ) { @@ -29,106 +28,94 @@ async function loadEmployee( return data } -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = 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.employees.absence.list', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const companyId = await requireCompanyId(supabase, user.id) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const query = validateQuery(request, AbsenceRangeQuerySchema) - if (!query.success) return query.response - - const { data, error } = await supabase - .from('salary_absence_days') - .select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at') - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .gte('absence_date', query.data.from) - .lte('absence_date', query.data.to) - .order('absence_date', { ascending: true }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = 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) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const validation = await validateBody(request, UpsertAbsenceDaySchema) - if (!validation.success) return validation.response - const body = validation.data - - // Upsert via DELETE+INSERT on the natural key (employee, date, type) so the - // notes/hours/run-link can be replaced cleanly. The unique index makes ON - // CONFLICT viable too, but Supabase's typed client doesn't expose - // onConflict for our composite key without a named constraint name: - // delete-then-insert keeps the pattern consistent with token-store.ts. - const { error: deleteError } = await supabase - .from('salary_absence_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .eq('absence_date', body.absence_date) - .eq('absence_type', body.absence_type) - - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) - } - - const { data, error } = await supabase - .from('salary_absence_days') - .insert({ - company_id: companyId, - employee_id: employeeId, - absence_date: body.absence_date, - absence_type: body.absence_type, - hours: body.hours, - notes: body.notes ?? null, - salary_run_employee_id: body.salary_run_employee_id ?? null, - }) - .select() - .single() - - if (error) { - // The 24h cap trigger raises check_violation when worked + absence > 24h - // for the same date. Surface a clean 409 with the Swedish message. - if (error.message?.includes('Total tid') || error.code === '23514') { - return NextResponse.json({ error: error.message }, { status: 409 }) + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }, { status: 201 }) -} + const query = validateQuery(request, AbsenceRangeQuerySchema) + if (!query.success) return query.response + + const { data, error } = await supabase + .from('salary_absence_days') + .select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at') + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .gte('absence_date', query.data.from) + .lte('absence_date', query.data.to) + .order('absence_date', { ascending: true }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) + }, +) + +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.absence.upsert', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params + + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + const validation = await validateBody(request, UpsertAbsenceDaySchema) + if (!validation.success) return validation.response + const body = validation.data + + // Upsert via DELETE+INSERT on the natural key (employee, date, type) so the + // notes/hours/run-link can be replaced cleanly. The unique index makes ON + // CONFLICT viable too, but Supabase's typed client doesn't expose + // onConflict for our composite key without a named constraint name: + // delete-then-insert keeps the pattern consistent with token-store.ts. + const { error: deleteError } = await supabase + .from('salary_absence_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .eq('absence_date', body.absence_date) + .eq('absence_type', body.absence_type) + + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + const { data, error } = await supabase + .from('salary_absence_days') + .insert({ + company_id: companyId, + employee_id: employeeId, + absence_date: body.absence_date, + absence_type: body.absence_type, + hours: body.hours, + notes: body.notes ?? null, + salary_run_employee_id: body.salary_run_employee_id ?? null, + }) + .select() + .single() + + if (error) { + // The 24h cap trigger raises check_violation when worked + absence > 24h + // for the same date. Surface a clean 409 with the Swedish message. + if (error.message?.includes('Total tid') || error.code === '23514') { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) // Two modes: ?date=YYYY-MM-DD&type=... (single row) or ?from=…&to=… (range). // We don't reuse AbsenceRangeQuerySchema.partial() because Zod refuses @@ -140,57 +127,50 @@ const DeleteQuerySchema = z.object({ type: AbsenceTypeSchema.optional(), }) -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.absence.delete', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const query = validateQuery(request, DeleteQuerySchema) + if (!query.success) return query.response + const { date, type, from, to } = query.data - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + // Two delete modes: a single (date, type) row, or a date range. + const hasSingle = !!date + const hasRange = !!from && !!to + if (!hasSingle && !hasRange) { + return NextResponse.json( + { error: 'Ange antingen ?date=YYYY-MM-DD&type=... eller ?from=...&to=...' }, + { status: 400 }, + ) + } - const query = validateQuery(request, DeleteQuerySchema) - if (!query.success) return query.response - const { date, type, from, to } = query.data + let q = supabase + .from('salary_absence_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) - // Two delete modes: a single (date, type) row, or a date range. - const hasSingle = !!date - const hasRange = !!from && !!to - if (!hasSingle && !hasRange) { - return NextResponse.json( - { error: 'Ange antingen ?date=YYYY-MM-DD&type=... eller ?from=...&to=...' }, - { status: 400 }, - ) - } + if (hasSingle) { + q = q.eq('absence_date', date!) + if (type) q = q.eq('absence_type', type) + } else { + q = q.gte('absence_date', from!).lte('absence_date', to!) + if (type) q = q.eq('absence_type', type) + } - let q = supabase - .from('salary_absence_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) + const { error } = await q + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (hasSingle) { - q = q.eq('absence_date', date!) - if (type) q = q.eq('absence_type', type) - } else { - q = q.gte('absence_date', from!).lte('absence_date', to!) - if (type) q = q.eq('absence_type', type) - } - - const { error } = await q - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { ok: true } }) -} + return NextResponse.json({ data: { ok: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts b/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts new file mode 100644 index 00000000..9062a548 --- /dev/null +++ b/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts @@ -0,0 +1,76 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/benefits/[benefitId] + * (PATCH/DELETE). Runs through the real withRouteContext wrapper; mocks auth/ + * company/write and injects a queued Supabase mock via requireAuth. Covers 401, + * 403 (viewer), and a DELETE happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { DELETE } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1', benefitId: 'ben-1' }) } as never + +function del() { + return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'DELETE' }) +} + +describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE(del(), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE(del(), params) + expect(response.status).toBe(403) + }) + + it('deletes the benefit (happy path)', async () => { + enqueue({ data: null }) // delete (no error) + + const response = await DELETE(del(), params) + const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ id: 'ben-1', deleted: true }) + }) +}) diff --git a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts index 3da76f2c..71b50e89 100644 --- a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts +++ b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts @@ -1,103 +1,87 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas' import { calculateBikeBenefit } from '@/lib/salary/benefits' ensureInitialized() -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string; benefitId: string }> } -) { - const { id, benefitId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId: string }> }>( + 'salary.employees.benefits.update', + async (request, { supabase, companyId }, { params }) => { + const { id, benefitId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validation = await validateBody(request, UpdateEmployeeBenefitSchema) + if (!validation.success) return validation.response + const body = validation.data - const companyId = await requireCompanyId(supabase, user.id) + const { data: existing, error: fetchError } = await supabase + .from('employee_benefits') + .select('benefit_type, metadata') + .eq('id', benefitId) + .eq('employee_id', id) + .eq('company_id', companyId) + .single() - const validation = await validateBody(request, UpdateEmployeeBenefitSchema) - if (!validation.success) return validation.response - const body = validation.data - - const { data: existing, error: fetchError } = await supabase - .from('employee_benefits') - .select('benefit_type, metadata') - .eq('id', benefitId) - .eq('employee_id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !existing) { - return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) - } - - const updates: Record = { ...body } - - if (body.annual_market_value !== undefined) { - if (existing.benefit_type !== 'bike') { - return NextResponse.json( - { error: 'annual_market_value gäller endast cykelförmån' }, - { status: 400 }, - ) + if (fetchError || !existing) { + return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) } - const calc = calculateBikeBenefit(body.annual_market_value) - updates.monthly_value = calc.monthlyValue - updates.metadata = { - ...(existing.metadata as Record ?? {}), - ...(body.metadata ?? {}), - annual_market_value: body.annual_market_value, - annual_taxable: calc.annualTaxable, - tax_free_portion: calc.taxFreePortion, + + const updates: Record = { ...body } + + if (body.annual_market_value !== undefined) { + if (existing.benefit_type !== 'bike') { + return NextResponse.json( + { error: 'annual_market_value gäller endast cykelförmån' }, + { status: 400 }, + ) + } + const calc = calculateBikeBenefit(body.annual_market_value) + updates.monthly_value = calc.monthlyValue + updates.metadata = { + ...(existing.metadata as Record ?? {}), + ...(body.metadata ?? {}), + annual_market_value: body.annual_market_value, + annual_taxable: calc.annualTaxable, + tax_free_portion: calc.taxFreePortion, + } + delete (updates as Record).annual_market_value } - delete (updates as Record).annual_market_value - } - const { data, error } = await supabase - .from('employee_benefits') - .update(updates) - .eq('id', benefitId) - .eq('employee_id', id) - .eq('company_id', companyId) - .select() - .single() + const { data, error } = await supabase + .from('employee_benefits') + .update(updates) + .eq('id', benefitId) + .eq('employee_id', id) + .eq('company_id', companyId) + .select() + .single() - if (error || !data) { - return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) - } + if (error || !data) { + return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) + } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string; benefitId: string }> } -) { - const { id, benefitId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string; benefitId: string }> }>( + 'salary.employees.benefits.delete', + async (_request, { supabase, companyId }, { params }) => { + const { id, benefitId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { error } = await supabase + .from('employee_benefits') + .delete() + .eq('id', benefitId) + .eq('employee_id', id) + .eq('company_id', companyId) - const companyId = await requireCompanyId(supabase, user.id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const { error } = await supabase - .from('employee_benefits') - .delete() - .eq('id', benefitId) - .eq('employee_id', id) - .eq('company_id', companyId) - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data: { id: benefitId, deleted: true } }) -} + return NextResponse.json({ data: { id: benefitId, deleted: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts b/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts new file mode 100644 index 00000000..76de831c --- /dev/null +++ b/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts @@ -0,0 +1,92 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/benefits (POST create). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/benefits', { method: 'POST', body }) +} + +const validBenefit = { + benefit_type: 'other', + description: 'Friskvård', + monthly_value: 500, + valid_from: '2026-01-01', +} + +describe('POST /api/salary/employees/[id]/benefits', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(403) + }) + + it('creates a benefit (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + enqueue({ data: { id: 'ben-1', benefit_type: 'other', monthly_value: 500 } }) // insert + + const response = await POST(post(validBenefit), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('ben-1') + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // employee ownership check → not found + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/benefits/route.ts b/app/api/salary/employees/[id]/benefits/route.ts index 2aadd883..4e56479d 100644 --- a/app/api/salary/employees/[id]/benefits/route.ts +++ b/app/api/salary/employees/[id]/benefits/route.ts @@ -1,97 +1,83 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { CreateEmployeeBenefitSchema } from '@/lib/api/schemas' import { calculateBikeBenefit } from '@/lib/salary/benefits' 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.employees.benefits.list', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('employee_benefits') + .select('*') + .eq('employee_id', id) + .eq('company_id', companyId) + .order('valid_from', { ascending: false }) - const { data, error } = await supabase - .from('employee_benefits') - .select('*') - .eq('employee_id', id) - .eq('company_id', companyId) - .order('valid_from', { ascending: false }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ data }) + }, +) - return NextResponse.json({ data }) -} +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.benefits.create', + async (request, { supabase, companyId, user }, { params }) => { + const { id } = await params -export async function POST( - 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 validation = await validateBody(request, CreateEmployeeBenefitSchema) + if (!validation.success) return validation.response + const body = validation.data - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Confirm employee belongs to the company + const { data: emp } = await supabase + .from('employees') + .select('id') + .eq('id', id) + .eq('company_id', companyId) + .single() + if (!emp) return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - const companyId = await requireCompanyId(supabase, user.id) + // Bike benefit: derive monthly_value + metadata from the annual market value + let monthlyValue = body.monthly_value ?? 0 + let metadata: Record = body.metadata ?? {} - const validation = await validateBody(request, CreateEmployeeBenefitSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Confirm employee belongs to the company - const { data: emp } = await supabase - .from('employees') - .select('id') - .eq('id', id) - .eq('company_id', companyId) - .single() - if (!emp) return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - - // Bike benefit: derive monthly_value + metadata from the annual market value - let monthlyValue = body.monthly_value ?? 0 - let metadata: Record = body.metadata ?? {} - - if (body.benefit_type === 'bike' && body.annual_market_value !== undefined) { - const calc = calculateBikeBenefit(body.annual_market_value) - monthlyValue = calc.monthlyValue - metadata = { - ...metadata, - annual_market_value: body.annual_market_value, - annual_taxable: calc.annualTaxable, - tax_free_portion: calc.taxFreePortion, + if (body.benefit_type === 'bike' && body.annual_market_value !== undefined) { + const calc = calculateBikeBenefit(body.annual_market_value) + monthlyValue = calc.monthlyValue + metadata = { + ...metadata, + annual_market_value: body.annual_market_value, + annual_taxable: calc.annualTaxable, + tax_free_portion: calc.taxFreePortion, + } } - } - const { data, error } = await supabase - .from('employee_benefits') - .insert({ - employee_id: id, - company_id: companyId, - user_id: user.id, - benefit_type: body.benefit_type, - description: body.description, - monthly_value: monthlyValue, - valid_from: body.valid_from, - valid_to: body.valid_to ?? null, - metadata, - is_active: body.is_active ?? true, - }) - .select() - .single() + const { data, error } = await supabase + .from('employee_benefits') + .insert({ + employee_id: id, + company_id: companyId, + user_id: user.id, + benefit_type: body.benefit_type, + description: body.description, + monthly_value: monthlyValue, + valid_from: body.valid_from, + valid_to: body.valid_to ?? null, + metadata, + is_active: body.is_active ?? true, + }) + .select() + .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ data }, { status: 201 }) -} + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts index 41e73b3f..90213b55 100644 --- a/app/api/salary/employees/[id]/route.ts +++ b/app/api/salary/employees/[id]/route.ts @@ -1,181 +1,161 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateEmployeeSchema } from '@/lib/api/schemas' -import { requireCompanyId, getCompanyEntityType } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { getCompanyEntityType } from '@/lib/company/context' import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account' 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.employees.get', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params - const companyId = await requireCompanyId(supabase, user.id) + const { data: employee, error } = await supabase + .from('employees') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const { data: employee, error } = await supabase - .from('employees') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error || !employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - return NextResponse.json({ - data: { - ...employee, - personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)), - }, - }) -} - -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) - - const validation = await validateBody(request, UpdateEmployeeSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Load existing employee for merged validation - const { data: existing, error: fetchError } = await supabase - .from('employees') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !existing) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - // Merged validation: combine existing + updates to check full integrity - const merged = { ...existing, ...body } - const mergedErrors: string[] = [] - - if (merged.salary_type === 'monthly' && (!merged.monthly_salary || merged.monthly_salary <= 0)) { - mergedErrors.push('Månadslön krävs och måste vara större än 0 för månadslöneform') - } - if (merged.salary_type === 'hourly' && (!merged.hourly_rate || merged.hourly_rate <= 0)) { - mergedErrors.push('Timlön krävs och måste vara större än 0 för timlöneform') - } - if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) { - mergedErrors.push('Skattetabell krävs för A-skatt anställda') - } - if (mergedErrors.length > 0) { - return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 }) - } - - // Validate bank details only when the caller actually changes them, so a - // legacy employee with incomplete/free-text bank data (from before this - // validation existed) can still be edited in unrelated ways. Validate the - // merged pair so both-or-neither reflects the row's real end state. - const clearingChanged = - body.clearing_number !== undefined && body.clearing_number !== existing.clearing_number - const accountChanged = - body.bank_account_number !== undefined && body.bank_account_number !== existing.bank_account_number - if (clearingChanged || accountChanged) { - const bankIssues = validateEmployeeBankAccount(merged.clearing_number, merged.bank_account_number) - if (bankIssues.length > 0) { - return NextResponse.json({ error: bankIssues.map((i) => i.message).join('. ') }, { status: 400 }) + if (error || !employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - } - // Only when the caller is changing employment_type: block setting an EF's - // owner/board on payroll (mirrors the enforce_ef_no_owner_employee trigger, - // which fires on UPDATE OF employment_type: so unrelated edits to any - // grandfathered row aren't blocked). #782 - if (body.employment_type !== undefined) { - const entityType = await getCompanyEntityType(supabase, companyId) - if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) { - return NextResponse.json({ error: EF_OWNER_EMPLOYMENT_ERROR }, { status: 400 }) + return NextResponse.json({ + data: { + ...employee, + personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)), + }, + }) + }, +) + +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.update', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params + + const validation = await validateBody(request, UpdateEmployeeSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Load existing employee for merged validation + const { data: existing, error: fetchError } = await supabase + .from('employees') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !existing) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - } - // Build update object - const updates: Record = { ...body } + // Merged validation: combine existing + updates to check full integrity + const merged = { ...existing, ...body } + const mergedErrors: string[] = [] - // Handle personnummer update if provided - if (body.personnummer) { - const pnrValidation = validatePersonnummer(body.personnummer) - if (!pnrValidation.valid) { - return NextResponse.json({ error: pnrValidation.error }, { status: 400 }) + if (merged.salary_type === 'monthly' && (!merged.monthly_salary || merged.monthly_salary <= 0)) { + mergedErrors.push('Månadslön krävs och måste vara större än 0 för månadslöneform') } - updates.personnummer = encryptPersonnummer(body.personnummer) - updates.personnummer_last4 = extractLast4(body.personnummer) - } - - const { data: updated, error } = await supabase - .from('employees') - .update(updates) - .eq('id', id) - .eq('company_id', companyId) - .select() - .single() - - if (error) { - if (error.code === '23505') { - return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 }) + if (merged.salary_type === 'hourly' && (!merged.hourly_rate || merged.hourly_rate <= 0)) { + mergedErrors.push('Timlön krävs och måste vara större än 0 för timlöneform') + } + if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) { + mergedErrors.push('Skattetabell krävs för A-skatt anställda') + } + if (mergedErrors.length > 0) { + return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ - data: { - ...updated, - personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)), - }, - }) -} + // Validate bank details only when the caller actually changes them, so a + // legacy employee with incomplete/free-text bank data (from before this + // validation existed) can still be edited in unrelated ways. Validate the + // merged pair so both-or-neither reflects the row's real end state. + const clearingChanged = + body.clearing_number !== undefined && body.clearing_number !== existing.clearing_number + const accountChanged = + body.bank_account_number !== undefined && body.bank_account_number !== existing.bank_account_number + if (clearingChanged || accountChanged) { + const bankIssues = validateEmployeeBankAccount(merged.clearing_number, merged.bank_account_number) + if (bankIssues.length > 0) { + return NextResponse.json({ error: bankIssues.map((i) => i.message).join('. ') }, { status: 400 }) + } + } -export async function DELETE( - 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 }) + // Only when the caller is changing employment_type: block setting an EF's + // owner/board on payroll (mirrors the enforce_ef_no_owner_employee trigger, + // which fires on UPDATE OF employment_type: so unrelated edits to any + // grandfathered row aren't blocked). #782 + if (body.employment_type !== undefined) { + const entityType = await getCompanyEntityType(supabase, companyId) + if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) { + return NextResponse.json({ error: EF_OWNER_EMPLOYMENT_ERROR }, { status: 400 }) + } + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Build update object + const updates: Record = { ...body } - const companyId = await requireCompanyId(supabase, user.id) + // Handle personnummer update if provided + if (body.personnummer) { + const pnrValidation = validatePersonnummer(body.personnummer) + if (!pnrValidation.valid) { + return NextResponse.json({ error: pnrValidation.error }, { status: 400 }) + } + updates.personnummer = encryptPersonnummer(body.personnummer) + updates.personnummer_last4 = extractLast4(body.personnummer) + } - // Soft delete only, BFL 7 kap retention - const { data, error } = await supabase - .from('employees') - .update({ is_active: false }) - .eq('id', id) - .eq('company_id', companyId) - .select('id') - .single() + const { data: updated, error } = await supabase + .from('employees') + .update(updates) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() - if (error || !data) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + if (error) { + if (error.code === '23505') { + return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } - return NextResponse.json({ data: { id: data.id, is_active: false } }) -} + return NextResponse.json({ + data: { + ...updated, + personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)), + }, + }) + }, + { requireWrite: true }, +) + +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.delete', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params + + // Soft delete only, BFL 7 kap retention + const { data, error } = await supabase + .from('employees') + .update({ is_active: false }) + .eq('id', id) + .eq('company_id', companyId) + .select('id') + .single() + + if (error || !data) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ data: { id: data.id, is_active: false } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts b/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts new file mode 100644 index 00000000..b3716c85 --- /dev/null +++ b/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts @@ -0,0 +1,86 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/worked-hours (POST upsert). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/worked-hours', { method: 'POST', body }) +} + +describe('POST /api/salary/employees/[id]/worked-hours', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + expect(response.status).toBe(403) + }) + + it('upserts a worked day (happy path)', async () => { + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee + enqueue({ data: null }) // delete existing + enqueue({ data: { id: 'wd-1', work_date: '2026-07-01', hours: 8 } }) // insert + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('wd-1') + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // loadEmployee → not found + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/worked-hours/batch/__tests__/route.test.ts b/app/api/salary/employees/[id]/worked-hours/batch/__tests__/route.test.ts new file mode 100644 index 00000000..7059a0d3 --- /dev/null +++ b/app/api/salary/employees/[id]/worked-hours/batch/__tests__/route.test.ts @@ -0,0 +1,90 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/worked-hours/batch (POST). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path (bulk insert). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/worked-hours/batch', { method: 'POST', body }) +} + +const validBatch = { dates: ['2026-07-01', '2026-07-02'], hours: 8 } + +describe('POST /api/salary/employees/[id]/worked-hours/batch', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post(validBatch), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post(validBatch), params) + expect(response.status).toBe(403) + }) + + it('bulk-inserts worked days (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + enqueue({ data: null }) // bulk delete + enqueue({ data: null }) // insert date 1 + enqueue({ data: null }) // insert date 2 + + const response = await POST(post(validBatch), params) + const { status, body } = await parseJsonResponse<{ data: { inserted: number; conflicts: unknown[] } }>(response) + + expect(status).toBe(201) + expect(body.data.inserted).toBe(2) + expect(body.data.conflicts).toEqual([]) + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // employee ownership check → not found + + const response = await POST(post(validBatch), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/worked-hours/batch/route.ts b/app/api/salary/employees/[id]/worked-hours/batch/route.ts index edc54f83..d248dcbe 100644 --- a/app/api/salary/employees/[id]/worked-hours/batch/route.ts +++ b/app/api/salary/employees/[id]/worked-hours/batch/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { BatchUpsertWorkedDaysSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -13,85 +11,78 @@ interface BatchConflict { reason: string } -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.worked_hours.batch', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { data: employee } = await supabase - .from('employees') - .select('id') - .eq('id', employeeId) - .eq('company_id', companyId) - .maybeSingle() - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const validation = await validateBody(request, BatchUpsertWorkedDaysSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Dedupe dates so the user can pass an array with accidental duplicates - // (e.g. shift-clicking over the same date twice). - const uniqueDates = Array.from(new Set(body.dates)) - - // Bulk delete existing rows on these dates first so the per-row insert step - // is a clean replace. Stays within RLS via company_id + employee_id filter. - const { error: deleteError } = await supabase - .from('salary_worked_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .in('work_date', uniqueDates) - - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) - } - - // Per-row insert so we can isolate trigger failures (24h cap on a date with - // existing absence) without aborting the whole batch. A single multi-row - // insert would fail-fast and surface only the first conflict. - const conflicts: BatchConflict[] = [] - let inserted = 0 - - for (const date of uniqueDates) { - const { error } = await supabase - .from('salary_worked_days') - .insert({ - company_id: companyId, - employee_id: employeeId, - work_date: date, - hours: body.hours, - notes: body.notes ?? null, - salary_run_employee_id: body.salary_run_employee_id ?? null, - }) - if (error) { - // 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish - // message starting with "Total tid". Other failures are unexpected. - if (error.message?.includes('Total tid') || error.code === '23514') { - conflicts.push({ date, reason: error.message }) - continue - } - return NextResponse.json( - { error: error.message, inserted, conflicts }, - { status: 500 }, - ) + const { data: employee } = await supabase + .from('employees') + .select('id') + .eq('id', employeeId) + .eq('company_id', companyId) + .maybeSingle() + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - inserted += 1 - } - return NextResponse.json( - { data: { inserted, conflicts } }, - { status: conflicts.length > 0 ? 207 : 201 }, - ) -} + const validation = await validateBody(request, BatchUpsertWorkedDaysSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Dedupe dates so the user can pass an array with accidental duplicates + // (e.g. shift-clicking over the same date twice). + const uniqueDates = Array.from(new Set(body.dates)) + + // Bulk delete existing rows on these dates first so the per-row insert step + // is a clean replace. Stays within RLS via company_id + employee_id filter. + const { error: deleteError } = await supabase + .from('salary_worked_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .in('work_date', uniqueDates) + + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + // Per-row insert so we can isolate trigger failures (24h cap on a date with + // existing absence) without aborting the whole batch. A single multi-row + // insert would fail-fast and surface only the first conflict. + const conflicts: BatchConflict[] = [] + let inserted = 0 + + for (const date of uniqueDates) { + const { error } = await supabase + .from('salary_worked_days') + .insert({ + company_id: companyId, + employee_id: employeeId, + work_date: date, + hours: body.hours, + notes: body.notes ?? null, + salary_run_employee_id: body.salary_run_employee_id ?? null, + }) + if (error) { + // 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish + // message starting with "Total tid". Other failures are unexpected. + if (error.message?.includes('Total tid') || error.code === '23514') { + conflicts.push({ date, reason: error.message }) + continue + } + return NextResponse.json( + { error: error.message, inserted, conflicts }, + { status: 500 }, + ) + } + inserted += 1 + } + + return NextResponse.json( + { data: { inserted, conflicts } }, + { status: conflicts.length > 0 ? 207 : 201 }, + ) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/worked-hours/route.ts b/app/api/salary/employees/[id]/worked-hours/route.ts index c106541a..6e827a40 100644 --- a/app/api/salary/employees/[id]/worked-hours/route.ts +++ b/app/api/salary/employees/[id]/worked-hours/route.ts @@ -1,21 +1,20 @@ import { z } from 'zod' -import { createClient } from '@/lib/supabase/server' +import type { SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody, validateQuery } from '@/lib/api/validate' import { UpsertWorkedDaySchema, WorkedHoursRangeQuerySchema, } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) ensureInitialized() async function loadEmployee( - supabase: Awaited>, + supabase: SupabaseClient, employeeId: string, companyId: string, ) { @@ -28,107 +27,95 @@ async function loadEmployee( return data } -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = 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.employees.worked_hours.list', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const companyId = await requireCompanyId(supabase, user.id) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const query = validateQuery(request, WorkedHoursRangeQuerySchema) - if (!query.success) return query.response - - const { data, error } = await supabase - .from('salary_worked_days') - .select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at') - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .gte('work_date', query.data.from) - .lte('work_date', query.data.to) - .order('work_date', { ascending: true }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - const totalHours = (data ?? []).reduce( - (sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100, - 0, - ) - - return NextResponse.json({ data, total_hours: totalHours }) -} - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = 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) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const validation = await validateBody(request, UpsertWorkedDaySchema) - if (!validation.success) return validation.response - const body = validation.data - - // Upsert via DELETE+INSERT on the natural key (employee, date). Worked days - // have one row per date: re-marking overwrites. Mirrors the absence route's - // pattern so behaviour stays predictable across the two calendars. - const { error: deleteError } = await supabase - .from('salary_worked_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .eq('work_date', body.work_date) - - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) - } - - const { data, error } = await supabase - .from('salary_worked_days') - .insert({ - company_id: companyId, - employee_id: employeeId, - work_date: body.work_date, - hours: body.hours, - notes: body.notes ?? null, - salary_run_employee_id: body.salary_run_employee_id ?? null, - }) - .select() - .single() - - if (error) { - // The 24h cap trigger raises check_violation when worked + absence > 24h - // for the same date. Surface a clean 409 with a Swedish message. - if (error.message?.includes('Total tid') || error.code === '23514') { - return NextResponse.json({ error: error.message }, { status: 409 }) + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }, { status: 201 }) -} + const query = validateQuery(request, WorkedHoursRangeQuerySchema) + if (!query.success) return query.response + + const { data, error } = await supabase + .from('salary_worked_days') + .select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at') + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .gte('work_date', query.data.from) + .lte('work_date', query.data.to) + .order('work_date', { ascending: true }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + const totalHours = (data ?? []).reduce( + (sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100, + 0, + ) + + return NextResponse.json({ data, total_hours: totalHours }) + }, +) + +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.worked_hours.upsert', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params + + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + const validation = await validateBody(request, UpsertWorkedDaySchema) + if (!validation.success) return validation.response + const body = validation.data + + // Upsert via DELETE+INSERT on the natural key (employee, date). Worked days + // have one row per date: re-marking overwrites. Mirrors the absence route's + // pattern so behaviour stays predictable across the two calendars. + const { error: deleteError } = await supabase + .from('salary_worked_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .eq('work_date', body.work_date) + + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + const { data, error } = await supabase + .from('salary_worked_days') + .insert({ + company_id: companyId, + employee_id: employeeId, + work_date: body.work_date, + hours: body.hours, + notes: body.notes ?? null, + salary_run_employee_id: body.salary_run_employee_id ?? null, + }) + .select() + .single() + + if (error) { + // The 24h cap trigger raises check_violation when worked + absence > 24h + // for the same date. Surface a clean 409 with a Swedish message. + if (error.message?.includes('Total tid') || error.code === '23514') { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) // Two modes: ?date=YYYY-MM-DD (single row) or ?from=…&to=… (range). const DeleteQuerySchema = z.object({ @@ -137,54 +124,47 @@ const DeleteQuerySchema = z.object({ date: isoDate.optional(), }) -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.worked_hours.delete', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const query = validateQuery(request, DeleteQuerySchema) + if (!query.success) return query.response + const { date, from, to } = query.data - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + const hasSingle = !!date + const hasRange = !!from && !!to + if (!hasSingle && !hasRange) { + return NextResponse.json( + { error: 'Ange antingen ?date=YYYY-MM-DD eller ?from=...&to=...' }, + { status: 400 }, + ) + } - const query = validateQuery(request, DeleteQuerySchema) - if (!query.success) return query.response - const { date, from, to } = query.data + let q = supabase + .from('salary_worked_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) - const hasSingle = !!date - const hasRange = !!from && !!to - if (!hasSingle && !hasRange) { - return NextResponse.json( - { error: 'Ange antingen ?date=YYYY-MM-DD eller ?from=...&to=...' }, - { status: 400 }, - ) - } + if (hasSingle) { + q = q.eq('work_date', date!) + } else { + q = q.gte('work_date', from!).lte('work_date', to!) + } - let q = supabase - .from('salary_worked_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) + const { error } = await q + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (hasSingle) { - q = q.eq('work_date', date!) - } else { - q = q.gte('work_date', from!).lte('work_date', to!) - } - - const { error } = await q - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { ok: true } }) -} + return NextResponse.json({ data: { ok: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/__tests__/route.test.ts b/app/api/salary/employees/__tests__/route.test.ts index 8263e0da..54dbba9c 100644 --- a/app/api/salary/employees/__tests__/route.test.ts +++ b/app/api/salary/employees/__tests__/route.test.ts @@ -1,5 +1,5 @@ /** - * Regression test for the plaintext-personnummer crash. + * Regression test for the plaintext-personnummer crash, plus auth wiring. * * GET /api/salary/employees decrypts every employee's personnummer on read and * maps over the whole roster. A row whose personnummer was stored UNENCRYPTED @@ -8,25 +8,28 @@ * the .map(), 500-ing the entire endpoint for the affected company. The decrypt * helper now passes a raw 12-digit value through unchanged, so a mixed * encrypted/plaintext table no longer takes the roster down. + * + * The route now runs through the withRouteContext wrapper, so we mock its + * auth/company/write dependencies and inject the Supabase client via requireAuth. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextResponse } from 'next/server' vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), -})) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), getCompanyEntityType: vi.fn(), })) -vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() })) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) import { GET } from '../route' -import { createClient } from '@/lib/supabase/server' +import { requireAuth } from '@/lib/auth/require-auth' import { encryptPersonnummer } from '@/lib/salary/personnummer' -const mockCreateClient = createClient as ReturnType - // Synthetic 12-digit values (year 1900 / 1902, zero suffix): obviously not // real birthdates. ISO A.5.34 / GDPR Art.5(1)(c): fixtures must not look like // production PII. @@ -38,39 +41,48 @@ function supabaseWithRows(rows: unknown[]) { query.select = vi.fn(() => query) query.eq = vi.fn(() => query) query.order = vi.fn(() => Promise.resolve({ data: rows, error: null })) - return { - auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) }, - from: vi.fn(() => query), - } + return { from: vi.fn(() => query) } +} + +function authed(supabase: unknown) { + vi.mocked(requireAuth).mockResolvedValue({ + user: { id: 'user-1' } as never, + supabase: supabase as never, + error: null, + } as never) } function req() { return new Request('https://x.test/api/salary/employees') } +const params = { params: Promise.resolve({}) } as never + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/salary/employees', () => { it('returns 401 when unauthenticated', async () => { - mockCreateClient.mockResolvedValue({ - auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, - }) + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + } as never) - const res = await GET(req()) + const res = await GET(req(), params) expect(res.status).toBe(401) }) it('does not 500 on a mixed plaintext + encrypted roster; masks both', async () => { - mockCreateClient.mockResolvedValue( + authed( supabaseWithRows([ { id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR }, { id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR }, ]), ) - const res = await GET(req()) + const res = await GET(req(), params) expect(res.status).toBe(200) const body = await res.json() // Both rows masked birthdate-visible, last-4 hidden. diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts index 6e56b092..2b072391 100644 --- a/app/api/salary/employees/route.ts +++ b/app/api/salary/employees/route.ts @@ -1,22 +1,15 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateEmployeeSchema } from '@/lib/api/schemas' -import { requireCompanyId, getCompanyEntityType } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { getCompanyEntityType } from '@/lib/company/context' import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' ensureInitialized() -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('salary.employees.list', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const activeOnly = searchParams.get('active') !== 'false' @@ -42,18 +35,9 @@ export async function GET(request: Request) { })) return NextResponse.json({ data: masked }) -} - -export async function POST(request: Request) { - 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) +}) +export const POST = withRouteContext('salary.employees.create', async (request, { supabase, companyId, user }) => { const validation = await validateBody(request, CreateEmployeeSchema) if (!validation.success) return validation.response const body = validation.data @@ -130,4 +114,4 @@ export async function POST(request: Request) { personnummer: maskPersonnummer(body.personnummer), }, }, { status: 201 }) -} +}, { requireWrite: true }) diff --git a/app/api/salary/ku/[year]/route.ts b/app/api/salary/ku/[year]/route.ts index 4993e4ba..5ff302b1 100644 --- a/app/api/salary/ku/[year]/route.ts +++ b/app/api/salary/ku/[year]/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateKU10Xml } from '@/lib/salary/ku/ku10-generator' import type { KU10EmployeeData, KU10CompanyData } from '@/lib/salary/ku/ku10-generator' @@ -15,145 +14,140 @@ ensureInitialized() * * The XML is räkenskapsinformation per BFL 7 kap, 7-year retention. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ year: string }> } -) { - const { year } = await params - const yearNum = parseInt(year) - 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<{ year: string }> }>( + 'salary.ku.export', + async (request, { supabase, companyId, user }, { params }) => { + const { year } = await params + const yearNum = parseInt(year) - const companyId = await requireCompanyId(supabase, user.id) + if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { + return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) + } - if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { - return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) - } + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() - // Load company - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() + if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + const { data: settings } = await supabase + .from('company_settings') + .select('company_name, org_number, phone, email') + .eq('company_id', companyId) + .single() - const { data: settings } = await supabase - .from('company_settings') - .select('company_name, org_number, phone, email') - .eq('company_id', companyId) - .single() + const { data: profile } = await supabase + .from('profiles') + .select('full_name, email') + .eq('id', user.id) + .single() - const { data: profile } = await supabase - .from('profiles') - .select('full_name, email') - .eq('id', user.id) - .single() + // Load all booked salary run employees for the year, grouped by employee + const { data: runEmployees, error } = await supabase + .from('salary_run_employees') + .select(` + employee_id, gross_salary, tax_withheld, tax_withheld_override, + avgifter_basis, avgifter_basis_override, + employee:employees(personnummer, specification_number, employment_start, employment_end), + salary_run:salary_runs!inner(period_year, status), + line_items:salary_line_items(item_type, amount) + `) + .eq('company_id', companyId) - // Load all booked salary run employees for the year, grouped by employee - const { data: runEmployees, error } = await supabase - .from('salary_run_employees') - .select(` - employee_id, gross_salary, tax_withheld, tax_withheld_override, - avgifter_basis, avgifter_basis_override, - employee:employees(personnummer, specification_number, employment_start, employment_end), - salary_run:salary_runs!inner(period_year, status), - line_items:salary_line_items(item_type, amount) - `) - .eq('company_id', companyId) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + // Filter to booked runs for the year + const bookedForYear = (runEmployees || []).filter(sre => { + const run = sre.salary_run as unknown as { period_year: number; status: string } | null + return run && run.period_year === yearNum && run.status === 'booked' + }) - // Filter to booked runs for the year - const bookedForYear = (runEmployees || []).filter(sre => { - const run = sre.salary_run as unknown as { period_year: number; status: string } | null - return run && run.period_year === yearNum && run.status === 'booked' - }) + // Aggregate per employee + const byEmployee = new Map() - // Aggregate per employee - const byEmployee = new Map() + for (const sre of bookedForYear) { + const emp = sre.employee as unknown as { personnummer: string; specification_number: number; employment_start: string; employment_end: string | null } | null + if (!emp) continue - for (const sre of bookedForYear) { - const emp = sre.employee as unknown as { personnummer: string; specification_number: number; employment_start: string; employment_end: string | null } | null - if (!emp) continue + const current = byEmployee.get(sre.employee_id) || { + personnummer: emp.personnummer, + specificationNumber: emp.specification_number, + employmentStart: emp.employment_start, + employmentEnd: emp.employment_end, + totalGross: 0, totalTax: 0, totalAvgifterBasis: 0, + benefitCar: 0, benefitHousing: 0, benefitMeals: 0, benefitOther: 0, + } - const current = byEmployee.get(sre.employee_id) || { + current.totalGross += sre.gross_salary + // Honor advanced-mode override so KU matches AGI + the ledger. + current.totalTax += sre.tax_withheld_override ?? sre.tax_withheld + current.totalAvgifterBasis += sre.avgifter_basis_override ?? sre.avgifter_basis + + // Sum benefits by type from line items + const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }> + for (const li of lineItems) { + if (li.item_type === 'benefit_car') current.benefitCar += li.amount + else if (li.item_type === 'benefit_housing') current.benefitHousing += li.amount + else if (li.item_type === 'benefit_meals') current.benefitMeals += li.amount + else if (['benefit_wellness', 'benefit_other'].includes(li.item_type)) current.benefitOther += li.amount + } + + byEmployee.set(sre.employee_id, current) + } + + if (byEmployee.size === 0) { + return NextResponse.json({ error: `Inga bokförda lönekörningar för ${yearNum}` }, { status: 404 }) + } + + // Employer name on the KU10 follows the current company name + // (company_settings.company_name), not the frozen onboarding companies.name. + const companyName = settings?.company_name || company.name + const companyData: KU10CompanyData = { + orgNumber: (settings?.org_number || company.org_number || '').trim(), + companyName, + year: yearNum, + contactName: (profile?.full_name || companyName || '').trim(), + contactPhone: (settings?.phone || '').trim(), + contactEmail: (settings?.email || profile?.email || user.email || '').trim(), + } + + const r = (x: number) => Math.round(x * 100) / 100 + const employeeData: KU10EmployeeData[] = Array.from(byEmployee.values()).map(emp => ({ personnummer: emp.personnummer, - specificationNumber: emp.specification_number, - employmentStart: emp.employment_start, - employmentEnd: emp.employment_end, - totalGross: 0, totalTax: 0, totalAvgifterBasis: 0, - benefitCar: 0, benefitHousing: 0, benefitMeals: 0, benefitOther: 0, - } + specificationNumber: emp.specificationNumber, + totalGross: r(emp.totalGross), + totalTax: r(emp.totalTax), + totalAvgifterBasis: r(emp.totalAvgifterBasis), + benefitCar: emp.benefitCar > 0 ? r(emp.benefitCar) : undefined, + benefitHousing: emp.benefitHousing > 0 ? r(emp.benefitHousing) : undefined, + benefitMeals: emp.benefitMeals > 0 ? r(emp.benefitMeals) : undefined, + benefitOther: emp.benefitOther > 0 ? r(emp.benefitOther) : undefined, + employmentStart: emp.employmentStart || undefined, + employmentEnd: emp.employmentEnd || undefined, + })) - current.totalGross += sre.gross_salary - // Honor advanced-mode override so KU matches AGI + the ledger. - current.totalTax += sre.tax_withheld_override ?? sre.tax_withheld - current.totalAvgifterBasis += sre.avgifter_basis_override ?? sre.avgifter_basis + const xml = generateKU10Xml(companyData, employeeData) - // Sum benefits by type from line items - const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }> - for (const li of lineItems) { - if (li.item_type === 'benefit_car') current.benefitCar += li.amount - else if (li.item_type === 'benefit_housing') current.benefitHousing += li.amount - else if (li.item_type === 'benefit_meals') current.benefitMeals += li.amount - else if (['benefit_wellness', 'benefit_other'].includes(li.item_type)) current.benefitOther += li.amount - } - - byEmployee.set(sre.employee_id, current) - } - - if (byEmployee.size === 0) { - return NextResponse.json({ error: `Inga bokförda lönekörningar för ${yearNum}` }, { status: 404 }) - } - - // Employer name on the KU10 follows the current company name - // (company_settings.company_name), not the frozen onboarding companies.name. - const companyName = settings?.company_name || company.name - const companyData: KU10CompanyData = { - orgNumber: (settings?.org_number || company.org_number || '').trim(), - companyName, - year: yearNum, - contactName: (profile?.full_name || companyName || '').trim(), - contactPhone: (settings?.phone || '').trim(), - contactEmail: (settings?.email || profile?.email || user.email || '').trim(), - } - - const r = (x: number) => Math.round(x * 100) / 100 - const employeeData: KU10EmployeeData[] = Array.from(byEmployee.values()).map(emp => ({ - personnummer: emp.personnummer, - specificationNumber: emp.specificationNumber, - totalGross: r(emp.totalGross), - totalTax: r(emp.totalTax), - totalAvgifterBasis: r(emp.totalAvgifterBasis), - benefitCar: emp.benefitCar > 0 ? r(emp.benefitCar) : undefined, - benefitHousing: emp.benefitHousing > 0 ? r(emp.benefitHousing) : undefined, - benefitMeals: emp.benefitMeals > 0 ? r(emp.benefitMeals) : undefined, - benefitOther: emp.benefitOther > 0 ? r(emp.benefitOther) : undefined, - employmentStart: emp.employmentStart || undefined, - employmentEnd: emp.employmentEnd || undefined, - })) - - const xml = generateKU10Xml(companyData, employeeData) - - return new Response(xml, { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="KU10_${company.org_number}_${yearNum}.xml"`, - }, - }) -} + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="KU10_${company.org_number}_${yearNum}.xml"`, + }, + }) + }, +) diff --git a/app/api/salary/payroll-config/[year]/route.ts b/app/api/salary/payroll-config/[year]/route.ts index ed3faf71..e2805f46 100644 --- a/app/api/salary/payroll-config/[year]/route.ts +++ b/app/api/salary/payroll-config/[year]/route.ts @@ -1,25 +1,22 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { loadPayrollConfig } from '@/lib/salary/payroll-config' -export async function GET( - request: Request, - { params }: { params: Promise<{ year: string }> } -) { - const { year } = 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<{ year: string }> }>( + 'salary.payroll_config.get', + async (request, { supabase }, { params }) => { + const { year } = await params - const yearNum = parseInt(year) - if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { - return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) - } + const yearNum = parseInt(year) + if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { + return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) + } - try { - const config = await loadPayrollConfig(supabase, yearNum) - return NextResponse.json({ data: config }) - } catch { - return NextResponse.json({ error: `Löneuppgifter för ${year} saknas` }, { status: 404 }) - } -} + try { + const config = await loadPayrollConfig(supabase, yearNum) + return NextResponse.json({ data: config }) + } catch { + return NextResponse.json({ error: `Löneuppgifter för ${year} saknas` }, { status: 404 }) + } + }, +) diff --git a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts index 4f34abc5..ca3d09df 100644 --- a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, @@ -7,24 +8,19 @@ import { } from '@/tests/helpers' // ── Mocks ──────────────────────────────────────────────────── +// The route is wrapped in withRouteContext (auth via requireAuth, company via +// getActiveCompanyId, write-gate via requireWritePermission). We inject a queued +// Supabase mock through requireAuth and mock fetch to the extension endpoint. -const mockCreateClient = vi.fn() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => mockCreateClient(), +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) - -vi.mock('@/lib/init', () => ({ - ensureInitialized: vi.fn(), -})) - vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) - -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), -})) - vi.mock('@/lib/events', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined) }, })) @@ -34,6 +30,8 @@ const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' import { eventBus } from '@/lib/events' // ── Test data ──────────────────────────────────────────────── @@ -64,16 +62,29 @@ const makeAgiDeclaration = (overrides = {}) => ({ ...overrides, }) +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + // ── Tests ──────────────────────────────────────────────────── describe('POST /api/salary/runs/[id]/agi/submit', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) }) it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue({ - auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }) const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' }) @@ -84,11 +95,22 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) - it('returns 404 when salary run not found', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) + it('returns 403 for a viewer (no write permission)', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('returns 404 when salary run not found', async () => { + const { enqueueMany } = authed() enqueueMany([ { data: null, error: { message: 'Not found' } }, // salary_runs query ]) @@ -102,10 +124,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns 400 when salary run is in draft status', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun({ status: 'draft' }) }, // salary_runs query ]) @@ -119,10 +138,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns 400 when AGI has not been generated', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, // salary_runs query { data: null }, // agi_declarations query (not found) @@ -137,10 +153,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns 409 when AGI has already been submitted', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, { data: makeAgiDeclaration({ status: 'submitted' }) }, @@ -155,10 +168,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('submits AGI draft and returns success', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, // salary_runs query { data: makeAgiDeclaration() }, // agi_declarations query @@ -214,10 +224,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns error when extension draft endpoint fails', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, { data: makeAgiDeclaration() }, @@ -241,10 +248,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('accepts booked salary runs for submission', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun({ status: 'booked' }) }, { data: makeAgiDeclaration() }, diff --git a/app/api/salary/runs/[id]/agi/submit/route.ts b/app/api/salary/runs/[id]/agi/submit/route.ts index fc4af82d..2481e82b 100644 --- a/app/api/salary/runs/[id]/agi/submit/route.ts +++ b/app/api/salary/runs/[id]/agi/submit/route.ts @@ -1,8 +1,6 @@ -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 { eventBus } from '@/lib/events' ensureInitialized() @@ -19,136 +17,130 @@ ensureInitialized() * The user then signs on Skatteverket's site (Mina Sidor). The frontend * polls /api/extensions/ext/skatteverket/agi/kvittenser to detect completion. */ -export async function POST( - 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 POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.agi.submit', + async (request, ctx, { params }) => { + const { id } = await params + const { user, supabase, companyId, log } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Load salary run + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - // Load salary run - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (runError || !run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } - - if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) { - return NextResponse.json( - { error: 'AGI kan bara skickas till Skatteverket efter granskning' }, - { status: 400 } - ) - } - - // Ensure AGI has been generated - const { data: agiDeclaration } = await supabase - .from('agi_declarations') - .select('id, status') - .eq('company_id', companyId) - .eq('salary_run_id', id) - .single() - - if (!agiDeclaration) { - return NextResponse.json( - { error: 'AGI har inte genererats ännu. Generera AGI XML först.' }, - { status: 400 } - ) - } - - if (agiDeclaration.status === 'submitted' || agiDeclaration.status === 'accepted') { - return NextResponse.json( - { error: 'AGI har redan skickats till Skatteverket för denna period' }, - { status: 409 } - ) - } - - // The actual SKV interaction lives in the Skatteverket extension. This - // route is a thin orchestrator: it forwards the salary_run_id to the - // extension's /agi/submit endpoint (which posts the stored XML underlag), - // then records that the AGI submission process has started. - // - // The frontend (AGIPanel) handles the rest of the flow: - // 1. POST /api/extensions/ext/skatteverket/agi/submit { salaryRunId } - // → returns { inlamningId } - // 2. GET /api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=... - // → poll until status != PROCESSING - // 3. POST /api/extensions/ext/skatteverket/agi/spara { inlamningId } - // 4. POST /api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare&period - // → returns { link } (Mina Sidor BankID signing) - // 5. GET /api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare&period - - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - - try { - const submitResponse = await fetch( - `${appUrl}/api/extensions/ext/skatteverket/agi/submit`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Cookie': request.headers.get('Cookie') || '', - }, - body: JSON.stringify({ salaryRunId: id }), - } - ) - - if (!submitResponse.ok) { - const errorData = await submitResponse.json().catch(() => ({ error: 'Okänt fel' })) + if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) { return NextResponse.json( - { error: errorData.error || `Kunde inte skicka AGI-underlag (${submitResponse.status})` }, - { status: submitResponse.status } + { error: 'AGI kan bara skickas till Skatteverket efter granskning' }, + { status: 400 } ) } - const submitData = await submitResponse.json() + // Ensure AGI has been generated + const { data: agiDeclaration } = await supabase + .from('agi_declarations') + .select('id, status') + .eq('company_id', companyId) + .eq('salary_run_id', id) + .single() - // Don't stamp salary_runs.agi_submitted_at here. The underlag has only - // been ingested; the user still has to pass kontrollresultat, save, - // produce a granskningsunderlag, and sign with BankID before the AGI is - // actually filed. Recording the submission time at ingest would make the - // audit trail lie about when filing completed. + if (!agiDeclaration) { + return NextResponse.json( + { error: 'AGI har inte genererats ännu. Generera AGI XML först.' }, + { status: 400 } + ) + } + + if (agiDeclaration.status === 'submitted' || agiDeclaration.status === 'accepted') { + return NextResponse.json( + { error: 'AGI har redan skickats till Skatteverket för denna period' }, + { status: 409 } + ) + } + + // The actual SKV interaction lives in the Skatteverket extension. This + // route is a thin orchestrator: it forwards the salary_run_id to the + // extension's /agi/submit endpoint (which posts the stored XML underlag), + // then records that the AGI submission process has started. // - // The real timestamp is set by the kvittenser handler in the extension - // (extensions/general/skatteverket/index.ts /agi/kvittenser route) when - // it observes a uuidKvittens for the period, mirroring SKV's signeradTid. + // The frontend (AGIPanel) handles the rest of the flow: + // 1. POST /api/extensions/ext/skatteverket/agi/submit { salaryRunId } + // → returns { inlamningId } + // 2. GET /api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=... + // → poll until status != PROCESSING + // 3. POST /api/extensions/ext/skatteverket/agi/spara { inlamningId } + // 4. POST /api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare&period + // → returns { link } (Mina Sidor BankID signing) + // 5. GET /api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare&period - await eventBus.emit({ - type: 'agi.submitted', - payload: { - salaryRunId: id, - periodYear: run.period_year, - periodMonth: run.period_month, - userId: user.id, - companyId, - }, - }) + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - return NextResponse.json({ - data: { - ...submitData.data, - salaryRunId: id, - periodYear: run.period_year, - periodMonth: run.period_month, - message: 'AGI-underlag inläst hos Skatteverket. Skapa granskningsunderlag och signera med BankID i Mina Sidor.', - }, - }) - } catch (err) { - console.error('[salary/agi/submit] Error:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Kunde inte skicka AGI till Skatteverket' }, - { status: 500 } - ) - } -} + try { + const submitResponse = await fetch( + `${appUrl}/api/extensions/ext/skatteverket/agi/submit`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': request.headers.get('Cookie') || '', + }, + body: JSON.stringify({ salaryRunId: id }), + } + ) + + if (!submitResponse.ok) { + const errorData = await submitResponse.json().catch(() => ({ error: 'Okänt fel' })) + return NextResponse.json( + { error: errorData.error || `Kunde inte skicka AGI-underlag (${submitResponse.status})` }, + { status: submitResponse.status } + ) + } + + const submitData = await submitResponse.json() + + // Don't stamp salary_runs.agi_submitted_at here. The underlag has only + // been ingested; the user still has to pass kontrollresultat, save, + // produce a granskningsunderlag, and sign with BankID before the AGI is + // actually filed. Recording the submission time at ingest would make the + // audit trail lie about when filing completed. + // + // The real timestamp is set by the kvittenser handler in the extension + // (extensions/general/skatteverket/index.ts /agi/kvittenser route) when + // it observes a uuidKvittens for the period, mirroring SKV's signeradTid. + + await eventBus.emit({ + type: 'agi.submitted', + payload: { + salaryRunId: id, + periodYear: run.period_year, + periodMonth: run.period_month, + userId: user.id, + companyId, + }, + }) + + return NextResponse.json({ + data: { + ...submitData.data, + salaryRunId: id, + periodYear: run.period_year, + periodMonth: run.period_month, + message: 'AGI-underlag inläst hos Skatteverket. Skapa granskningsunderlag och signera med BankID i Mina Sidor.', + }, + }) + } catch (err) { + log.error('[salary/agi/submit] Error', err as Error) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Kunde inte skicka AGI till Skatteverket' }, + { status: 500 } + ) + } + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/agi/xml/route.ts b/app/api/salary/runs/[id]/agi/xml/route.ts index c3b5dee9..d5eb8329 100644 --- a/app/api/salary/runs/[id]/agi/xml/route.ts +++ b/app/api/salary/runs/[id]/agi/xml/route.ts @@ -1,9 +1,6 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateAgiDeclaration } from '@/lib/salary/agi/generate-declaration' -import { createLogger } from '@/lib/logger' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' ensureInitialized() @@ -25,54 +22,46 @@ ensureInitialized() * - Filing deadline: the 12th of the following month (17th in Jan/Aug for * companies ≤ 40 MSEK turnover) */ -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.run.agi.xml', + async (_request, ctx, { params }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx - const companyId = await requireCompanyId(supabase, user.id) - const requestId = `req_${crypto.randomUUID()}` - const log = createLogger('api/salary/agi/xml', { requestId, userId: user.id }) - - const result = await generateAgiDeclaration({ - supabase, - companyId, - userId: user.id, - userEmail: user.email ?? null, - salaryRunId: id, - log, - requestId, - }) - - if (!result.ok) { - return errorResponseFromCode(result.code, log, { + const result = await generateAgiDeclaration({ + supabase, + companyId, + userId: user.id, + userEmail: user.email ?? null, + salaryRunId: id, + log, requestId, - details: result.details, - status: result.status, }) - } - // OWASP V3.2 / V4 (HTTP response header injection prevention): sanitise - // header-interpolated values. orgNumber comes from company_settings - // (user-editable) and period_* from the run's own columns, but defense - // in depth requires we strip anything that could be construed as a - // header-injection character before splicing into Content-Disposition. - const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '') - const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace( - /[^0-9]/g, - '', - ) + if (!result.ok) { + return errorResponseFromCode(result.code, log, { + requestId, + details: result.details, + status: result.status, + }) + } - return new Response(result.xml, { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="AGI_${safeOrg}_${safePeriod}.xml"`, - }, - }) -} + // OWASP V3.2 / V4 (HTTP response header injection prevention): sanitise + // header-interpolated values. orgNumber comes from company_settings + // (user-editable) and period_* from the run's own columns, but defense + // in depth requires we strip anything that could be construed as a + // header-injection character before splicing into Content-Disposition. + const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '') + const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace( + /[^0-9]/g, + '', + ) + + return new Response(result.xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="AGI_${safeOrg}_${safePeriod}.xml"`, + }, + }) + }, +) diff --git a/app/api/salary/runs/[id]/correct/__tests__/route.test.ts b/app/api/salary/runs/[id]/correct/__tests__/route.test.ts new file mode 100644 index 00000000..8bba333c --- /dev/null +++ b/app/api/salary/runs/[id]/correct/__tests__/route.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +// The route is wrapped in withRouteContext. Auth/company/write are injected via +// mocks; the storno engine and payslip-link revocation are stubbed. +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: vi.fn() })) +vi.mock('@/lib/salary/payslips/links', () => ({ revokeLinksForRun: vi.fn() })) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('POST /api/salary/runs/[id]/correct', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('creates a correction run for a booked original', async () => { + const { enqueueMany } = authed() + enqueueMany([ + // original booked run (no entry ids → no reversal loop) + { + data: { + id: 'run-1', + status: 'booked', + period_year: 2026, + period_month: 3, + payment_date: '2026-03-25', + voucher_series: 'A', + salary_entry_id: null, + avgifter_entry_id: null, + vacation_entry_id: null, + pension_entry_id: null, + }, + }, + { data: null }, // update original → corrected + { data: { id: 'corr-1', period_year: 2026, period_month: 3 } }, // insert correction run + { data: [] }, // original employees (none to copy) + ]) + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string }; reversed_entry_count: number }>( + response, + ) + + expect(status).toBe(201) + expect(body.data.id).toBe('corr-1') + expect(body.reversed_entry_count).toBe(0) + }) + + it('rejects correcting a run that is not booked', async () => { + const { enqueueMany } = authed() + enqueueMany([{ data: null, error: { message: 'no rows' } }]) // status filter excludes it + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('bokförda') + }) +}) diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts index 2a07f6b4..64ead61a 100644 --- a/app/api/salary/runs/[id]/correct/route.ts +++ b/app/api/salary/runs/[id]/correct/route.ts @@ -1,8 +1,6 @@ -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 { reverseEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors' import { revokeLinksForRun } from '@/lib/salary/payslips/links' @@ -24,151 +22,145 @@ ensureInitialized() * * AGI must be re-generated with same FK570 (correction flag) per agi-filing.md. */ -export async function POST( - 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 POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.correct', + async (_request, ctx, { params }) => { + const { id } = await params + const { user, supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Load the original booked run + const { data: originalRun, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'booked') + .single() - const companyId = await requireCompanyId(supabase, user.id) - - // Load the original booked run - const { data: originalRun, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .eq('status', 'booked') - .single() - - if (runError || !originalRun) { - return NextResponse.json({ error: 'Kan bara korrigera bokförda lönekörningar' }, { status: 400 }) - } - - // Reverse all journal entries from the original run (storno per BFL 5 kap 5§) - const entryIds = [ - originalRun.salary_entry_id, - originalRun.avgifter_entry_id, - originalRun.vacation_entry_id, - originalRun.pension_entry_id, - ].filter(Boolean) as string[] - - for (const entryId of entryIds) { - try { - await reverseEntry(supabase, companyId, user.id, entryId) - } catch (err) { - // Entry may already be reversed: continue - if (err instanceof EntryAlreadyReversedError) continue - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - const msg = err instanceof Error ? err.message : '' - return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 }) + if (runError || !originalRun) { + return NextResponse.json({ error: 'Kan bara korrigera bokförda lönekörningar' }, { status: 400 }) } - } - // Mark original as corrected - await supabase - .from('salary_runs') - .update({ status: 'corrected' }) - .eq('id', id) + // Reverse all journal entries from the original run (storno per BFL 5 kap 5§) + const entryIds = [ + originalRun.salary_entry_id, + originalRun.avgifter_entry_id, + originalRun.vacation_entry_id, + originalRun.pension_entry_id, + ].filter(Boolean) as string[] - // The storno replaces the payslips — previously emailed payslip links for - // the original run must stop resolving (they show as "ersatt" to the - // employee). Fresh links are issued when the correction run's payslips - // are sent. - await revokeLinksForRun(supabase, id) - - // Create new correction run for same period - // Remove the unique constraint conflict by using the original run's unique key - // The unique constraint is (company_id, period_year, period_month) so we need - // to delete the uniqueness or handle it. Since original is now 'corrected', - // and we want a new run for the same period, we update the unique constraint. - // Actually the DB still enforces uniqueness. The correction run needs the same period. - // Solution: drop the old unique index and add a partial one excluding corrected runs, - // OR just use the same run ID pattern. Let's create the correction run and handle the conflict. - - const { data: correctionRun, error: createError } = await supabase - .from('salary_runs') - .insert({ - company_id: companyId, - user_id: user.id, - period_year: originalRun.period_year, - period_month: originalRun.period_month, - payment_date: originalRun.payment_date, - voucher_series: originalRun.voucher_series, - is_correction: true, - corrects_run_id: originalRun.id, - notes: `Korrigering av lönekörning ${originalRun.period_year}-${String(originalRun.period_month).padStart(2, '0')}`, - }) - .select() - .single() - - if (createError) { - // If unique constraint violation, the period already has an active run - if (createError.code === '23505') { - return NextResponse.json({ - error: 'Det finns redan en aktiv lönekörning för denna period. Ta bort den först.', - }, { status: 409 }) + for (const entryId of entryIds) { + try { + await reverseEntry(supabase, companyId, user.id, entryId) + } catch (err) { + // Entry may already be reversed: continue + if (err instanceof EntryAlreadyReversedError) continue + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + const msg = err instanceof Error ? err.message : '' + return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 }) + } } - return NextResponse.json({ error: createError.message }, { status: 500 }) - } - // Copy employees from original run to correction run (with snapshots) - const { data: originalEmployees } = await supabase - .from('salary_run_employees') - .select('*, line_items:salary_line_items(*)') - .eq('salary_run_id', id) + // Mark original as corrected + await supabase + .from('salary_runs') + .update({ status: 'corrected' }) + .eq('id', id) - for (const origEmp of originalEmployees || []) { - const { data: newSre } = await supabase - .from('salary_run_employees') + // The storno replaces the payslips — previously emailed payslip links for + // the original run must stop resolving (they show as "ersatt" to the + // employee). Fresh links are issued when the correction run's payslips + // are sent. + await revokeLinksForRun(supabase, id) + + // Create new correction run for same period + // Remove the unique constraint conflict by using the original run's unique key + // The unique constraint is (company_id, period_year, period_month) so we need + // to delete the uniqueness or handle it. Since original is now 'corrected', + // and we want a new run for the same period, we update the unique constraint. + // Actually the DB still enforces uniqueness. The correction run needs the same period. + // Solution: drop the old unique index and add a partial one excluding corrected runs, + // OR just use the same run ID pattern. Let's create the correction run and handle the conflict. + + const { data: correctionRun, error: createError } = await supabase + .from('salary_runs') .insert({ - salary_run_id: correctionRun.id, - employee_id: origEmp.employee_id, company_id: companyId, - employment_degree: origEmp.employment_degree, - monthly_salary: origEmp.monthly_salary, - salary_type: origEmp.salary_type, - hours_worked: origEmp.hours_worked, - tax_table_number: origEmp.tax_table_number, - tax_column: origEmp.tax_column, + user_id: user.id, + period_year: originalRun.period_year, + period_month: originalRun.period_month, + payment_date: originalRun.payment_date, + voucher_series: originalRun.voucher_series, + is_correction: true, + corrects_run_id: originalRun.id, + notes: `Korrigering av lönekörning ${originalRun.period_year}-${String(originalRun.period_month).padStart(2, '0')}`, }) .select() .single() - if (newSre) { - // Copy line items - const lineItems = (origEmp.line_items || []) as Array> - for (const li of lineItems) { - await supabase.from('salary_line_items').insert({ - salary_run_employee_id: newSre.id, + if (createError) { + // If unique constraint violation, the period already has an active run + if (createError.code === '23505') { + return NextResponse.json({ + error: 'Det finns redan en aktiv lönekörning för denna period. Ta bort den först.', + }, { status: 409 }) + } + return NextResponse.json({ error: createError.message }, { status: 500 }) + } + + // Copy employees from original run to correction run (with snapshots) + const { data: originalEmployees } = await supabase + .from('salary_run_employees') + .select('*, line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + for (const origEmp of originalEmployees || []) { + const { data: newSre } = await supabase + .from('salary_run_employees') + .insert({ + salary_run_id: correctionRun.id, + employee_id: origEmp.employee_id, company_id: companyId, - item_type: li.item_type, - description: li.description, - quantity: li.quantity, - unit_price: li.unit_price, - amount: li.amount, - is_taxable: li.is_taxable, - is_avgift_basis: li.is_avgift_basis, - is_vacation_basis: li.is_vacation_basis, - is_gross_deduction: li.is_gross_deduction, - is_net_deduction: li.is_net_deduction, - account_number: li.account_number, - sort_order: li.sort_order, + employment_degree: origEmp.employment_degree, + monthly_salary: origEmp.monthly_salary, + salary_type: origEmp.salary_type, + hours_worked: origEmp.hours_worked, + tax_table_number: origEmp.tax_table_number, + tax_column: origEmp.tax_column, }) + .select() + .single() + + if (newSre) { + // Copy line items + const lineItems = (origEmp.line_items || []) as Array> + for (const li of lineItems) { + await supabase.from('salary_line_items').insert({ + salary_run_employee_id: newSre.id, + company_id: companyId, + item_type: li.item_type, + description: li.description, + quantity: li.quantity, + unit_price: li.unit_price, + amount: li.amount, + is_taxable: li.is_taxable, + is_avgift_basis: li.is_avgift_basis, + is_vacation_basis: li.is_vacation_basis, + is_gross_deduction: li.is_gross_deduction, + is_net_deduction: li.is_net_deduction, + account_number: li.account_number, + sort_order: li.sort_order, + }) + } } } - } - return NextResponse.json({ - data: correctionRun, - message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.', - reversed_entry_count: entryIds.length, - }, { status: 201 }) -} + return NextResponse.json({ + data: correctionRun, + message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.', + reversed_entry_count: entryIds.length, + }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts b/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts index 438de757..4ff1d8d8 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, @@ -7,43 +8,83 @@ import { } from '@/tests/helpers' // ── Mocks ──────────────────────────────────────────────────── -// This route hand-rolls auth (createClient + getUser) rather than -// withRouteContext, so we mock createClient and the write/company helpers. +// The route is wrapped in withRouteContext. We inject a queued Supabase mock +// through requireAuth and mock the company/write helpers the wrapper uses. vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) - -const mockCreateClient = vi.fn() -vi.mock('@/lib/supabase/server', () => ({ createClient: () => mockCreateClient() })) - +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), -})) vi.mock('@/lib/salary/personnummer', () => ({ decryptPersonnummer: (x: string) => x, maskPersonnummer: (x: string) => x, })) import { PATCH } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' const mockUser = { id: 'user-1', email: 'test@test.se' } -function authedSupabase() { +function authed() { const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) return { supabase, enqueueMany } } describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edit', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 30000 }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 30000 }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + expect(response.status).toBe(403) }) it('updates the per-run monthly salary while the run is a draft', async () => { - const { enqueueMany } = authedSupabase() + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup { @@ -67,7 +108,7 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi }) it('allows a zero monthly salary (nollkörning) on a draft', async () => { - const { enqueueMany } = authedSupabase() + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', status: 'draft' } }, { data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 0 } }, @@ -88,7 +129,7 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi }) it('rejects a monthly salary edit when the run is no longer a draft', async () => { - const { enqueueMany } = authedSupabase() + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', status: 'review' } }, // not a draft ]) @@ -108,7 +149,7 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi }) it('rejects mixing a salary edit with a tax override in one request', async () => { - authedSupabase() + authed() const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { method: 'PATCH', diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts index 7a7506d5..63ad694a 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts @@ -1,8 +1,6 @@ -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 { validateBody } from '@/lib/api/validate' import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas' import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' @@ -10,47 +8,43 @@ import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer ensureInitialized() /** Fetch one employee's pay spec within a salary run, with employee + line items. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> }, -) { - const { id, employeeId } = 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; employeeId: string }> }>( + 'salary.run.employee.get', + async (_request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(*), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) + .maybeSingle() - const { data, error } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(*), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .eq('company_id', companyId) - .maybeSingle() + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + if (!data) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - if (!data) { - return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) - } + // Strip the encrypted personnummer ciphertext before sending to the browser: + // replace it with the YYYYMMDD-XXXX masked form so the page can render + // identity without exposing the suffix or the raw cipher blob. + const masked = { + ...data, + employee: data.employee + ? { + ...data.employee, + personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)), + } + : data.employee, + } - // Strip the encrypted personnummer ciphertext before sending to the browser: - // replace it with the YYYYMMDD-XXXX masked form so the page can render - // identity without exposing the suffix or the raw cipher blob. - const masked = { - ...data, - employee: data.employee - ? { - ...data.employee, - personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)), - } - : data.employee, - } - - return NextResponse.json({ data: masked }) -} + return NextResponse.json({ data: masked }) + }, +) /** * Per-employee edits within a salary run. Two operations, gated to different @@ -67,170 +61,158 @@ export async function GET( * the effective values; further changes require correction flows. Pass `null` * for any override field to clear it. */ -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> }, -) { - const { id, employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string; employeeId: string }> }>( + 'salary.run.employee.update', + async (request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const parsed = await validateBody(request, SalaryEmployeeOverrideSchema) + if (!parsed.success) return parsed.response - const companyId = await requireCompanyId(supabase, user.id) + // Two distinct operations share this endpoint, gated to different statuses: + // • monthly_salary → edit this month's base salary (draft only) + // • *_override → manual tax/avgifter adjustment (review only) + // They must not be mixed in one request. + const wantsSalaryEdit = parsed.data.monthly_salary !== undefined + const wantsOverride = + parsed.data.tax_withheld_override !== undefined || + parsed.data.avgifter_amount_override !== undefined || + parsed.data.avgifter_basis_override !== undefined || + parsed.data.reason !== undefined - const parsed = await validateBody(request, SalaryEmployeeOverrideSchema) - if (!parsed.success) return parsed.response - - // Two distinct operations share this endpoint, gated to different statuses: - // • monthly_salary → edit this month's base salary (draft only) - // • *_override → manual tax/avgifter adjustment (review only) - // They must not be mixed in one request. - const wantsSalaryEdit = parsed.data.monthly_salary !== undefined - const wantsOverride = - parsed.data.tax_withheld_override !== undefined || - parsed.data.avgifter_amount_override !== undefined || - parsed.data.avgifter_basis_override !== undefined || - parsed.data.reason !== undefined - - if (wantsSalaryEdit && wantsOverride) { - return NextResponse.json( - { error: 'Kan inte ändra månadslön och skatte-/avgiftsjustering i samma anrop.' }, - { status: 400 }, - ) - } - - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - - // ── Draft-stage edit of this month's base salary ── - if (wantsSalaryEdit) { - if (run.status !== 'draft') { + if (wantsSalaryEdit && wantsOverride) { return NextResponse.json( - { error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' }, + { error: 'Kan inte ändra månadslön och skatte-/avgiftsjustering i samma anrop.' }, { status: 400 }, ) } - const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100 - const { data: sre, error: sreErr } = await supabase + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + + // ── Draft-stage edit of this month's base salary ── + if (wantsSalaryEdit) { + if (run.status !== 'draft') { + return NextResponse.json( + { error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' }, + { status: 400 }, + ) + } + const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100 + + const { data: sre, error: sreErr } = await supabase + .from('salary_run_employees') + .update({ monthly_salary: monthly }) + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) + .select('id, employment_degree, salary_type, monthly_salary') + .maybeSingle() + + if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 }) + if (!sre) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } + + // Keep the displayed 'Grundlön' line consistent with the new salary. This is + // display-only: the engine recomputes baseSalary from monthly_salary at + // calc time, but it avoids a stale row before the user clicks Beräkna. + if (sre.salary_type === 'monthly') { + const baseAmount = Math.round(monthly * (sre.employment_degree / 100) * 100) / 100 + await supabase + .from('salary_line_items') + .update({ amount: baseAmount }) + .eq('salary_run_employee_id', sre.id) + .eq('company_id', companyId) + .eq('item_type', 'monthly_salary') + } + + return NextResponse.json({ data: sre }) + } + + // ── Review-stage override of tax/avgifter ── + if (run.status !== 'review') { + return NextResponse.json( + { error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' }, + { status: 400 }, + ) + } + + // Build patch: only include fields that were explicitly provided so + // unrelated overrides are not nulled. + const patch: Record = {} + if ('tax_withheld_override' in parsed.data) { + patch.tax_withheld_override = parsed.data.tax_withheld_override ?? null + } + if ('avgifter_amount_override' in parsed.data) { + patch.avgifter_amount_override = parsed.data.avgifter_amount_override ?? null + } + if ('avgifter_basis_override' in parsed.data) { + patch.avgifter_basis_override = parsed.data.avgifter_basis_override ?? null + } + if ('reason' in parsed.data) { + patch.override_reason = parsed.data.reason ?? null + } + + const { data, error } = await supabase .from('salary_run_employees') - .update({ monthly_salary: monthly }) + .update(patch) .eq('salary_run_id', id) .eq('employee_id', employeeId) .eq('company_id', companyId) - .select('id, employment_degree, salary_type, monthly_salary') + .select('id, tax_withheld, tax_withheld_override, avgifter_amount, avgifter_amount_override, avgifter_basis, avgifter_basis_override, override_reason') .maybeSingle() - if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 }) - if (!sre) { + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (!data) { return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) } - // Keep the displayed 'Grundlön' line consistent with the new salary. This is - // display-only: the engine recomputes baseSalary from monthly_salary at - // calc time, but it avoids a stale row before the user clicks Beräkna. - if (sre.salary_type === 'monthly') { - const baseAmount = Math.round(monthly * (sre.employment_degree / 100) * 100) / 100 - await supabase - .from('salary_line_items') - .update({ amount: baseAmount }) - .eq('salary_run_employee_id', sre.id) - .eq('company_id', companyId) - .eq('item_type', 'monthly_salary') - } - - return NextResponse.json({ data: sre }) - } - - // ── Review-stage override of tax/avgifter ── - if (run.status !== 'review') { - return NextResponse.json( - { error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' }, - { status: 400 }, - ) - } - - // Build patch: only include fields that were explicitly provided so - // unrelated overrides are not nulled. - const patch: Record = {} - if ('tax_withheld_override' in parsed.data) { - patch.tax_withheld_override = parsed.data.tax_withheld_override ?? null - } - if ('avgifter_amount_override' in parsed.data) { - patch.avgifter_amount_override = parsed.data.avgifter_amount_override ?? null - } - if ('avgifter_basis_override' in parsed.data) { - patch.avgifter_basis_override = parsed.data.avgifter_basis_override ?? null - } - if ('reason' in parsed.data) { - patch.override_reason = parsed.data.reason ?? null - } - - const { data, error } = await supabase - .from('salary_run_employees') - .update(patch) - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .eq('company_id', companyId) - .select('id, tax_withheld, tax_withheld_override, avgifter_amount, avgifter_amount_override, avgifter_basis, avgifter_basis_override, override_reason') - .maybeSingle() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (!data) { - return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) /** Remove employee from a draft salary run. Cascades to delete their line items. */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> } -) { - const { id, employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string; employeeId: string }> }>( + 'salary.run.employee.delete', + async (_request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!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 }) - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE) + const { error } = await supabase + .from('salary_run_employees') + .delete() + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) - if (!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 }) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - // Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE) - const { error } = await supabase - .from('salary_run_employees') - .delete() - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { deleted: true } }) -} + return NextResponse.json({ data: { deleted: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/employees/__tests__/route.test.ts b/app/api/salary/runs/[id]/employees/__tests__/route.test.ts new file mode 100644 index 00000000..9b0af4d5 --- /dev/null +++ b/app/api/salary/runs/[id]/employees/__tests__/route.test.ts @@ -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' + +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } +const EMP_UUID = '11111111-1111-4111-8111-111111111111' + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('POST /api/salary/runs/[id]/employees', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: EMP_UUID }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: EMP_UUID }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('returns 400 on an invalid body', async () => { + authed() + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: 'not-a-uuid' }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(400) + }) + + it('adds an employee to a draft run and returns 201', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { + data: { + id: EMP_UUID, + employment_degree: 100, + monthly_salary: 30000, + salary_type: 'monthly', + tax_table_number: 31, + tax_column: 1, + employment_type: 'employee', + hourly_rate: null, + }, + }, // employees lookup + { data: null }, // already-added check (not present) + { data: { id: 'sre-1', employee_id: EMP_UUID } }, // insert salary_run_employees + { data: null }, // insert base line item + ]) + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: EMP_UUID }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('sre-1') + }) +}) diff --git a/app/api/salary/runs/[id]/employees/route.ts b/app/api/salary/runs/[id]/employees/route.ts index dd08e810..2fe29471 100644 --- a/app/api/salary/runs/[id]/employees/route.ts +++ b/app/api/salary/runs/[id]/employees/route.ts @@ -1,119 +1,111 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { AddEmployeeToRunSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { getLineItemAccount } from '@/lib/salary/account-mapping' import type { SalaryLineItemType } from '@/types' ensureInitialized() -export async function POST( - 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 POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.employee.add', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validation = await validateBody(request, AddEmployeeToRunSchema) + if (!validation.success) return validation.response + const body = validation.data - const companyId = await requireCompanyId(supabase, user.id) + // Verify run is draft + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const validation = await validateBody(request, AddEmployeeToRunSchema) - if (!validation.success) return validation.response - const body = validation.data + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + if (run.status !== 'draft') { + return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 }) + } - // Verify run is draft - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Verify employee exists and is active + const { data: employee, error: empError } = await supabase + .from('employees') + .select('*') + .eq('id', body.employee_id) + .eq('company_id', companyId) + .eq('is_active', true) + .single() - if (runError || !run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } - if (run.status !== 'draft') { - return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 }) - } + if (empError || !employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } - // Verify employee exists and is active - const { data: employee, error: empError } = await supabase - .from('employees') - .select('*') - .eq('id', body.employee_id) - .eq('company_id', companyId) - .eq('is_active', true) - .single() + // Check if already added + const { data: existing } = await supabase + .from('salary_run_employees') + .select('id') + .eq('salary_run_id', id) + .eq('employee_id', body.employee_id) + .single() - if (empError || !employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + if (existing) { + return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 }) + } - // Check if already added - const { data: existing } = await supabase - .from('salary_run_employees') - .select('id') - .eq('salary_run_id', id) - .eq('employee_id', body.employee_id) - .single() + // Snapshot employee data + const { data: sre, error: sreError } = await supabase + .from('salary_run_employees') + .insert({ + salary_run_id: id, + employee_id: employee.id, + company_id: companyId, + employment_degree: employee.employment_degree, + monthly_salary: employee.monthly_salary || 0, + salary_type: employee.salary_type, + hours_worked: body.hours_worked || null, + tax_table_number: employee.tax_table_number, + tax_column: employee.tax_column, + }) + .select() + .single() - if (existing) { - return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 }) - } + if (sreError) { + return NextResponse.json({ error: sreError.message }, { status: 500 }) + } - // Snapshot employee data - const { data: sre, error: sreError } = await supabase - .from('salary_run_employees') - .insert({ - salary_run_id: id, - employee_id: employee.id, - company_id: companyId, - employment_degree: employee.employment_degree, - monthly_salary: employee.monthly_salary || 0, - salary_type: employee.salary_type, - hours_worked: body.hours_worked || null, - tax_table_number: employee.tax_table_number, - tax_column: employee.tax_column, - }) - .select() - .single() + // Auto-create base salary line item + const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary' + let baseAmount: number + if (employee.salary_type === 'monthly') { + baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100 + } else { + baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100 + } - if (sreError) { - return NextResponse.json({ error: sreError.message }, { status: 500 }) - } + await supabase + .from('salary_line_items') + .insert({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: baseSalaryType, + description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön', + quantity: employee.salary_type === 'hourly' ? body.hours_worked : null, + unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null, + amount: baseAmount, + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: true, + account_number: getLineItemAccount(baseSalaryType, employee.employment_type), + sort_order: 0, + }) - // Auto-create base salary line item - const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary' - let baseAmount: number - if (employee.salary_type === 'monthly') { - baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100 - } else { - baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100 - } - - await supabase - .from('salary_line_items') - .insert({ - salary_run_employee_id: sre.id, - company_id: companyId, - item_type: baseSalaryType, - description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön', - quantity: employee.salary_type === 'hourly' ? body.hours_worked : null, - unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null, - amount: baseAmount, - is_taxable: true, - is_avgift_basis: true, - is_vacation_basis: true, - account_number: getLineItemAccount(baseSalaryType, employee.employment_type), - sort_order: 0, - }) - - return NextResponse.json({ data: sre }, { status: 201 }) -} + return NextResponse.json({ data: sre }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/lines/[lineId]/__tests__/route.test.ts b/app/api/salary/runs/[id]/lines/[lineId]/__tests__/route.test.ts new file mode 100644 index 00000000..4a7aad8f --- /dev/null +++ b/app/api/salary/runs/[id]/lines/[lineId]/__tests__/route.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { PATCH, DELETE } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +const params = () => createMockRouteParams({ id: 'run-1', lineId: 'line-1' }) + +describe('PATCH /api/salary/runs/[id]/lines/[lineId]', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await PATCH( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body: { amount: 100 } }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await PATCH( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body: { amount: 100 } }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('updates a line on a draft run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { data: { id: 'line-1', amount: 100 } }, // update returning + ]) + const response = await PATCH( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body: { amount: 100 } }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + expect(status).toBe(200) + expect(body.data.id).toBe('line-1') + }) +}) + +describe('DELETE /api/salary/runs/[id]/lines/[lineId]', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await DELETE( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'DELETE' }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await DELETE( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'DELETE' }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('deletes a line on a draft run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { data: null }, // delete (error null) + ]) + const response = await DELETE( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'DELETE' }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response) + expect(status).toBe(200) + expect(body.data.deleted).toBe(true) + }) +}) diff --git a/app/api/salary/runs/[id]/lines/[lineId]/route.ts b/app/api/salary/runs/[id]/lines/[lineId]/route.ts index 69a6bb50..fdae1137 100644 --- a/app/api/salary/runs/[id]/lines/[lineId]/route.ts +++ b/app/api/salary/runs/[id]/lines/[lineId]/route.ts @@ -1,97 +1,83 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string; lineId: string }> } -) { - const { id, lineId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>( + 'salary.run.line.update', + async (request, ctx, { params }) => { + const { id, lineId } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!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 }) - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + const validation = await validateBody(request, UpdateSalaryLineItemSchema) + if (!validation.success) return validation.response + const body = validation.data - if (!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 }) + // Round amount if provided + const updates = { ...body } + if (updates.amount !== undefined) { + updates.amount = Math.round(updates.amount * 100) / 100 + } - const validation = await validateBody(request, UpdateSalaryLineItemSchema) - if (!validation.success) return validation.response - const body = validation.data + const { data: updated, error } = await supabase + .from('salary_line_items') + .update(updates) + .eq('id', lineId) + .eq('company_id', companyId) + .select() + .single() - // Round amount if provided - const updates = { ...body } - if (updates.amount !== undefined) { - updates.amount = Math.round(updates.amount * 100) / 100 - } + if (error || !updated) { + return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 }) + } - const { data: updated, error } = await supabase - .from('salary_line_items') - .update(updates) - .eq('id', lineId) - .eq('company_id', companyId) - .select() - .single() + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) - if (error || !updated) { - return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 }) - } +export const DELETE = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>( + 'salary.run.line.delete', + async (_request, ctx, { params }) => { + const { id, lineId } = await params + const { supabase, companyId } = ctx - return NextResponse.json({ data: updated }) -} + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string; lineId: string }> } -) { - const { id, lineId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (!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 writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { error } = await supabase + .from('salary_line_items') + .delete() + .eq('id', lineId) + .eq('company_id', companyId) - const companyId = await requireCompanyId(supabase, user.id) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (!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 { error } = await supabase - .from('salary_line_items') - .delete() - .eq('id', lineId) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { deleted: true } }) -} + return NextResponse.json({ data: { deleted: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/lines/__tests__/route.test.ts b/app/api/salary/runs/[id]/lines/__tests__/route.test.ts new file mode 100644 index 00000000..be3ff650 --- /dev/null +++ b/app/api/salary/runs/[id]/lines/__tests__/route.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } +const SRE_UUID = '22222222-2222-4222-8222-222222222222' + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +const validBody = { + salary_run_employee_id: SRE_UUID, + item_type: 'bonus', + description: 'Bonus', + amount: 5000, +} + +describe('POST /api/salary/runs/[id]/lines', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/lines', { method: 'POST', body: validBody }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/lines', { method: 'POST', body: validBody }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('creates a line item on a draft run and returns 201', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { data: { id: SRE_UUID, employee_id: 'emp-1' } }, // salary_run_employees membership + { data: { id: 'li-1' } }, // insert line item + ]) + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/lines', { method: 'POST', body: validBody }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('li-1') + }) +}) diff --git a/app/api/salary/runs/[id]/lines/route.ts b/app/api/salary/runs/[id]/lines/route.ts index cf8bc2ca..48915b9a 100644 --- a/app/api/salary/runs/[id]/lines/route.ts +++ b/app/api/salary/runs/[id]/lines/route.ts @@ -1,86 +1,78 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateSalaryLineItemSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { getLineItemAccount } from '@/lib/salary/account-mapping' ensureInitialized() -export async function POST( - 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 POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.line.create', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validation = await validateBody(request, CreateSalaryLineItemSchema) + if (!validation.success) return validation.response + const body = validation.data - const companyId = await requireCompanyId(supabase, user.id) + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const validation = await validateBody(request, CreateSalaryLineItemSchema) - if (!validation.success) return validation.response - const body = validation.data + if (!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 }) + } - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Verify salary_run_employee belongs to this run + const { data: sre } = await supabase + .from('salary_run_employees') + .select('id, employee_id') + .eq('id', body.salary_run_employee_id) + .eq('salary_run_id', id) + .single() - if (!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 }) - } + if (!sre) { + return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 }) + } - // Verify salary_run_employee belongs to this run - const { data: sre } = await supabase - .from('salary_run_employees') - .select('id, employee_id') - .eq('id', body.salary_run_employee_id) - .eq('salary_run_id', id) - .single() + // Auto-resolve account if not provided + const accountNumber = body.account_number || getLineItemAccount(body.item_type as never) - if (!sre) { - return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 }) - } + const { data: lineItem, error } = await supabase + .from('salary_line_items') + .insert({ + salary_run_employee_id: body.salary_run_employee_id, + company_id: companyId, + item_type: body.item_type, + description: body.description, + quantity: body.quantity || null, + unit_price: body.unit_price || null, + amount: Math.round(body.amount * 100) / 100, + is_taxable: body.is_taxable, + is_avgift_basis: body.is_avgift_basis, + is_vacation_basis: body.is_vacation_basis, + is_gross_deduction: body.is_gross_deduction, + is_net_deduction: body.is_net_deduction, + account_number: accountNumber, + sort_order: body.sort_order, + }) + .select() + .single() - // Auto-resolve account if not provided - const accountNumber = body.account_number || getLineItemAccount(body.item_type as never) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const { data: lineItem, error } = await supabase - .from('salary_line_items') - .insert({ - salary_run_employee_id: body.salary_run_employee_id, - company_id: companyId, - item_type: body.item_type, - description: body.description, - quantity: body.quantity || null, - unit_price: body.unit_price || null, - amount: Math.round(body.amount * 100) / 100, - is_taxable: body.is_taxable, - is_avgift_basis: body.is_avgift_basis, - is_vacation_basis: body.is_vacation_basis, - is_gross_deduction: body.is_gross_deduction, - is_net_deduction: body.is_net_deduction, - account_number: accountNumber, - sort_order: body.sort_order, - }) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: lineItem }, { status: 201 }) -} + return NextResponse.json({ data: lineItem }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts b/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts new file mode 100644 index 00000000..7d384bda --- /dev/null +++ b/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers' + +// The route is wrapped in withRouteContext and gated with requireWrite (it +// persists payment_file_generated_at). The file generator, net-payout helper and +// bankgiro validator are stubbed so we can exercise auth + the happy path. +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/salary/payment/bg-lb-generator', () => ({ + generateBgLb: vi.fn(() => ({ content: 'LBFILE', filename: 'lb_2026-03.txt' })), +})) +vi.mock('@/lib/salary/payment/effective-net', () => ({ + effectiveNetPayout: vi.fn(() => 20000), +})) +vi.mock('@/lib/bankgiro/luhn', () => ({ + validateBankgiroNumber: vi.fn(() => true), +})) + +import { GET } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('GET /api/salary/runs/[id]/payment/bg-lb', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/bg-lb'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/bg-lb'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('generates a Bankgirot LB file for an approved run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'approved', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } }, + { data: { name: 'Bolaget AB' } }, // companies + { data: { company_name: 'Bolaget AB', bankgiro: '123-4567' } }, // company_settings + { + data: [ + { + employee: { first_name: 'Anna', last_name: 'A', clearing_number: '1234', bank_account_number: '567890' }, + }, + ], + }, // salary_run_employees + { data: null }, // salary_runs update (payment_file_generated_at) + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/bg-lb'), + createMockRouteParams({ id: 'run-1' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=iso-8859-1') + expect(response.headers.get('Content-Disposition')).toContain('lb_2026-03.txt') + }) +}) diff --git a/app/api/salary/runs/[id]/payment/bg-lb/route.ts b/app/api/salary/runs/[id]/payment/bg-lb/route.ts index 27899bbe..7c5a4272 100644 --- a/app/api/salary/runs/[id]/payment/bg-lb/route.ts +++ b/app/api/salary/runs/[id]/payment/bg-lb/route.ts @@ -1,8 +1,6 @@ -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 { generateBgLb } from '@/lib/salary/payment/bg-lb-generator' import { effectiveNetPayout } from '@/lib/salary/payment/effective-net' import { validateBankgiroNumber } from '@/lib/bankgiro/luhn' @@ -20,146 +18,140 @@ ensureInitialized() * Per BFL: The payment file is räkenskapsinformation linked to the salary * journal entry. Subject to 7-year retention. */ -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.run.payment.bg_lb', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - const { data: run } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (!['approved', 'paid', 'booked'].includes(run.status)) { + return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) + } - if (!run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } + const { data: company } = await supabase + .from('companies') + .select('name') + .eq('id', companyId) + .single() - if (!['approved', 'paid', 'booked'].includes(run.status)) { - return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) - } + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } - const { data: company } = await supabase - .from('companies') - .select('name') - .eq('id', companyId) - .single() + const { data: settings } = await supabase + .from('company_settings') + .select('company_name, bankgiro') + .eq('company_id', companyId) + .single() - if (!company) { - return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - } + if (!settings?.bankgiro) { + return NextResponse.json( + { error: 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.' }, + { status: 400 } + ) + } - const { data: settings } = await supabase - .from('company_settings') - .select('company_name, bankgiro') - .eq('company_id', companyId) - .single() + if (!validateBankgiroNumber(settings.bankgiro)) { + return NextResponse.json( + { error: 'Bankgironumret i företagsinställningar är ogiltigt (felaktig kontrollsiffra).' }, + { status: 400 } + ) + } - if (!settings?.bankgiro) { - return NextResponse.json( - { error: 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.' }, - { status: 400 } - ) - } + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') + .eq('salary_run_id', id) - if (!validateBankgiroNumber(settings.bankgiro)) { - return NextResponse.json( - { error: 'Bankgironumret i företagsinställningar är ogiltigt (felaktig kontrollsiffra).' }, - { status: 400 } - ) - } + if (!runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') - .eq('salary_run_id', id) - - if (!runEmployees || runEmployees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) - } - - // Only employees with a positive payout end up in the file (see filter - // below), so missing bank details must only block when they're actually - // being paid: a zero-net employee needs no destination account. - const missingBank = runEmployees.filter((sre) => { - if (effectiveNetPayout(sre) <= 0) return false - const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null - return !emp?.clearing_number || !emp?.bank_account_number - }) - - if (missingBank.length > 0) { - return NextResponse.json( - { error: `${missingBank.length} anställd(a) saknar bankkontouppgifter` }, - { status: 400 } - ) - } - - const companyData: BgLbCompanyData = { - // Sender name follows the current company name (company_settings.company_name), - // not the frozen onboarding companies.name. - name: settings.company_name || company.name, - senderBankgiro: settings.bankgiro, - } - - const employees: BgLbEmployee[] = runEmployees - // Honor tax override on the bank payment file too: the net the employee - // actually receives depends on the effective tax. - .map((sre) => ({ sre, effectiveNet: effectiveNetPayout(sre) })) - .filter(({ effectiveNet }) => effectiveNet > 0) - .map(({ sre, effectiveNet }) => { - const emp = sre.employee as { - first_name: string - last_name: string - clearing_number: string - bank_account_number: string - } - return { - name: `${emp.first_name} ${emp.last_name}`, - clearingNumber: emp.clearing_number, - bankAccountNumber: emp.bank_account_number, - netSalary: effectiveNet, - } + // Only employees with a positive payout end up in the file (see filter + // below), so missing bank details must only block when they're actually + // being paid: a zero-net employee needs no destination account. + const missingBank = runEmployees.filter((sre) => { + if (effectiveNetPayout(sre) <= 0) return false + const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null + return !emp?.clearing_number || !emp?.bank_account_number }) - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + if (missingBank.length > 0) { + return NextResponse.json( + { error: `${missingBank.length} anställd(a) saknar bankkontouppgifter` }, + { status: 400 } + ) + } - let result - try { - result = generateBgLb(companyData, employees, { - paymentDate: run.payment_date, - periodLabel, + const companyData: BgLbCompanyData = { + // Sender name follows the current company name (company_settings.company_name), + // not the frozen onboarding companies.name. + name: settings.company_name || company.name, + senderBankgiro: settings.bankgiro, + } + + const employees: BgLbEmployee[] = runEmployees + // Honor tax override on the bank payment file too: the net the employee + // actually receives depends on the effective tax. + .map((sre) => ({ sre, effectiveNet: effectiveNetPayout(sre) })) + .filter(({ effectiveNet }) => effectiveNet > 0) + .map(({ sre, effectiveNet }) => { + const emp = sre.employee as { + first_name: string + last_name: string + clearing_number: string + bank_account_number: string + } + return { + name: `${emp.first_name} ${emp.last_name}`, + clearingNumber: emp.clearing_number, + bankAccountNumber: emp.bank_account_number, + netSalary: effectiveNet, + } + }) + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + + let result + try { + result = generateBgLb(companyData, employees, { + paymentDate: run.payment_date, + periodLabel, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Kunde inte generera Bankgirot LB-fil' + return NextResponse.json({ error: msg }, { status: 400 }) + } + + await supabase + .from('salary_runs') + .update({ + payment_file_format: 'bg_lb', + payment_file_generated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + + // ISO 8859-1 encoding: re-encode the JS string to Latin-1 bytes. + const buffer = Buffer.from(result.content, 'latin1') + + return new Response(buffer, { + headers: { + 'Content-Type': 'text/plain; charset=iso-8859-1', + 'Content-Disposition': `attachment; filename="${result.filename}"`, + }, }) - } catch (err) { - const msg = err instanceof Error ? err.message : 'Kunde inte generera Bankgirot LB-fil' - return NextResponse.json({ error: msg }, { status: 400 }) - } - - await supabase - .from('salary_runs') - .update({ - payment_file_format: 'bg_lb', - payment_file_generated_at: new Date().toISOString(), - }) - .eq('id', id) - .eq('company_id', companyId) - - // ISO 8859-1 encoding: re-encode the JS string to Latin-1 bytes. - const buffer = Buffer.from(result.content, 'latin1') - - return new Response(buffer, { - headers: { - 'Content-Type': 'text/plain; charset=iso-8859-1', - 'Content-Disposition': `attachment; filename="${result.filename}"`, - }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/payment/pain001/__tests__/route.test.ts b/app/api/salary/runs/[id]/payment/pain001/__tests__/route.test.ts new file mode 100644 index 00000000..d9190a86 --- /dev/null +++ b/app/api/salary/runs/[id]/payment/pain001/__tests__/route.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers' + +// The route is wrapped in withRouteContext and gated with requireWrite (it +// persists payment_file_generated_at). The XML generator, net-payout helper and +// branding are stubbed so we can exercise auth + the happy path. +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/salary/payment/pain001-generator', () => ({ + generatePain001: vi.fn(() => ''), +})) +vi.mock('@/lib/salary/payment/effective-net', () => ({ + effectiveNetPayout: vi.fn(() => 20000), +})) +vi.mock('@/lib/branding/service', () => ({ + getBranding: vi.fn(() => ({ appName: 'gnubok' })), +})) + +import { GET } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('GET /api/salary/runs/[id]/payment/pain001', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/pain001'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/pain001'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('generates a pain.001 file for an approved run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'approved', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } }, + { data: { name: 'Bolaget AB', org_number: '5560000000' } }, // companies + { data: { company_name: 'Bolaget AB', iban: 'SE4550000000058398257466', bic: 'NDEASESS' } }, // settings + { + data: [ + { + employee: { first_name: 'Anna', last_name: 'A', clearing_number: '1234', bank_account_number: '567890' }, + }, + ], + }, // salary_run_employees + { data: null }, // salary_runs update + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/pain001'), + createMockRouteParams({ id: 'run-1' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/xml; charset=utf-8') + expect(response.headers.get('Content-Disposition')).toContain('pain001_lon_2026-03.xml') + }) +}) diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts index 0919bf58..96eb20bc 100644 --- a/app/api/salary/runs/[id]/payment/pain001/route.ts +++ b/app/api/salary/runs/[id]/payment/pain001/route.ts @@ -1,8 +1,6 @@ -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 { generatePain001 } from '@/lib/salary/payment/pain001-generator' import { effectiveNetPayout } from '@/lib/salary/payment/effective-net' import { getBranding } from '@/lib/branding/service' @@ -18,126 +16,120 @@ ensureInitialized() * * The file is uploaded to the bank's corporate portal for batch payment. */ -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.run.payment.pain001', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Load salary run + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - // Load salary run - const { data: run } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (!['approved', 'paid', 'booked'].includes(run.status)) { + return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) + } - if (!run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } + // Load company + settings + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() - if (!['approved', 'paid', 'booked'].includes(run.status)) { - return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) - } + const { data: settings } = await supabase + .from('company_settings') + .select('company_name, iban, bic') + .eq('company_id', companyId) + .single() - // Load company + settings - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } - const { data: settings } = await supabase - .from('company_settings') - .select('company_name, iban, bic') - .eq('company_id', companyId) - .single() + if (!settings?.iban || !settings?.bic) { + return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 }) + } - if (!company) { - return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - } + // Load employees + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') + .eq('salary_run_id', id) - if (!settings?.iban || !settings?.bic) { - return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 }) - } + if (!runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } - // Load employees - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') - .eq('salary_run_id', id) - - if (!runEmployees || runEmployees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) - } - - // Validate bank accounts, but only for employees who will actually appear - // in the file (positive payout). A zero-net employee is filtered out below, - // so missing bank details for them must not block the file. - const missingBank = runEmployees.filter(sre => { - if (effectiveNetPayout(sre) <= 0) return false - const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null - return !emp?.clearing_number || !emp?.bank_account_number - }) - - if (missingBank.length > 0) { - return NextResponse.json({ - error: `${missingBank.length} anställd(a) saknar bankkontouppgifter`, - }, { status: 400 }) - } - - const companyData: Pain001CompanyData = { - // Sender name follows the current company name (company_settings.company_name), - // not the frozen onboarding companies.name. - name: settings.company_name || company.name, - orgNumber: company.org_number || '', - iban: settings.iban, - bic: settings.bic, - } - - const employees: Pain001Employee[] = runEmployees - .map(sre => ({ sre, effectiveNet: effectiveNetPayout(sre) })) - .filter(({ effectiveNet }) => effectiveNet > 0) - .map(({ sre, effectiveNet }) => { - const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string } - return { - name: `${emp.first_name} ${emp.last_name}`, - clearingNumber: emp.clearing_number, - bankAccountNumber: emp.bank_account_number, - netSalary: effectiveNet, - } + // Validate bank accounts, but only for employees who will actually appear + // in the file (positive payout). A zero-net employee is filtered out below, + // so missing bank details for them must not block the file. + const missingBank = runEmployees.filter(sre => { + if (effectiveNetPayout(sre) <= 0) return false + const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null + return !emp?.clearing_number || !emp?.bank_account_number }) - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const messageId = `${getBranding().appName.toUpperCase()}-${company.org_number?.replace('-', '')}-${periodLabel}` + if (missingBank.length > 0) { + return NextResponse.json({ + error: `${missingBank.length} anställd(a) saknar bankkontouppgifter`, + }, { status: 400 }) + } - const xml = generatePain001(companyData, employees, { - messageId, - paymentDate: run.payment_date, - periodLabel, - }) + const companyData: Pain001CompanyData = { + // Sender name follows the current company name (company_settings.company_name), + // not the frozen onboarding companies.name. + name: settings.company_name || company.name, + orgNumber: company.org_number || '', + iban: settings.iban, + bic: settings.bic, + } - await supabase - .from('salary_runs') - .update({ - payment_file_format: 'pain001', - payment_file_generated_at: new Date().toISOString(), + const employees: Pain001Employee[] = runEmployees + .map(sre => ({ sre, effectiveNet: effectiveNetPayout(sre) })) + .filter(({ effectiveNet }) => effectiveNet > 0) + .map(({ sre, effectiveNet }) => { + const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string } + return { + name: `${emp.first_name} ${emp.last_name}`, + clearingNumber: emp.clearing_number, + bankAccountNumber: emp.bank_account_number, + netSalary: effectiveNet, + } + }) + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const messageId = `${getBranding().appName.toUpperCase()}-${company.org_number?.replace('-', '')}-${periodLabel}` + + const xml = generatePain001(companyData, employees, { + messageId, + paymentDate: run.payment_date, + periodLabel, }) - .eq('id', id) - .eq('company_id', companyId) - return new Response(xml, { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="pain001_lon_${periodLabel}.xml"`, - }, - }) -} + await supabase + .from('salary_runs') + .update({ + payment_file_format: 'pain001', + payment_file_generated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="pain001_lon_${periodLabel}.xml"`, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts index aa7f9fc6..04de05b8 100644 --- a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers' +// The route is wrapped in withRouteContext. Auth/company are injected via the +// mocked requireAuth + getActiveCompanyId; the PDF pipeline is fully stubbed. vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), getCompanyDisplayName: vi.fn().mockResolvedValue('Ny Firma AB'), })) @@ -17,16 +21,19 @@ vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({ })) import { GET } from '../route' -import { createClient } from '@/lib/supabase/server' +import { requireAuth } from '@/lib/auth/require-auth' import { getCompanyDisplayName } from '@/lib/company/context' import { buildPayslipData } from '@/lib/salary/payslips/build-payslip-data' -const mockUser = { id: 'user-1' } +const mockUser = { id: 'user-1', email: 'test@test.se' } -function mockClient(user: unknown) { +function authed() { const { supabase, enqueue, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser = vi.fn().mockResolvedValue({ data: { user }, error: null }) - vi.mocked(createClient).mockResolvedValue(supabase as never) + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) return { supabase, enqueue, enqueueMany } } @@ -37,7 +44,11 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('returns 401 when unauthenticated', async () => { - mockClient(null) + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const response = await GET( createMockRequest('/api/salary/runs/run-1/payslips/emp-1/pdf'), createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), @@ -46,7 +57,7 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('returns 404 when the run does not exist', async () => { - const { enqueueMany } = mockClient(mockUser) + const { enqueueMany } = authed() enqueueMany([{ data: null }]) const response = await GET( createMockRequest('/api/salary/runs/run-x/payslips/emp-1/pdf'), @@ -56,7 +67,7 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('renders the payslip PDF with the current company name', async () => { - const { enqueueMany } = mockClient(mockUser) + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } }, { data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } }, @@ -78,7 +89,7 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('falls back to companies.name when the resolver returns null', async () => { - const { enqueueMany } = mockClient(mockUser) + const { enqueueMany } = authed() vi.mocked(getCompanyDisplayName).mockResolvedValue(null) enqueueMany([ { data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } }, diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts index bdec542e..60ff6a1d 100644 --- a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts +++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts @@ -1,7 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId, getCompanyDisplayName } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { getCompanyDisplayName } from '@/lib/company/context' import { renderToBuffer } from '@react-pdf/renderer' import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data' @@ -17,76 +17,72 @@ ensureInitialized() * Data assembly is shared with the public token surface via * lib/salary/payslips/build-payslip-data — both must render identical PDFs. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> } -) { - const { id, employeeId } = 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; employeeId: string }> }>( + 'salary.run.payslip.pdf', + async (_request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + // Load salary run + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - // Load salary run - const { data: run } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - if (!run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } + // Load salary run employee + const { data: sre } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .single() - // Load salary run employee - const { data: sre } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .single() + if (!sre) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } - if (!sre) { - return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) - } + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() - // Load company - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } - if (!company) { - return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - } + const emp = sre.employee as { + first_name: string; last_name: string; personnummer: string; personnummer_last4: string; + employment_type: string; tax_table_number: number | null; tax_column: number; + clearing_number: string | null; bank_account_number: string | null; + } - const emp = sre.employee as { - first_name: string; last_name: string; personnummer: string; personnummer_last4: string; - employment_type: string; tax_table_number: number | null; tax_column: number; - clearing_number: string | null; bank_account_number: string | null; - } + // Employer name on the payslip follows the current company name + // (company_settings.company_name), not the frozen onboarding companies.name. + const displayName = await getCompanyDisplayName(supabase, companyId) + const data = buildPayslipData({ + run, + sre, + employee: emp, + company: { name: displayName ?? company.name, org_number: company.org_number }, + }) + const fileName = payslipFileName(run, emp) - // Employer name on the payslip follows the current company name - // (company_settings.company_name), not the frozen onboarding companies.name. - const displayName = await getCompanyDisplayName(supabase, companyId) - const data = buildPayslipData({ - run, - sre, - employee: emp, - company: { name: displayName ?? company.name, org_number: company.org_number }, - }) - const fileName = payslipFileName(run, emp) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buffer = await renderToBuffer(PayslipPDF({ data }) as any) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const buffer = await renderToBuffer(PayslipPDF({ data }) as any) - - return new Response(buffer as unknown as BodyInit, { - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `inline; filename="${fileName}"`, - }, - }) -} + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${fileName}"`, + }, + }) + }, +) diff --git a/app/api/salary/runs/[id]/preview/route.ts b/app/api/salary/runs/[id]/preview/route.ts index 8aa0a6ce..3f12edc0 100644 --- a/app/api/salary/runs/[id]/preview/route.ts +++ b/app/api/salary/runs/[id]/preview/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { SALARY_ACCOUNTS, getLineItemAccount } from '@/lib/salary/account-mapping' import type { CreateJournalEntryLineInput } from '@/types' @@ -11,190 +10,186 @@ ensureInitialized() * Preview the journal entries that would be created when booking this salary run. * Shows exact BAS accounts and amounts: this is a key differentiator. */ -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.run.preview', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (runError || !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(employment_type), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - - if (!employees || employees.length === 0) { - return NextResponse.json({ error: 'Inga beräknade resultat: kör beräkning först' }, { status: 400 }) - } - - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const desc = `Lön ${periodLabel}` - - // Build salary entry preview - const salaryLines: CreateJournalEntryLineInput[] = [] - const expenseByAccount = new Map() - - for (const sre of employees) { - for (const li of sre.line_items || []) { - if (li.is_net_deduction || li.is_gross_deduction) continue - const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee') - expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount) + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) } - } - for (const [account, amount] of expenseByAccount) { - if (amount === 0) continue - salaryLines.push({ - account_number: account, - debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0, - credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0, - line_description: `${desc}`, - }) - } + // Load employees with line items + const { data: employees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .eq('salary_run_id', id) - const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0) - if (totalTax > 0) { - salaryLines.push({ - account_number: SALARY_ACCOUNTS.TAX_WITHHELD, - debit_amount: 0, - credit_amount: Math.round(totalTax * 100) / 100, - line_description: `${desc}: Personalskatt`, - }) - } + if (!employees || employees.length === 0) { + return NextResponse.json({ error: 'Inga beräknade resultat: kör beräkning först' }, { status: 400 }) + } - const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0) - if (totalNet > 0) { - salaryLines.push({ - account_number: SALARY_ACCOUNTS.BANK, - debit_amount: 0, - credit_amount: Math.round(totalNet * 100) / 100, - line_description: `${desc}: Nettolön`, - }) - } + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const desc = `Lön ${periodLabel}` - // Build avgifter entry preview: skipped for a nollkörning (0 avgifter), - // mirroring the vacation/pension guards below. The bookkeeping engine never - // posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path), - // so previewing one would falsely imply a verifikat that is never created. - const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0) - const roundedAvgifter = Math.round(totalAvgifter * 100) / 100 - const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0 - ? [ + // Build salary entry preview + const salaryLines: CreateJournalEntryLineInput[] = [] + const expenseByAccount = new Map() + + for (const sre of employees) { + for (const li of sre.line_items || []) { + if (li.is_net_deduction || li.is_gross_deduction) continue + const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee') + expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount) + } + } + + for (const [account, amount] of expenseByAccount) { + if (amount === 0) continue + salaryLines.push({ + account_number: account, + debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0, + credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0, + line_description: `${desc}`, + }) + } + + const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0) + if (totalTax > 0) { + salaryLines.push({ + account_number: SALARY_ACCOUNTS.TAX_WITHHELD, + debit_amount: 0, + credit_amount: Math.round(totalTax * 100) / 100, + line_description: `${desc}: Personalskatt`, + }) + } + + const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0) + if (totalNet > 0) { + salaryLines.push({ + account_number: SALARY_ACCOUNTS.BANK, + debit_amount: 0, + credit_amount: Math.round(totalNet * 100) / 100, + line_description: `${desc}: Nettolön`, + }) + } + + // Build avgifter entry preview: skipped for a nollkörning (0 avgifter), + // mirroring the vacation/pension guards below. The bookkeeping engine never + // posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path), + // so previewing one would falsely imply a verifikat that is never created. + const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0) + const roundedAvgifter = Math.round(totalAvgifter * 100) / 100 + const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0 + ? [ + { + account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, + debit_amount: roundedAvgifter, + credit_amount: 0, + line_description: `${desc}: Arbetsgivaravgifter`, + }, + { + account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, + debit_amount: 0, + credit_amount: roundedAvgifter, + line_description: `${desc}: Arbetsgivaravgifter`, + }, + ] + : [] + + // Build vacation entry preview + const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0) + const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0) + const vacationLines: CreateJournalEntryLineInput[] = [] + if (totalVacation > 0) { + vacationLines.push( { - account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, - debit_amount: roundedAvgifter, + account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE, + debit_amount: Math.round(totalVacation * 100) / 100, credit_amount: 0, - line_description: `${desc}: Arbetsgivaravgifter`, + line_description: `${desc}: Semesteravsättning`, }, { - account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, + account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY, debit_amount: 0, - credit_amount: roundedAvgifter, - line_description: `${desc}: Arbetsgivaravgifter`, - }, - ] - : [] - - // Build vacation entry preview - const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0) - const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0) - const vacationLines: CreateJournalEntryLineInput[] = [] - if (totalVacation > 0) { - vacationLines.push( - { - account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE, - debit_amount: Math.round(totalVacation * 100) / 100, - credit_amount: 0, - line_description: `${desc}: Semesteravsättning`, - }, - { - account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY, - debit_amount: 0, - credit_amount: Math.round(totalVacation * 100) / 100, - line_description: `${desc}: Semesteravsättning`, - } - ) - } - if (totalVacationAvgifter > 0) { - vacationLines.push( - { - account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE, - debit_amount: Math.round(totalVacationAvgifter * 100) / 100, - credit_amount: 0, - line_description: `${desc}: Sociala avgifter semester`, - }, - { - account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY, - debit_amount: 0, - credit_amount: Math.round(totalVacationAvgifter * 100) / 100, - line_description: `${desc}: Sociala avgifter semester`, - } - ) - } - - // Build pension entry preview (löneväxling, per deductions-lonevaxling.md) - // This would be populated from salary_line_items with type 'gross_deduction_pension' - // For now, pension preview is shown when pension line items exist - const pensionLineItems = employees.flatMap(e => - ((e.line_items || []) as Array>) - .filter(li => li.item_type === 'gross_deduction_pension') - ) - const pensionLines: CreateJournalEntryLineInput[] = [] - if (pensionLineItems.length > 0) { - const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0)) - const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100 - const slp = Math.round(pensionContribution * 0.2426 * 100) / 100 - if (pensionContribution > 0) { - pensionLines.push( - { account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc}: Pensionsförsäkringspremier` }, - { account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc}: Pensionsförsäkringspremier` }, + credit_amount: Math.round(totalVacation * 100) / 100, + line_description: `${desc}: Semesteravsättning`, + } ) - if (slp > 0) { + } + if (totalVacationAvgifter > 0) { + vacationLines.push( + { + account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE, + debit_amount: Math.round(totalVacationAvgifter * 100) / 100, + credit_amount: 0, + line_description: `${desc}: Sociala avgifter semester`, + }, + { + account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY, + debit_amount: 0, + credit_amount: Math.round(totalVacationAvgifter * 100) / 100, + line_description: `${desc}: Sociala avgifter semester`, + } + ) + } + + // Build pension entry preview (löneväxling, per deductions-lonevaxling.md) + // This would be populated from salary_line_items with type 'gross_deduction_pension' + // For now, pension preview is shown when pension line items exist + const pensionLineItems = employees.flatMap(e => + ((e.line_items || []) as Array>) + .filter(li => li.item_type === 'gross_deduction_pension') + ) + const pensionLines: CreateJournalEntryLineInput[] = [] + if (pensionLineItems.length > 0) { + const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0)) + const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100 + const slp = Math.round(pensionContribution * 0.2426 * 100) / 100 + if (pensionContribution > 0) { pensionLines.push( - { account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc}: Särskild löneskatt 24,26%` }, - { account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc}: Särskild löneskatt 24,26%` }, + { account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc}: Pensionsförsäkringspremier` }, + { account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc}: Pensionsförsäkringspremier` }, ) + if (slp > 0) { + pensionLines.push( + { account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc}: Särskild löneskatt 24,26%` }, + { account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc}: Särskild löneskatt 24,26%` }, + ) + } } } - } - return NextResponse.json({ - data: { - // Each entry is null when it has no lines: a nollkörning posts nothing, - // so the salary and avgifter entries fall away just like vacation/pension - // already do, and the UI can simply skip the null ones. - salaryEntry: salaryLines.length > 0 ? { - description: desc, - lines: salaryLines, - } : null, - avgifterEntry: avgifterLines.length > 0 ? { - description: `${desc}: Arbetsgivaravgifter`, - lines: avgifterLines, - } : null, - vacationEntry: vacationLines.length > 0 ? { - description: `${desc}: Semesteravsättning`, - lines: vacationLines, - } : null, - pensionEntry: pensionLines.length > 0 ? { - description: `${desc}: Pensionsavsättning`, - lines: pensionLines, - } : null, - }, - }) -} + return NextResponse.json({ + data: { + // Each entry is null when it has no lines: a nollkörning posts nothing, + // so the salary and avgifter entries fall away just like vacation/pension + // already do, and the UI can simply skip the null ones. + salaryEntry: salaryLines.length > 0 ? { + description: desc, + lines: salaryLines, + } : null, + avgifterEntry: avgifterLines.length > 0 ? { + description: `${desc}: Arbetsgivaravgifter`, + lines: avgifterLines, + } : null, + vacationEntry: vacationLines.length > 0 ? { + description: `${desc}: Semesteravsättning`, + lines: vacationLines, + } : null, + pensionEntry: pensionLines.length > 0 ? { + description: `${desc}: Pensionsavsättning`, + lines: pensionLines, + } : null, + }, + }) + }, +) diff --git a/app/api/salary/runs/[id]/review/__tests__/route.test.ts b/app/api/salary/runs/[id]/review/__tests__/route.test.ts new file mode 100644 index 00000000..c08c70a7 --- /dev/null +++ b/app/api/salary/runs/[id]/review/__tests__/route.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +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'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('POST /api/salary/runs/[id]/review', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('moves a draft run to review', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: [] }, // salary_run_employees (F-skatt check, no warnings) + { data: { id: 'run-1', status: 'review' } }, // update draft → review + ]) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response) + expect(status).toBe(200) + expect(body.data.status).toBe('review') + }) + + it('surfaces an F-skatt warning when an employee is unverified', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: [{ employee: { first_name: 'Anna', last_name: 'A', f_skatt_status: 'not_verified' } }] }, + { data: { id: 'run-1', status: 'review' } }, + ]) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ warnings?: string[] }>(response) + expect(status).toBe(200) + expect(body.warnings?.[0]).toContain('F-skatt ej verifierad') + }) + + it('returns 400 when the run is not a draft', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: [] }, // F-skatt check + { data: null, error: { message: 'no rows' } }, // update finds no draft row + ]) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + expect(status).toBe(400) + expect(body.error).toContain('utkaststatus') + }) +}) diff --git a/app/api/salary/runs/[id]/review/route.ts b/app/api/salary/runs/[id]/review/route.ts index f09f7735..5beb21eb 100644 --- a/app/api/salary/runs/[id]/review/route.ts +++ b/app/api/salary/runs/[id]/review/route.ts @@ -1,8 +1,6 @@ -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' ensureInitialized() @@ -13,51 +11,45 @@ ensureInitialized() * If any employee has f_skatt_status = 'not_verified', return a warning. * The user can still proceed but the warning is logged for audit trail. */ -export async function POST( - 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 POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.review', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Check for F-skatt verification warnings + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('employee:employees(first_name, last_name, f_skatt_status)') + .eq('salary_run_id', id) - const companyId = await requireCompanyId(supabase, user.id) - - // Check for F-skatt verification warnings - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('employee:employees(first_name, last_name, f_skatt_status)') - .eq('salary_run_id', id) - - const warnings: string[] = [] - for (const sre of runEmployees || []) { - const emp = sre.employee as unknown as { first_name: string; last_name: string; f_skatt_status: string } | null - if (emp?.f_skatt_status === 'not_verified') { - warnings.push( - `${emp.first_name} ${emp.last_name}: F-skatt ej verifierad: 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)` - ) + const warnings: string[] = [] + for (const sre of runEmployees || []) { + const emp = sre.employee as unknown as { first_name: string; last_name: string; f_skatt_status: string } | null + if (emp?.f_skatt_status === 'not_verified') { + warnings.push( + `${emp.first_name} ${emp.last_name}: F-skatt ej verifierad: 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)` + ) + } } - } - const { data: run, error } = await supabase - .from('salary_runs') - .update({ status: 'review' }) - .eq('id', id) - .eq('company_id', companyId) - .eq('status', 'draft') - .select() - .single() + const { data: run, error } = await supabase + .from('salary_runs') + .update({ status: 'review' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'draft') + .select() + .single() - if (error || !run) { - return NextResponse.json({ error: 'Lönekörningen måste vara i utkaststatus' }, { status: 400 }) - } + if (error || !run) { + return NextResponse.json({ error: 'Lönekörningen måste vara i utkaststatus' }, { status: 400 }) + } - return NextResponse.json({ - data: run, - warnings: warnings.length > 0 ? warnings : undefined, - }) -} + return NextResponse.json({ + data: run, + warnings: warnings.length > 0 ? warnings : undefined, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/tax-tables/lookup/route.ts b/app/api/salary/tax-tables/lookup/route.ts index 89f123d9..b6c14fd4 100644 --- a/app/api/salary/tax-tables/lookup/route.ts +++ b/app/api/salary/tax-tables/lookup/route.ts @@ -1,12 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { lookupTaxFromApi, TaxTableUnavailableError } from '@/lib/salary/tax-tables' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - +export const GET = withRouteContext('salary.tax_tables.lookup', async (request) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) const tableNumber = parseInt(searchParams.get('table') || '0') @@ -35,4 +31,4 @@ export async function GET(request: Request) { } throw err } -} +}) diff --git a/app/api/salary/tax-tables/status/route.ts b/app/api/salary/tax-tables/status/route.ts index 0e388e5f..150385e0 100644 --- a/app/api/salary/tax-tables/status/route.ts +++ b/app/api/salary/tax-tables/status/route.ts @@ -1,16 +1,12 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { fetchTaxTableRates, TaxTableUnavailableError } from '@/lib/salary/tax-tables' /** * Probe Skatteverket's open data API to confirm tax tables are reachable. * Used by the salary settings page to surface that fetching is automatic. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - +export const GET = withRouteContext('salary.tax_tables.status', async (request) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || String(new Date().getFullYear())) @@ -38,4 +34,4 @@ export async function GET(request: Request) { } throw err } -} +}) diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts new file mode 100644 index 00000000..76093b52 --- /dev/null +++ b/app/api/settings/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/tax/deadline-generator', () => ({ + didTaxFieldsChange: vi.fn().mockReturnValue(false), + regenerateTaxDeadlinesForUser: vi.fn().mockResolvedValue(undefined), +})) + +import { PUT } from '../route' + +describe('PUT /api/settings', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { company_name: 'New Name' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { company_name: 'New Name' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('updates the settings on the happy path', async () => { + enqueueMany([ + { data: { entity_type: 'enskild_firma', onboarding_complete: false } }, // fetch oldSettings + { data: { id: 's1', company_name: 'New Name' } }, // update ... returning + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { company_name: 'New Name' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { company_name: string } }>(response) + + expect(status).toBe(200) + expect(body.data.company_name).toBe('New Name') + }) +}) diff --git a/app/api/settings/api-keys/[id]/route.ts b/app/api/settings/api-keys/[id]/route.ts index b71d2668..eb477876 100644 --- a/app/api/settings/api-keys/[id]/route.ts +++ b/app/api/settings/api-keys/[id]/route.ts @@ -1,38 +1,27 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * DELETE /api/settings/api-keys/[id]: Revoke an API key (soft delete) */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - const { data: { user } } = await supabase.auth.getUser() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'api_key.revoke', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { error } = await supabase + .from('api_keys') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('company_id', companyId) + .is('revoked_at', null) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { error } = await supabase - .from('api_keys') - .update({ revoked_at: new Date().toISOString() }) - .eq('id', id) - .eq('company_id', companyId) - .is('revoked_at', null) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/[id]/route.ts b/app/api/settings/booking-templates/[id]/route.ts index eab838ec..17eb079a 100644 --- a/app/api/settings/booking-templates/[id]/route.ts +++ b/app/api/settings/booking-templates/[id]/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' @@ -29,32 +28,28 @@ const UpdateBookingTemplateSchema = z.object({ * PUT /api/settings/booking-templates/[id] * Update a non-system template. */ -export async function PUT( - 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 PUT = withRouteContext<{ params: Promise<{ id: string }> }>( + 'booking_template.update', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const result = await validateBody(request, UpdateBookingTemplateSchema) + if (!result.success) return result.response - const result = await validateBody(request, UpdateBookingTemplateSchema) - if (!result.success) return result.response + // RLS prevents updating system templates + const { data, error } = await supabase + .from('booking_template_library') + .update(result.data) + .eq('id', id) + .eq('is_system', false) + .select() + .single() - // RLS prevents updating system templates - const { data, error } = await supabase - .from('booking_template_library') - .update(result.data) - .eq('id', id) - .eq('is_system', false) - .select() - .single() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/[id]/touch/route.ts b/app/api/settings/booking-templates/[id]/touch/route.ts index b3f398bc..6f50cd5f 100644 --- a/app/api/settings/booking-templates/[id]/touch/route.ts +++ b/app/api/settings/booking-templates/[id]/touch/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * POST /api/settings/booking-templates/[id]/touch @@ -11,31 +10,28 @@ import { requireCompanyId } from '@/lib/company/context' * * Fire-and-forget from the client: errors are non-fatal. */ -export async function POST( - _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 POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'booking_template.touch', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { error } = await supabase + .from('booking_template_usage') + .upsert( + { + template_id: id, + company_id: companyId, + last_used_at: new Date().toISOString(), + }, + { onConflict: 'template_id,company_id' }, + ) - const { error } = await supabase - .from('booking_template_usage') - .upsert( - { - template_id: id, - company_id: companyId, - last_used_at: new Date().toISOString(), - }, - { onConflict: 'template_id,company_id' }, - ) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/export/route.ts b/app/api/settings/booking-templates/export/route.ts index 49ef6231..c0f84fb3 100644 --- a/app/api/settings/booking-templates/export/route.ts +++ b/app/api/settings/booking-templates/export/route.ts @@ -1,34 +1,32 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/settings/booking-templates/export * Export company + team templates as JSON (excludes system templates). * Useful for sharing templates between unrelated companies. */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'booking_template.export', + async (_request, ctx) => { + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('booking_template_library') + .select('name, description, category, entity_type, lines') + .eq('company_id', companyId) + .eq('is_active', true) + .eq('is_system', false) + .order('category') + .order('name') - const { data, error } = await supabase - .from('booking_template_library') - .select('name, description, category, entity_type, lines') - .eq('company_id', companyId) - .eq('is_active', true) - .eq('is_system', false) - .order('category') - .order('name') + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), { - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': 'attachment; filename="bokforingsmallar.json"', - }, - }) -} + return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': 'attachment; filename="bokforingsmallar.json"', + }, + }) + }, +) diff --git a/app/api/settings/booking-templates/import/route.ts b/app/api/settings/booking-templates/import/route.ts index a89c9052..53f81c0c 100644 --- a/app/api/settings/booking-templates/import/route.ts +++ b/app/api/settings/booking-templates/import/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' const ImportLineSchema = z.object({ @@ -35,49 +33,46 @@ const ImportPayloadSchema = z.object({ * Import templates from JSON (exported from another company). * Creates company-scoped templates for the active company. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'booking_template.import', + async (request, ctx) => { + const { supabase, user, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const parsed = ImportPayloadSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid import format', details: parsed.error.issues }, + { status: 400 }, + ) + } - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) - } + const rows = parsed.data.templates.map((t) => ({ + company_id: companyId, + team_id: null, + created_by: user.id, + name: t.name, + description: t.description, + category: t.category, + entity_type: t.entity_type, + lines: t.lines, + is_system: false, + })) - const parsed = ImportPayloadSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid import format', details: parsed.error.issues }, - { status: 400 }, - ) - } + const { data, error } = await supabase + .from('booking_template_library') + .insert(rows) + .select() - const rows = parsed.data.templates.map((t) => ({ - company_id: companyId, - team_id: null, - created_by: user.id, - name: t.name, - description: t.description, - category: t.category, - entity_type: t.entity_type, - lines: t.lines, - is_system: false, - })) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const { data, error } = await supabase - .from('booking_template_library') - .insert(rows) - .select() - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 }) -} + return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts index 2415f567..25b6229a 100644 --- a/app/api/settings/booking-templates/route.ts +++ b/app/api/settings/booking-templates/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' @@ -42,158 +40,155 @@ const CreateBookingTemplateSchema = z.object({ * and name for never-used templates. Usage is tracked in * booking_template_usage via POST /[id]/touch. */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'booking_template.list', + async (_request, ctx) => { + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + // Resolve the team this company belongs to (if any) so team-shared + // templates stay visible while this company is selected. + const { data: company } = await supabase + .from('companies') + .select('team_id') + .eq('id', companyId) + .maybeSingle() + const teamId = company?.team_id ?? null - // Resolve the team this company belongs to (if any) so team-shared - // templates stay visible while this company is selected. - const { data: company } = await supabase - .from('companies') - .select('team_id') - .eq('id', companyId) - .maybeSingle() - const teamId = company?.team_id ?? null - - // requireCompanyId only ever returns a real membership UUID, but assert the - // shape before interpolating it into the .or() filter. - if (!UUID_RE.test(companyId)) { - return NextResponse.json({ error: 'Invalid company context' }, { status: 400 }) - } - - // Scope to the SELECTED company: system + this company + this company's team. - // RLS (btl_select) is membership-wide: it returns templates from *every* - // company the user belongs to: so the active-company narrowing must happen - // here in the API layer (mirrors counterparty-templates). Without this, a - // user who owns several companies sees all of their templates merged. - // Only interpolate a team id that passes the strict UUID guard. - const scope = [ - 'is_system.eq.true', - `company_id.eq.${companyId}`, - ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []), - ].join(',') - - const [templatesRes, usageRes] = await Promise.all([ - supabase - .from('booking_template_library') - .select('*') - .eq('is_active', true) - .or(scope) - .order('category') - .order('name'), - supabase - .from('booking_template_usage') - .select('template_id, last_used_at') - .eq('company_id', companyId), - ]) - - if (templatesRes.error) { - return NextResponse.json({ error: templatesRes.error.message }, { status: 500 }) - } - // usage lookup failing is non-fatal: we just fall back to default ordering - const usageByTemplate = new Map() - if (!usageRes.error && usageRes.data) { - for (const row of usageRes.data) { - usageByTemplate.set(row.template_id, row.last_used_at) + // The wrapper only ever resolves a real membership UUID, but assert the + // shape before interpolating it into the .or() filter. + if (!UUID_RE.test(companyId)) { + return NextResponse.json({ error: 'Invalid company context' }, { status: 400 }) } - } - const templates = templatesRes.data ?? [] - const decorated = templates.map((t) => ({ - ...t, - last_used_at: usageByTemplate.get(t.id) ?? null, - })) + // Scope to the SELECTED company: system + this company + this company's team. + // RLS (btl_select) is membership-wide: it returns templates from *every* + // company the user belongs to: so the active-company narrowing must happen + // here in the API layer (mirrors counterparty-templates). Without this, a + // user who owns several companies sees all of their templates merged. + // Only interpolate a team id that passes the strict UUID guard. + const scope = [ + 'is_system.eq.true', + `company_id.eq.${companyId}`, + ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []), + ].join(',') - // Stable-sort: templates with last_used_at come first (most-recent first). - // Templates without usage keep their category/name order from the query. - // ISO 8601 timestamps are fixed-width ASCII: plain relational comparison - // is correct and avoids any locale-dependent behaviour from localeCompare. - decorated.sort((a, b) => { - const aUsed = a.last_used_at - const bUsed = b.last_used_at - if (aUsed && bUsed) { - if (bUsed > aUsed) return -1 - if (bUsed < aUsed) return 1 + const [templatesRes, usageRes] = await Promise.all([ + supabase + .from('booking_template_library') + .select('*') + .eq('is_active', true) + .or(scope) + .order('category') + .order('name'), + supabase + .from('booking_template_usage') + .select('template_id, last_used_at') + .eq('company_id', companyId), + ]) + + if (templatesRes.error) { + return NextResponse.json({ error: templatesRes.error.message }, { status: 500 }) + } + // usage lookup failing is non-fatal: we just fall back to default ordering + const usageByTemplate = new Map() + if (!usageRes.error && usageRes.data) { + for (const row of usageRes.data) { + usageByTemplate.set(row.template_id, row.last_used_at) + } + } + + const templates = templatesRes.data ?? [] + const decorated = templates.map((t) => ({ + ...t, + last_used_at: usageByTemplate.get(t.id) ?? null, + })) + + // Stable-sort: templates with last_used_at come first (most-recent first). + // Templates without usage keep their category/name order from the query. + // ISO 8601 timestamps are fixed-width ASCII: plain relational comparison + // is correct and avoids any locale-dependent behaviour from localeCompare. + decorated.sort((a, b) => { + const aUsed = a.last_used_at + const bUsed = b.last_used_at + if (aUsed && bUsed) { + if (bUsed > aUsed) return -1 + if (bUsed < aUsed) return 1 + return 0 + } + if (aUsed) return -1 + if (bUsed) return 1 return 0 - } - if (aUsed) return -1 - if (bUsed) return 1 - return 0 - }) + }) - return NextResponse.json({ data: decorated }) -} + return NextResponse.json({ data: decorated }) + }, +) /** * POST /api/settings/booking-templates * Create a company-scoped or team-scoped template. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'booking_template.create', + async (request, ctx) => { + const { supabase, user } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const result = await validateBody(request, CreateBookingTemplateSchema) + if (!result.success) return result.response - const result = await validateBody(request, CreateBookingTemplateSchema) - if (!result.success) return result.response + const body = result.data + const companyId = body.team_id ? null : ctx.companyId - const body = result.data - const companyId = body.team_id ? null : await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('booking_template_library') + .insert({ + company_id: companyId, + team_id: body.team_id ?? null, + created_by: user.id, + name: body.name, + description: body.description, + category: body.category, + entity_type: body.entity_type, + lines: body.lines, + is_system: false, + }) + .select() + .single() - const { data, error } = await supabase - .from('booking_template_library') - .insert({ - company_id: companyId, - team_id: body.team_id ?? null, - created_by: user.id, - name: body.name, - description: body.description, - category: body.category, - entity_type: body.entity_type, - lines: body.lines, - is_system: false, - }) - .select() - .single() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data }, { status: 201 }) -} + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) /** * DELETE /api/settings/booking-templates * Soft-delete a template by id (company or team scope only, never system). */ -export async function DELETE(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext( + 'booking_template.delete', + async (request, ctx) => { + const { supabase } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + let id: string | undefined + try { + const body = await request.json() + id = body?.id + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) - let id: string | undefined - try { - const body = await request.json() - id = body?.id - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } - if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) + // RLS prevents deleting system templates (btl_delete policy checks NOT is_system) + const { error } = await supabase + .from('booking_template_library') + .update({ is_active: false }) + .eq('id', id) - // RLS prevents deleting system templates (btl_delete policy checks NOT is_system) - const { error } = await supabase - .from('booking_template_library') - .update({ is_active: false }) - .eq('id', id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/counterparty-templates/__tests__/route.test.ts b/app/api/settings/counterparty-templates/__tests__/route.test.ts new file mode 100644 index 00000000..a0b46fdd --- /dev/null +++ b/app/api/settings/counterparty-templates/__tests__/route.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { DELETE } from '../route' + +describe('DELETE /api/settings/counterparty-templates', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/settings/counterparty-templates', { + method: 'DELETE', + body: { id: 't1' }, + }) + const response = await DELETE(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/settings/counterparty-templates', { + method: 'DELETE', + body: { id: 't1' }, + }) + const response = await DELETE(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('soft-deletes the template on the happy path', async () => { + enqueue({ error: null }) // update is_active: false + + const request = createMockRequest('/api/settings/counterparty-templates', { + method: 'DELETE', + body: { id: 't1' }, + }) + const response = await DELETE(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + }) +}) diff --git a/app/api/settings/counterparty-templates/route.ts b/app/api/settings/counterparty-templates/route.ts index b9220a6a..8c6d9d32 100644 --- a/app/api/settings/counterparty-templates/route.ts +++ b/app/api/settings/counterparty-templates/route.ts @@ -1,53 +1,43 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'counterparty_template.list', + async (_request, { supabase, companyId }) => { + const { data, error } = await supabase + .from('categorization_templates') + .select('*') + .eq('company_id', companyId) + .eq('is_active', true) + .order('occurrence_count', { ascending: false }) - const companyId = await requireCompanyId(supabase, user.id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const { data, error } = await supabase - .from('categorization_templates') - .select('*') - .eq('company_id', companyId) - .eq('is_active', true) - .order('occurrence_count', { ascending: false }) + return NextResponse.json({ data }) + }, +) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) +export const DELETE = withRouteContext( + 'counterparty_template.delete', + async (request, { supabase, companyId }) => { + let id: string | undefined + try { + const body = await request.json() + id = body?.id + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) - return NextResponse.json({ data }) -} + const { error } = await supabase + .from('categorization_templates') + .update({ is_active: false }) + .eq('id', id) + .eq('company_id', companyId) -export async function DELETE(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - let id: string | undefined - try { - const body = await request.json() - id = body?.id - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } - if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) - - const { error } = await supabase - .from('categorization_templates') - .update({ is_active: false }) - .eq('id', id) - .eq('company_id', companyId) - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/logo/__tests__/route.test.ts b/app/api/settings/logo/__tests__/route.test.ts new file mode 100644 index 00000000..e9df07f3 --- /dev/null +++ b/app/api/settings/logo/__tests__/route.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const serviceStorage = { + from: vi.fn().mockReturnValue({ + list: vi.fn().mockResolvedValue({ data: [], error: null }), + remove: vi.fn().mockResolvedValue({ data: [], error: null }), + upload: vi.fn().mockResolvedValue({ data: {}, error: null }), + getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://cdn.example.com/logo.png' } }), + }), +} +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: () => ({ storage: serviceStorage }), +})) + +import { POST } from '../route' + +function makeFormRequest(): Request { + const fd = new FormData() + fd.append('file', new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' })) + return new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd }) +} + +describe('POST /api/settings/logo', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(makeFormRequest(), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(makeFormRequest(), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('uploads the logo and returns its public url on the happy path', async () => { + enqueue({ error: null }) // company_settings update + + const response = await POST(makeFormRequest(), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { logo_url: string } }>(response) + + expect(status).toBe(200) + expect(body.data.logo_url).toBe('https://cdn.example.com/logo.png') + }) +}) diff --git a/app/api/settings/logo/route.ts b/app/api/settings/logo/route.ts index 8532ff15..8bbe8ec5 100644 --- a/app/api/settings/logo/route.ts +++ b/app/api/settings/logo/route.ts @@ -1,107 +1,41 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { createServiceClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' const MAX_SIZE = 2 * 1024 * 1024 // 2MB const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'] -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'settings.logo.upload', + async (request, { supabase, companyId }) => { + const formData = await request.formData() + const file = formData.get('file') as File | null - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!file) { + return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) - if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 }) + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 }) + } - const formData = await request.formData() - const file = formData.get('file') as File | null + if (file.size > MAX_SIZE) { + return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 }) + } - if (!file) { - return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 }) - } + const buffer = Buffer.from(await file.arrayBuffer()) + const mimeToExt: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + } + const ext = mimeToExt[file.type] ?? 'png' + const storagePath = `${companyId}/logo-${Date.now()}.${ext}` - if (!ALLOWED_TYPES.includes(file.type)) { - return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 }) - } - - if (file.size > MAX_SIZE) { - return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 }) - } - - const buffer = Buffer.from(await file.arrayBuffer()) - const mimeToExt: Record = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/svg+xml': 'svg', - 'image/webp': 'webp', - } - const ext = mimeToExt[file.type] ?? 'png' - const storagePath = `${companyId}/logo-${Date.now()}.${ext}` - - const serviceClient = createServiceClient() - - // Remove any previous logo files for this company so we don't pile up orphans. - const { data: existing } = await serviceClient.storage - .from('logos') - .list(companyId) - if (existing && existing.length > 0) { - await serviceClient.storage - .from('logos') - .remove(existing.map((f) => `${companyId}/${f.name}`)) - } - - const { error: uploadError } = await serviceClient.storage - .from('logos') - .upload(storagePath, buffer, { - contentType: file.type, - upsert: true, - }) - - if (uploadError) { - return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 }) - } - - const { data: urlData } = serviceClient.storage - .from('logos') - .getPublicUrl(storagePath) - - // Update company settings - const { error: updateError } = await supabase - .from('company_settings') - .update({ logo_url: urlData.publicUrl }) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 }) - } - - return NextResponse.json({ data: { logo_url: urlData.publicUrl } }) -} - -export async function DELETE() { - 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) - if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 }) - - // Get current logo path - const { data: settings } = await supabase - .from('company_settings') - .select('logo_url') - .eq('company_id', companyId) - .single() - - if (settings?.logo_url) { const serviceClient = createServiceClient() + + // Remove any previous logo files for this company so we don't pile up orphans. const { data: existing } = await serviceClient.storage .from('logos') .list(companyId) @@ -110,13 +44,66 @@ export async function DELETE() { .from('logos') .remove(existing.map((f) => `${companyId}/${f.name}`)) } - } - // Clear logo_url - await supabase - .from('company_settings') - .update({ logo_url: null }) - .eq('company_id', companyId) + const { error: uploadError } = await serviceClient.storage + .from('logos') + .upload(storagePath, buffer, { + contentType: file.type, + upsert: true, + }) - return NextResponse.json({ data: { logo_url: null } }) -} + if (uploadError) { + return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 }) + } + + const { data: urlData } = serviceClient.storage + .from('logos') + .getPublicUrl(storagePath) + + // Update company settings + const { error: updateError } = await supabase + .from('company_settings') + .update({ logo_url: urlData.publicUrl }) + .eq('company_id', companyId) + + if (updateError) { + return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 }) + } + + return NextResponse.json({ data: { logo_url: urlData.publicUrl } }) + }, + { requireWrite: true }, +) + +export const DELETE = withRouteContext( + 'settings.logo.delete', + async (_request, { supabase, companyId }) => { + // Get current logo path + const { data: settings } = await supabase + .from('company_settings') + .select('logo_url') + .eq('company_id', companyId) + .single() + + if (settings?.logo_url) { + const serviceClient = createServiceClient() + const { data: existing } = await serviceClient.storage + .from('logos') + .list(companyId) + if (existing && existing.length > 0) { + await serviceClient.storage + .from('logos') + .remove(existing.map((f) => `${companyId}/${f.name}`)) + } + } + + // Clear logo_url + await supabase + .from('company_settings') + .update({ logo_url: null }) + .eq('company_id', companyId) + + return NextResponse.json({ data: { logo_url: null } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/oauth-clients/[id]/__tests__/route.test.ts b/app/api/settings/oauth-clients/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..3961ff26 --- /dev/null +++ b/app/api/settings/oauth-clients/[id]/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRouteParams, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const appendMock = vi.fn() +vi.mock('@/lib/processing-history/append', () => ({ + appendProcessingHistory: (...args: unknown[]) => appendMock(...args), +})) + +import { DELETE } from '../route' + +describe('DELETE /api/settings/oauth-clients/[id]', () => { + const routeParams = createMockRouteParams({ id: 'reg-1' }) + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + appendMock.mockResolvedValue(undefined) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('returns 404 when the registration is unknown or already revoked', async () => { + enqueue({ data: [] }) // update ... returning no rows + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('revokes the registration and appends an audit event on the happy path', async () => { + enqueue({ + data: [{ id: 'reg-1', redirect_uri: 'https://app.example.com/cb', client_name: 'My App' }], + }) + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(appendMock).toHaveBeenCalledOnce() + }) +}) diff --git a/app/api/settings/oauth-clients/[id]/route.ts b/app/api/settings/oauth-clients/[id]/route.ts index d0bfa886..90cd0cab 100644 --- a/app/api/settings/oauth-clients/[id]/route.ts +++ b/app/api/settings/oauth-clients/[id]/route.ts @@ -1,11 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireWritePermission } from '@/lib/auth/require-write' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { appendProcessingHistory } from '@/lib/processing-history/append' -import { createLogger } from '@/lib/logger' - -const log = createLogger('oauth-clients:delete') /** * DELETE /api/settings/oauth-clients/[id]: revoke a redirect URI @@ -17,65 +12,56 @@ const log = createLogger('oauth-clients:delete') * (SOC 2 CC7.2). Returns 404 when the row is unknown or already revoked * so callers can surface failures rather than treating "no-op" as success. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - const { data: { user } } = await supabase.auth.getUser() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'oauth_client.delete', + async (_request, { supabase, user, companyId, log }, { params }) => { + const { id } = await params - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: rows, error } = await supabase + .from('oauth_client_registrations') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('user_id', user.id) + .is('revoked_at', null) + .select('id, redirect_uri, client_name') - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const { data: rows, error } = await supabase - .from('oauth_client_registrations') - .update({ revoked_at: new Date().toISOString() }) - .eq('id', id) - .eq('user_id', user.id) - .is('revoked_at', null) - .select('id, redirect_uri, client_name') + if (!rows || rows.length === 0) { + return NextResponse.json( + { error: 'OAuth-klient hittades inte eller är redan återkallad.' }, + { status: 404 } + ) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + // Audit the revocation. Failure to append must not break the user flow: + // the revocation has already happened in the DB. We log the appendError + // so a systematic outage is visible in operations rather than silently + // degraded. + try { + await appendProcessingHistory({ + companyId, + correlationId: id, + aggregateType: 'System', + aggregateId: id, + eventType: 'OAuthClientRevoked', + payload: { + client_id: id, + // Note: redirect_uri may contain a host the user identifies with their + // own infrastructure but is not PII per Art. 4(1). Stored to satisfy + // SOC 2 CC7.2 "what was revoked" evidence trail. + redirect_uri: rows[0].redirect_uri, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (auditErr) { + log.warn('Failed to append OAuthClientRevoked audit event', auditErr) + } - if (!rows || rows.length === 0) { - return NextResponse.json( - { error: 'OAuth-klient hittades inte eller är redan återkallad.' }, - { status: 404 } - ) - } - - // Audit the revocation. Failure to append must not break the user flow: - // the revocation has already happened in the DB. We log the appendError - // so a systematic outage is visible in operations rather than silently - // degraded. - try { - const companyId = await requireCompanyId(supabase, user.id) - await appendProcessingHistory({ - companyId, - correlationId: id, - aggregateType: 'System', - aggregateId: id, - eventType: 'OAuthClientRevoked', - payload: { - client_id: id, - // Note: redirect_uri may contain a host the user identifies with their - // own infrastructure but is not PII per Art. 4(1). Stored to satisfy - // SOC 2 CC7.2 "what was revoked" evidence trail. - redirect_uri: rows[0].redirect_uri, - }, - actor: { type: 'user', id: user.id }, - occurredAt: new Date(), - }) - } catch (auditErr) { - log.warn('Failed to append OAuthClientRevoked audit event', auditErr) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/oauth-clients/__tests__/route.test.ts b/app/api/settings/oauth-clients/__tests__/route.test.ts new file mode 100644 index 00000000..66cdfe2a --- /dev/null +++ b/app/api/settings/oauth-clients/__tests__/route.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST } from '../route' + +describe('POST /api/settings/oauth-clients', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/settings/oauth-clients', { + method: 'POST', + body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/settings/oauth-clients', { + method: 'POST', + body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('registers a redirect uri scoped to the current user on the happy path', async () => { + enqueue({ + data: { + id: 'reg-1', + client_name: 'My App', + redirect_uri: 'https://app.example.com/cb', + created_at: '2026-01-01T00:00:00Z', + }, + }) + + const request = createMockRequest('/api/settings/oauth-clients', { + method: 'POST', + body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('reg-1') + }) +}) diff --git a/app/api/settings/oauth-clients/route.ts b/app/api/settings/oauth-clients/route.ts index 867f3cb9..ae5881ca 100644 --- a/app/api/settings/oauth-clients/route.ts +++ b/app/api/settings/oauth-clients/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' /** @@ -29,66 +28,62 @@ const RegistrationSchema = z.object({ .max(500), }) -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'oauth_client.list', + async (_request, { supabase, user }) => { + const { data, error } = await supabase + .from('oauth_client_registrations') + .select('id, client_name, redirect_uri, created_at, revoked_at') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) - const { data, error } = await supabase - .from('oauth_client_registrations') - .select('id, client_name, redirect_uri, created_at, revoked_at') - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST(request: Request) { - 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 - - let body: z.infer - try { - const json = await request.json() - body = RegistrationSchema.parse(json) - } catch (err) { - const message = - err instanceof z.ZodError - ? err.issues[0]?.message ?? 'Ogiltig redirect URI' - : err instanceof SyntaxError - ? 'Ogiltig JSON i request body' - : 'Ogiltig redirect URI' - return NextResponse.json({ error: message }, { status: 400 }) - } - - const { data, error } = await supabase - .from('oauth_client_registrations') - .insert({ - user_id: user.id, - client_name: body.client_name, - redirect_uri: body.redirect_uri, - }) - .select('id, client_name, redirect_uri, created_at') - .single() - - if (error) { - // Unique-index violation on redirect_uri → 409 - if (error.code === '23505') { - return NextResponse.json( - { error: 'Den här redirect URI:n är redan registrerad.' }, - { status: 409 } - ) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) + +export const POST = withRouteContext( + 'oauth_client.create', + async (request, { supabase, user }) => { + let body: z.infer + try { + const json = await request.json() + body = RegistrationSchema.parse(json) + } catch (err) { + const message = + err instanceof z.ZodError + ? err.issues[0]?.message ?? 'Ogiltig redirect URI' + : err instanceof SyntaxError + ? 'Ogiltig JSON i request body' + : 'Ogiltig redirect URI' + return NextResponse.json({ error: message }, { status: 400 }) + } + + const { data, error } = await supabase + .from('oauth_client_registrations') + .insert({ + user_id: user.id, + client_name: body.client_name, + redirect_uri: body.redirect_uri, + }) + .select('id, client_name, redirect_uri, created_at') + .single() + + if (error) { + // Unique-index violation on redirect_uri → 409 + if (error.code === '23505') { + return NextResponse.json( + { error: 'Den här redirect URI:n är redan registrerad.' }, + { status: 409 } + ) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 11ea710b..58d1463f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,137 +1,119 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' import { validateBody } from '@/lib/api/validate' import { UpdateSettingsSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function GET() { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('company_settings') - .select('*') - .eq('company_id', companyId) - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Fall back to companies.entity_type if company_settings.entity_type is null - let responseData = data - if (data && !data.entity_type) { - const { data: company } = await supabase - .from('companies') - .select('entity_type') - .eq('id', companyId) +export const GET = withRouteContext( + 'settings.get', + async (_request, { supabase, companyId }) => { + const { data, error } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) .single() - if (company?.entity_type) { - responseData = { ...data, entity_type: company.entity_type } + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) } - } - return NextResponse.json({ data: responseData }) -} + // Fall back to companies.entity_type if company_settings.entity_type is null + let responseData = data + if (data && !data.entity_type) { + const { data: company } = await supabase + .from('companies') + .select('entity_type') + .eq('id', companyId) + .single() + if (company?.entity_type) { + responseData = { ...data, entity_type: company.entity_type } + } + } -export async function PUT(request: Request) { - const supabase = await createClient() + return NextResponse.json({ data: responseData }) + }, +) - const { data: { user } } = await supabase.auth.getUser() +export const PUT = withRouteContext( + 'settings.update', + async (request, { supabase, companyId }) => { + // Fetch current settings to check for tax-relevant changes + const { data: oldSettings } = await supabase + .from('company_settings') + .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete') + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const validation = await validateBody(request, UpdateSettingsSchema) + if (!validation.success) return validation.response + const body = validation.data - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Lock org_number after onboarding is complete (legal identifier: changing it + // would orphan vouchers, SIE history, and tax filings). company_name remains + // editable so users can update their display/brand name (e.g. särskilt företagsnamn). + if (oldSettings && (oldSettings as Record).onboarding_complete === true) { + delete (body as Record).org_number + } - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch current settings to check for tax-relevant changes - const { data: oldSettings } = await supabase - .from('company_settings') - .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete') - .eq('company_id', companyId) - .single() - - const validation = await validateBody(request, UpdateSettingsSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Lock org_number after onboarding is complete (legal identifier: changing it - // would orphan vouchers, SIE history, and tax filings). company_name remains - // editable so users can update their display/brand name (e.g. särskilt företagsnamn). - if (oldSettings && (oldSettings as Record).onboarding_complete === true) { - delete (body as Record).org_number - } - - // Validate: enskild firma must use calendar year (BFL 3 kap.) - const effectiveEntityType = body.entity_type || oldSettings?.entity_type - const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month - if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) { - return NextResponse.json( - { error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' }, - { status: 400 } - ) - } - - // Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.) - const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered - if (effectiveVatRegistered === true) { - const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number - if (!effectiveVatNumber) { + // Validate: enskild firma must use calendar year (BFL 3 kap.) + const effectiveEntityType = body.entity_type || oldSettings?.entity_type + const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month + if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) { return NextResponse.json( - { error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' }, + { error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' }, { status: 400 } ) } - const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period - if (!effectiveMomsPeriod) { - return NextResponse.json( - { error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' }, - { status: 400 } - ) + + // Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.) + const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered + if (effectiveVatRegistered === true) { + const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number + if (!effectiveVatNumber) { + return NextResponse.json( + { error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' }, + { status: 400 } + ) + } + const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period + if (!effectiveMomsPeriod) { + return NextResponse.json( + { error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' }, + { status: 400 } + ) + } } - } - const { data, error } = await supabase - .from('company_settings') - .update(body) - .eq('company_id', companyId) - .select() - .single() + const { data, error } = await supabase + .from('company_settings') + .update(body) + .eq('company_id', companyId) + .select() + .single() - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Check if tax-relevant fields changed and regenerate deadlines - if (oldSettings && didTaxFieldsChange(oldSettings, data)) { - try { - await regenerateTaxDeadlinesForUser(supabase, companyId, { - entity_type: data.entity_type, - moms_period: data.moms_period, - f_skatt: data.f_skatt, - vat_registered: data.vat_registered, - pays_salaries: data.pays_salaries ?? false, - fiscal_year_start_month: data.fiscal_year_start_month, - }) - console.log('Tax deadlines regenerated after settings change') - } catch (err) { - console.error('Failed to regenerate tax deadlines:', err) - // Don't fail the settings update if deadline generation fails + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) } - } - return NextResponse.json({ data }) -} + // Check if tax-relevant fields changed and regenerate deadlines + if (oldSettings && didTaxFieldsChange(oldSettings, data)) { + try { + await regenerateTaxDeadlinesForUser(supabase, companyId, { + entity_type: data.entity_type, + moms_period: data.moms_period, + f_skatt: data.f_skatt, + vat_registered: data.vat_registered, + pays_salaries: data.pays_salaries ?? false, + fiscal_year_start_month: data.fiscal_year_start_month, + }) + console.log('Tax deadlines regenerated after settings change') + } catch (err) { + console.error('Failed to regenerate tax deadlines:', err) + // Don't fail the settings update if deadline generation fails + } + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/skatteverket/tax-payments/[period]/mark-paid/__tests__/route.test.ts b/app/api/skatteverket/tax-payments/[period]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..cec57763 --- /dev/null +++ b/app/api/skatteverket/tax-payments/[period]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST } from '../route' + +describe('POST /api/skatteverket/tax-payments/[period]/mark-paid', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(403) + }) + + it('returns 400 for an invalid period', async () => { + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/nope/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: 'nope' }), + ) + expect(response.status).toBe(400) + }) + + it('returns 404 when no AGI exists for the period', async () => { + enqueue({ data: null }) // agi lookup + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(404) + }) + + it('marks the AGI period as paid (happy path)', async () => { + enqueue({ data: { id: 'agi-1' } }) // agi lookup + enqueue({ data: null, error: null }) // update + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + const { status, body } = await parseJsonResponse<{ data: { ok: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.ok).toBe(true) + }) +}) diff --git a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts index 9bec7602..952d01fa 100644 --- a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts @@ -1,8 +1,6 @@ -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' ensureInitialized() @@ -13,10 +11,9 @@ ensureInitialized() * Skattekontot transactions can also flip this flag automatically (handled * elsewhere via the Skattekonto sync). */ -export async function POST( - request: Request, - { params }: { params: Promise<{ period: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ period: string }> }>( + 'tax_payment.mark_paid', + async (request, { supabase, companyId }, { params }) => { const { period } = await params const periodMatch = /^(\d{4})-(\d{2})$/.exec(period) if (!periodMatch) { @@ -28,15 +25,6 @@ export async function POST( const periodYear = parseInt(periodMatch[1], 10) const periodMonth = parseInt(periodMatch[2], 10) - 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) - const { data: agi } = await supabase .from('agi_declarations') .select('id') @@ -62,4 +50,6 @@ export async function POST( } return NextResponse.json({ data: { ok: true } }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts new file mode 100644 index 00000000..0ec8d10a --- /dev/null +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const mockGenerateBgLb = vi.fn() +vi.mock('@/lib/salary/payment/bg-lb-generator', () => ({ + generateBankgiroPaymentBgLb: (...args: unknown[]) => mockGenerateBgLb(...args), +})) + +vi.mock('@/lib/skatteverket/skattekonto-ocr', () => ({ + generateSkattekontoOcr: vi.fn().mockReturnValue('1234567890'), + SKATTEKONTO_BANKGIRO: '5050-1055', +})) + +vi.mock('@/lib/bankgiro/luhn', () => ({ + validateBankgiroNumber: vi.fn().mockReturnValue(true), +})) + +import { GET } from '../route' + +describe('GET /api/skatteverket/tax-payments/[period]/payment-file', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + mockGenerateBgLb.mockReturnValue({ content: 'LB-FILE', filename: 'skatt-2026-04.txt' }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(403) + }) + + it('generates the LB file (happy path)', async () => { + enqueue({ data: { id: 'agi-1', total_tax: 1000, total_avgifter: 500 } }) // agi + enqueue({ data: { name: 'Test AB', org_number: '5566778899' } }) // companies + enqueue({ data: { bankgiro: '123-4567' } }) // company_settings + enqueue({ data: null, error: null }) // update tax_payment_file_generated_at + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=iso-8859-1') + expect(response.headers.get('Content-Disposition')).toContain('skatt-2026-04.txt') + expect(mockGenerateBgLb).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts index bde29d7d..8e5a3926 100644 --- a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts @@ -1,8 +1,6 @@ -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 { generateBankgiroPaymentBgLb } from '@/lib/salary/payment/bg-lb-generator' import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '@/lib/skatteverket/skattekonto-ocr' import { validateBankgiroNumber } from '@/lib/bankgiro/luhn' @@ -18,11 +16,14 @@ ensureInitialized() * * Per BFL: Generated payment file is räkenskapsinformation linked to the * salary journal entry. Subject to 7-year retention. + * + * requireWrite: this GET mutates state (stamps tax_payment_file_generated_at + * on the AGI declaration), so it retains the non-viewer role gate the + * hand-rolled version enforced. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ period: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ period: string }> }>( + 'tax_payment.payment_file', + async (request, { supabase, companyId }, { params }) => { const { period } = await params const periodMatch = /^(\d{4})-(\d{2})$/.exec(period) if (!periodMatch) { @@ -34,15 +35,6 @@ export async function GET( const periodYear = parseInt(periodMatch[1], 10) const periodMonth = parseInt(periodMatch[2], 10) - 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) - const { data: agi } = await supabase .from('agi_declarations') .select('id, total_tax, total_avgifter') @@ -145,7 +137,9 @@ export async function GET( 'Content-Disposition': `attachment; filename="${result.filename}"`, }, }) -} + }, + { requireWrite: true }, +) /** * Tax payment deadline = the 12th of the month *following* the AGI period. diff --git a/app/api/skatteverket/tax-payments/[period]/route.ts b/app/api/skatteverket/tax-payments/[period]/route.ts index 2e911d92..c0d3d924 100644 --- a/app/api/skatteverket/tax-payments/[period]/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' ensureInitialized() @@ -12,10 +11,9 @@ ensureInitialized() * paid at, totals) so the UI can render the TaxPaymentPanel without * round-tripping to load the full declaration. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ period: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ period: string }> }>( + 'tax_payment.status', + async (request, { supabase, companyId }, { params }) => { const { period } = await params const periodMatch = /^(\d{4})-(\d{2})$/.exec(period) if (!periodMatch) { @@ -27,12 +25,6 @@ export async function GET( const periodYear = parseInt(periodMatch[1], 10) const periodMonth = parseInt(periodMatch[2], 10) - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - const { data: agi } = await supabase .from('agi_declarations') .select('total_tax, total_avgifter, tax_payment_file_generated_at, tax_payment_file_format, tax_paid_at') @@ -46,4 +38,5 @@ export async function GET( } return NextResponse.json({ data: agi }) -} + }, +) diff --git a/app/api/supplier-invoices/[id]/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/__tests__/route.test.ts index fbdc7b33..256ef9bf 100644 --- a/app/api/supplier-invoices/[id]/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -7,8 +8,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -28,7 +31,7 @@ describe('DELETE /api/supplier-invoices/[id]', () => { beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) function deleteRequest() { @@ -39,7 +42,11 @@ describe('DELETE /api/supplier-invoices/[id]', () => { } it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const response = await deleteRequest() expect(response.status).toBe(401) diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts index f1d19e77..a49a8967 100644 --- a/app/api/supplier-invoices/[id]/route.ts +++ b/app/api/supplier-invoices/[id]/route.ts @@ -1,29 +1,14 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateSupplierInvoiceSchema } 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.supplier_invoices.id') - -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.get', + async (_request, { supabase, companyId }, { params }) => { const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - const { data: invoice, error } = await supabase .from('supplier_invoices') .select( @@ -38,26 +23,14 @@ export async function GET( } return NextResponse.json({ data: invoice }) -} + }, +) -export async function PUT( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.update', + async (request, { supabase, companyId }, { params }) => { const { id } = await params - 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 editing registered invoices const { data: existing } = await supabase .from('supplier_invoices') @@ -94,26 +67,15 @@ export async function PUT( } return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.delete', + async (_request, { supabase, companyId, log }, { params }) => { const { id } = await params - 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 deleting registered invoices without journal entries const { data: existing } = await supabase .from('supplier_invoices') @@ -188,4 +150,6 @@ export async function DELETE( } return NextResponse.json({ success: true }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts index 87ebf4c8..8f5c963c 100644 --- a/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -8,8 +9,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -43,11 +46,15 @@ describe('POST /api/supplier-invoices/[id]/uncredit', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) diff --git a/app/api/supplier-invoices/[id]/uncredit/route.ts b/app/api/supplier-invoices/[id]/uncredit/route.ts index 72680cf5..933e05ed 100644 --- a/app/api/supplier-invoices/[id]/uncredit/route.ts +++ b/app/api/supplier-invoices/[id]/uncredit/route.ts @@ -1,7 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { reverseEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse, @@ -9,30 +9,15 @@ import { EntryAlreadyReversedError, } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { SupplierInvoice, SupplierInvoicePayment } from '@/types' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.uncredit', + async (_request, { supabase, user, companyId }, { params }) => { const { id } = await params - 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) - const { data: original, error: fetchError } = await supabase .from('supplier_invoices') .select('*, payments:supplier_invoice_payments(*)') @@ -172,4 +157,6 @@ export async function POST( data: restored, reversal_entry_id: reversalEntryId, }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/support/contact/route.ts b/app/api/support/contact/route.ts index 523d5d58..ca7112d6 100644 --- a/app/api/support/contact/route.ts +++ b/app/api/support/contact/route.ts @@ -1,5 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' import { getEmailService } from '@/lib/email/service' import { getSupportRecipientEmail } from '@/lib/support' import { requireCompanyId } from '@/lib/company/context' @@ -13,9 +13,8 @@ function escapeHtml(s: string): string { } export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, supabase, error } = await requireAuth() + if (error) return error await requireCompanyId(supabase, user.id) diff --git a/app/api/team/accept/__tests__/route.test.ts b/app/api/team/accept/__tests__/route.test.ts new file mode 100644 index 00000000..3198697e --- /dev/null +++ b/app/api/team/accept/__tests__/route.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +// team/accept uses the service client for all DB work (invite acceptance can +// run before the user has any company membership). requireAuth only gates the +// caller's identity + MFA. +const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => serviceSupabase, +})) + +vi.mock('@/lib/auth/invite-tokens', () => ({ + hashInviteToken: (t: string) => `hash-${t}`, +})) + +import { POST } from '../route' + +const mockUser = { id: 'user-1', email: 'invitee@test.se' } + +function makeReq(body: unknown) { + return new Request('http://localhost/api/team/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) as never +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: serviceSupabase, error: null }) +}) + +describe('POST /api/team/accept', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: serviceSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq({ token: 'abc' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 400 when the token is missing', async () => { + const res = await POST(makeReq({})) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(400) + expect(body.error).toContain('Token') + }) + + it('accepts a valid company invite', async () => { + const future = new Date(Date.now() + 86_400_000).toISOString() + // 1. company_invitations lookup + enqueue({ + data: { + id: 'inv-1', + company_id: 'company-1', + email: 'invitee@test.se', + role: 'member', + status: 'pending', + expires_at: future, + }, + }) + // 2. company_members insert + enqueue({ error: null }) + // 3. user_preferences upsert + enqueue({ error: null }) + // 4. company_invitations update -> accepted + enqueue({ error: null }) + + const res = await POST(makeReq({ token: 'abc' })) + const { status, body } = await parseJsonResponse<{ data: { type: string; companyId: string } }>(res) + expect(status).toBe(200) + expect(body.data).toEqual({ type: 'company', companyId: 'company-1' }) + }) + + it('returns 403 when the invite email does not match the user', async () => { + const future = new Date(Date.now() + 86_400_000).toISOString() + enqueue({ + data: { + id: 'inv-1', + company_id: 'company-1', + email: 'someone-else@test.se', + role: 'member', + status: 'pending', + expires_at: future, + }, + }) + const res = await POST(makeReq({ token: 'abc' })) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(403) + expect(body.error).toContain('matchar inte') + }) +}) diff --git a/app/api/team/accept/route.ts b/app/api/team/accept/route.ts index 8e5fcbb9..70dc640f 100644 --- a/app/api/team/accept/route.ts +++ b/app/api/team/accept/route.ts @@ -1,5 +1,6 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse, type NextRequest } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' import { hashInviteToken } from '@/lib/auth/invite-tokens' /** @@ -54,11 +55,8 @@ export async function GET(request: NextRequest) { * Team invitations are disabled: teams are single-user. */ export async function POST(request: NextRequest) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { user, error } = await requireAuth() + if (error) return error const body = await request.json() const token = body.token as string diff --git a/app/api/team/members/route.ts b/app/api/team/members/route.ts index 7c247aa9..a3660625 100644 --- a/app/api/team/members/route.ts +++ b/app/api/team/members/route.ts @@ -1,14 +1,19 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' /** * GET /api/team/members * Returns team members (single-user teams, no invitations). + * + * Team-scoped (not company-scoped): a brand-new user with no company must + * still get a valid empty response (ownsCompany: false), so this uses + * requireAuth() directly rather than withRouteContext, which would require an + * active company context. requireAuth still enforces MFA (AAL2) on hosted. */ export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, error } = await requireAuth() + if (error) return error const serviceClient = await createServiceClient() diff --git a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts index 2a565bad..9228a477 100644 --- a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts +++ b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { parseJsonResponse, createMockRouteParams, @@ -6,8 +7,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -15,8 +18,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -30,7 +34,8 @@ const mockUser = { id: 'user-1', email: 'test@test.se' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') { @@ -43,13 +48,31 @@ function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') { describe('POST /api/transactions/[id]/attach-document', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await POST(makeReq({ document_id: 'doc-1' }), createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await POST( + makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status, body } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + it('returns 400 when document_id missing', async () => { const res = await POST(makeReq({}), createMockRouteParams({ id: 'tx-1' })) const { status } = await parseJsonResponse(res) @@ -234,13 +257,28 @@ describe('POST /api/transactions/[id]/attach-document', () => { describe('DELETE /api/transactions/[id]/attach-document', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + it('returns 404 when transaction not in company', async () => { enqueue({ data: null, error: null }) // tx fetch const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' })) diff --git a/app/api/transactions/[id]/attach-document/route.ts b/app/api/transactions/[id]/attach-document/route.ts index 8fe8fc58..e2ca7f83 100644 --- a/app/api/transactions/[id]/attach-document/route.ts +++ b/app/api/transactions/[id]/attach-document/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { AttachDocumentSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { appendProcessingHistory } from '@/lib/processing-history/append' ensureInitialized() @@ -21,183 +19,175 @@ ensureInitialized() * * Idempotent: overwrites any existing link. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id: transactionId } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.attach_document', + async (request, { supabase, user, companyId }, { params }) => { + const { id: transactionId } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const validation = await validateBody(request, AttachDocumentSchema) + if (!validation.success) return validation.response + const { document_id } = validation.data - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data: transaction, error: txError } = await supabase + .from('transactions') + .select('id, document_id, journal_entry_id') + .eq('id', transactionId) + .eq('company_id', companyId) + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (txError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const validation = await validateBody(request, AttachDocumentSchema) - if (!validation.success) return validation.response - const { document_id } = validation.data + const previousDocumentId = (transaction.document_id as string | null) ?? null - const { data: transaction, error: txError } = await supabase - .from('transactions') - .select('id, document_id, journal_entry_id') - .eq('id', transactionId) - .eq('company_id', companyId) - .maybeSingle() + const { data: document, error: docError } = await supabase + .from('document_attachments') + .select('id, journal_entry_id') + .eq('id', document_id) + .eq('company_id', companyId) + .maybeSingle() - if (txError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } + if (docError || !document) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } - const previousDocumentId = (transaction.document_id as string | null) ?? null - - const { data: document, error: docError } = await supabase - .from('document_attachments') - .select('id, journal_entry_id') - .eq('id', document_id) - .eq('company_id', companyId) - .maybeSingle() - - if (docError || !document) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - // A document that already serves as underlag for a DIFFERENT verifikation - // cannot be pinned here: propagating would either corrupt that link or be - // blocked by the document-metadata immutability trigger. Same verifikation - // is fine (idempotent re-attach; propagation below becomes a no-op). - const docJournalEntryId = (document.journal_entry_id as string | null) ?? null - if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) { - return NextResponse.json( - { error: 'Underlaget är redan kopplat till en annan verifikation.' }, - { status: 409 }, - ) - } - - // Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we - // propagate against reflects any concurrent categorize that committed before - // our UPDATE acquired the row lock. Mirrors commitAttachDocumentToTransaction - // in lib/pending-operations/commit.ts so REST and MCP attaches converge. - const { data: postUpdate, error: updateError } = await supabase - .from('transactions') - .update({ document_id }) - .eq('id', transactionId) - .eq('company_id', companyId) - .select('journal_entry_id') - .maybeSingle() - - if (updateError) { - const errMsg = (updateError as { message?: string }).message ?? '' - if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { + // A document that already serves as underlag for a DIFFERENT verifikation + // cannot be pinned here: propagating would either corrupt that link or be + // blocked by the document-metadata immutability trigger. Same verifikation + // is fine (idempotent re-attach; propagation below becomes a no-op). + const docJournalEntryId = (document.journal_entry_id as string | null) ?? null + if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) { return NextResponse.json( - { - error: - 'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.', - }, + { error: 'Underlaget är redan kopplat till en annan verifikation.' }, { status: 409 }, ) } - console.error('[attach-document] Failed to attach:', updateError) - return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 }) - } - if (!postUpdate) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - // If this document came from an invoice_inbox_items row, mark that row - // as matched so the inbox UI can show it as "Kopplad" + link back to the - // transaction. Best-effort: a failure here must not roll back the - // (compliant) document attach. - // - // The Supabase client resolves with { error } rather than rejecting on - // RLS/DB errors, so we destructure rather than try/catch. - const { error: inboxLinkErr } = await supabase - .from('invoice_inbox_items') - .update({ matched_transaction_id: transactionId }) - .eq('document_id', document_id) - .eq('company_id', companyId) - .is('matched_transaction_id', null) - .is('created_supplier_invoice_id', null) - if (inboxLinkErr) { - console.error('[attach-document] Failed to link inbox item:', inboxLinkErr) - } - - // If the transaction is already booked, propagate the link onto the - // verifikation immediately (BFL 5 kap 6 §: the verifikation must reference - // its underlag). Skipped when the doc already points at this verifikation - // (idempotent re-attach). Mirrors commitAttachDocumentToTransaction. - const journalEntryId = (postUpdate.journal_entry_id as string | null) ?? null - if (journalEntryId && docJournalEntryId !== journalEntryId) { - const { error: linkErr } = await supabase - .from('document_attachments') - .update({ journal_entry_id: journalEntryId }) - .eq('id', document_id) + // Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we + // propagate against reflects any concurrent categorize that committed before + // our UPDATE acquired the row lock. Mirrors commitAttachDocumentToTransaction + // in lib/pending-operations/commit.ts so REST and MCP attaches converge. + const { data: postUpdate, error: updateError } = await supabase + .from('transactions') + .update({ document_id }) + .eq('id', transactionId) .eq('company_id', companyId) - if (linkErr) { - // The enforce_period_lock trigger blocks journal_entry_id writes when - // the target entry sits in a closed/locked period. - const linkMsg = (linkErr as { message?: string }).message ?? '' - if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) { - // Honest about the partial write: the pin on the transaction (and the - // inbox back-link) persisted; only the verifikat link was blocked. + .select('journal_entry_id') + .maybeSingle() + + if (updateError) { + const errMsg = (updateError as { message?: string }).message ?? '' + if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { return NextResponse.json( { error: - 'Bilagan kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.', + 'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.', }, { status: 409 }, ) } - // Surface the propagation failure rather than logging-and-continuing: - // a "succeeded" attach that left document_attachments.journal_entry_id - // null would be a silent compliance gap. A retry is idempotent. - console.error('[attach-document] Failed to propagate to journal entry:', linkErr) - return NextResponse.json( - { - error: - 'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen: operationen är idempotent.', - }, - { status: 500 }, - ) + console.error('[attach-document] Failed to attach:', updateError) + return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 }) } - } - - // Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc - // was replaced. Best-effort: a logging failure must not roll back the - // (compliant) attach. - if (previousDocumentId && previousDocumentId !== document_id) { - try { - await appendProcessingHistory({ - companyId, - correlationId: transactionId, - aggregateType: 'BankTransaction', - aggregateId: transactionId, - eventType: 'TransactionDocumentReplaced', - payload: { - transaction_id: transactionId, - previous_document_id: previousDocumentId, - new_document_id: document_id, - journal_entry_id: journalEntryId, - }, - actor: { type: 'user', id: user.id }, - occurredAt: new Date(), - }) - } catch (logErr) { - console.error('[attach-document] Failed to append rättelse event:', logErr) + if (!postUpdate) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) } - } - return NextResponse.json({ - data: { - transaction_id: transactionId, - document_id, - previous_document_id: previousDocumentId, - journal_entry_id: journalEntryId, - }, - }) -} + // If this document came from an invoice_inbox_items row, mark that row + // as matched so the inbox UI can show it as "Kopplad" + link back to the + // transaction. Best-effort: a failure here must not roll back the + // (compliant) document attach. + // + // The Supabase client resolves with { error } rather than rejecting on + // RLS/DB errors, so we destructure rather than try/catch. + const { error: inboxLinkErr } = await supabase + .from('invoice_inbox_items') + .update({ matched_transaction_id: transactionId }) + .eq('document_id', document_id) + .eq('company_id', companyId) + .is('matched_transaction_id', null) + .is('created_supplier_invoice_id', null) + if (inboxLinkErr) { + console.error('[attach-document] Failed to link inbox item:', inboxLinkErr) + } + + // If the transaction is already booked, propagate the link onto the + // verifikation immediately (BFL 5 kap 6 §: the verifikation must reference + // its underlag). Skipped when the doc already points at this verifikation + // (idempotent re-attach). Mirrors commitAttachDocumentToTransaction. + const journalEntryId = (postUpdate.journal_entry_id as string | null) ?? null + if (journalEntryId && docJournalEntryId !== journalEntryId) { + const { error: linkErr } = await supabase + .from('document_attachments') + .update({ journal_entry_id: journalEntryId }) + .eq('id', document_id) + .eq('company_id', companyId) + if (linkErr) { + // The enforce_period_lock trigger blocks journal_entry_id writes when + // the target entry sits in a closed/locked period. + const linkMsg = (linkErr as { message?: string }).message ?? '' + if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) { + // Honest about the partial write: the pin on the transaction (and the + // inbox back-link) persisted; only the verifikat link was blocked. + return NextResponse.json( + { + error: + 'Bilagan kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.', + }, + { status: 409 }, + ) + } + // Surface the propagation failure rather than logging-and-continuing: + // a "succeeded" attach that left document_attachments.journal_entry_id + // null would be a silent compliance gap. A retry is idempotent. + console.error('[attach-document] Failed to propagate to journal entry:', linkErr) + return NextResponse.json( + { + error: + 'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen: operationen är idempotent.', + }, + { status: 500 }, + ) + } + } + + // Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc + // was replaced. Best-effort: a logging failure must not roll back the + // (compliant) attach. + if (previousDocumentId && previousDocumentId !== document_id) { + try { + await appendProcessingHistory({ + companyId, + correlationId: transactionId, + aggregateType: 'BankTransaction', + aggregateId: transactionId, + eventType: 'TransactionDocumentReplaced', + payload: { + transaction_id: transactionId, + previous_document_id: previousDocumentId, + new_document_id: document_id, + journal_entry_id: journalEntryId, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (logErr) { + console.error('[attach-document] Failed to append rättelse event:', logErr) + } + } + + return NextResponse.json({ + data: { + transaction_id: transactionId, + document_id, + previous_document_id: previousDocumentId, + journal_entry_id: journalEntryId, + }, + }) + }, + { requireWrite: true }, +) /** * DELETE /api/transactions/[id]/attach-document @@ -208,75 +198,67 @@ export async function POST( * räkenskapsinformation immutability): at that point the doc is the * verifikation's underlag and can only be undone by reversing the entry. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id: transactionId } = await params +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.detach_document', + async (_request, { supabase, companyId }, { params }) => { + const { id: transactionId } = await params - 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) - - const { data: tx, error: fetchError } = await supabase - .from('transactions') - .select('id, document_id') - .eq('id', transactionId) - .eq('company_id', companyId) - .maybeSingle() - - if (fetchError || !tx) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - if (tx.document_id) { - const { data: doc } = await supabase - .from('document_attachments') - .select('journal_entry_id') - .eq('id', tx.document_id) + const { data: tx, error: fetchError } = await supabase + .from('transactions') + .select('id, document_id') + .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() - if (doc?.journal_entry_id) { - return NextResponse.json( - { - error: - 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', - }, - { status: 409 }, - ) + + if (fetchError || !tx) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) } - } - const { error: updateError } = await supabase - .from('transactions') - .update({ document_id: null }) - .eq('id', transactionId) - .eq('company_id', companyId) - - if (updateError) { - // The enforce_transactions_document_immutability trigger raises a - // P0001 exception with a stable BFL_DOCUMENT_IMMUTABILITY: prefix when the - // previously-attached doc has already become räkenskapsinformation. - // Match on the prefix (not on the generic SQLSTATE) so unrelated future - // exceptions don't get translated into the Swedish underlag message. - const errMsg = (updateError as { message?: string }).message ?? '' - if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { - return NextResponse.json( - { - error: - 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', - }, - { status: 409 }, - ) + if (tx.document_id) { + const { data: doc } = await supabase + .from('document_attachments') + .select('journal_entry_id') + .eq('id', tx.document_id) + .eq('company_id', companyId) + .maybeSingle() + if (doc?.journal_entry_id) { + return NextResponse.json( + { + error: + 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', + }, + { status: 409 }, + ) + } } - console.error('[attach-document] Failed to detach:', updateError) - return NextResponse.json({ error: 'Failed to detach document' }, { status: 500 }) - } - return NextResponse.json({ data: { transaction_id: transactionId, document_id: null } }) -} + const { error: updateError } = await supabase + .from('transactions') + .update({ document_id: null }) + .eq('id', transactionId) + .eq('company_id', companyId) + + if (updateError) { + // The enforce_transactions_document_immutability trigger raises a + // P0001 exception with a stable BFL_DOCUMENT_IMMUTABILITY: prefix when the + // previously-attached doc has already become räkenskapsinformation. + // Match on the prefix (not on the generic SQLSTATE) so unrelated future + // exceptions don't get translated into the Swedish underlag message. + const errMsg = (updateError as { message?: string }).message ?? '' + if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { + return NextResponse.json( + { + error: + 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', + }, + { status: 409 }, + ) + } + console.error('[attach-document] Failed to detach:', updateError) + return NextResponse.json({ error: 'Failed to detach document' }, { status: 500 }) + } + + return NextResponse.json({ data: { transaction_id: transactionId, document_id: null } }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index 272c3316..3bad15d4 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -10,8 +11,10 @@ import { import { eventBus } from '@/lib/events' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -23,8 +26,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) const mockCreateJournalEntry = vi.fn() @@ -70,14 +74,19 @@ describe('POST /api/transactions/[id]/book', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) // No booking-duplicate by default; guard tests override per-case. mockDetectDup.mockResolvedValue(null) mockAppendProcessingHistory.mockResolvedValue('evt-1') }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', @@ -90,6 +99,24 @@ describe('POST /api/transactions/[id]/book', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + it('returns 400 when missing required fields', async () => { const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index c3860b44..f00e408b 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -1,201 +1,187 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { createJournalEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { BookTransactionSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { createLogger } from '@/lib/logger' import { appendProcessingHistory } from '@/lib/processing-history/append' import type { Transaction } from '@/types' ensureInitialized() -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.book', + async (request, { supabase, user, companyId, log }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + const validation = await validateBody(request, BookTransactionSchema) + if (!validation.success) return validation.response + const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id, expected_duplicate_journal_entry_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Fetch transaction (validates ownership) + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + // Reject if already booked + if (transaction.journal_entry_id) { + return NextResponse.json( + { error: 'Transaction already has a journal entry' }, + { status: 409 } + ) + } - const validation = await validateBody(request, BookTransactionSchema) - if (!validation.success) return validation.response - const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id, expected_duplicate_journal_entry_id } = validation.data - - // Fetch transaction (validates ownership) - const { data: transaction, error: fetchError } = await supabase - .from('transactions') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - // Reject if already booked - if (transaction.journal_entry_id) { - return NextResponse.json( - { error: 'Transaction already has a journal entry' }, - { status: 409 } - ) - } - - // Booking-time duplicate guard: if another transaction with the same - // date+amount+account is already booked, booking this one would double-count - // one real event (two verifikationer: felaktig bokföring per BFL). Warn; the - // user confirms with force=true bound to the reviewed sibling. Mirrors the - // match-invoice soft-duplicate guard. - const dupLog = createLogger('transactions.book', { companyId, userId: user.id }) - try { - const candidate = await detectBookingDuplicate(supabase, companyId, { - id, - date: transaction.date, - amount: transaction.amount, - cash_account_id: transaction.cash_account_id ?? null, - }) - if (!force) { - if (candidate) { - return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', dupLog, { - details: { candidate }, + // Booking-time duplicate guard: if another transaction with the same + // date+amount+account is already booked, booking this one would double-count + // one real event (two verifikationer: felaktig bokföring per BFL). Warn; the + // user confirms with force=true bound to the reviewed sibling. Mirrors the + // match-invoice soft-duplicate guard. + const dupLog = log + try { + const candidate = await detectBookingDuplicate(supabase, companyId, { + id, + date: transaction.date, + amount: transaction.amount, + cash_account_id: transaction.cash_account_id ?? null, + }) + if (!force) { + if (candidate) { + return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', dupLog, { + details: { candidate }, + }) + } + } else if ( + // force=true is bound to the reviewed candidate. A sibling-transaction + // candidate carries a transaction_id; a ledger-only voucher candidate does + // not, so both are bound by journal_entry_id. Either echoed id confirms. + // Re-detect and refuse the bypass unless it still matches, so a guessed id + // can't wave the guard. + !candidate || + !( + (candidate.journal_entry_id && candidate.journal_entry_id === expected_duplicate_journal_entry_id) || + (candidate.transaction_id && candidate.transaction_id === expected_duplicate_transaction_id) + ) + ) { + return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { + details: { + expected_duplicate_transaction_id: expected_duplicate_transaction_id ?? null, + expected_duplicate_journal_entry_id: expected_duplicate_journal_entry_id ?? null, + detected_transaction_id: candidate?.transaction_id ?? null, + detected_journal_entry_id: candidate?.journal_entry_id ?? null, + }, + }) + } else { + dupLog.warn('booking-time duplicate guard bypassed', { + reason: 'force=true', + transactionId: id, + dismissedTransactionId: candidate.transaction_id, + }) + // Persist the dismissal to behandlingshistorik (BFNAR 2013:2 kap 8): the + // decision to book over a DETECTED possible double-booking is a + // bookkeeping act that must leave a durable, queryable record; a warn in + // the application log is ephemeral and does not satisfy the requirement. + // Best-effort: a logging failure must never block a legitimate booking. + try { + await appendProcessingHistory({ + companyId, + correlationId: id, + aggregateType: 'BankTransaction', + aggregateId: id, + eventType: 'BankTransactionDuplicateDismissed', + payload: { + transaction_id: id, + dismissed_transaction_id: candidate.transaction_id, + dismissed_journal_entry_id: candidate.journal_entry_id, + amount_ore: Math.round(candidate.amount * 100), + entry_date: candidate.entry_date, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (logErr) { + dupLog.error('failed to append duplicate-dismissal behandlingshistorik', logErr as Error) + } + } + } catch (err) { + // Detection is fail-open for the non-force path; force requires a confirmed + // candidate, so a detection failure under force is rejected as a mismatch. + if (force) { + return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { + details: { detection_failed: true }, }) } - } else if ( - // force=true is bound to the reviewed candidate. A sibling-transaction - // candidate carries a transaction_id; a ledger-only voucher candidate does - // not, so both are bound by journal_entry_id. Either echoed id confirms. - // Re-detect and refuse the bypass unless it still matches, so a guessed id - // can't wave the guard. - !candidate || - !( - (candidate.journal_entry_id && candidate.journal_entry_id === expected_duplicate_journal_entry_id) || - (candidate.transaction_id && candidate.transaction_id === expected_duplicate_transaction_id) + dupLog.warn('booking-time duplicate detection failed (continuing)', err as Error) + } + + // Create journal entry via the engine + let journalEntry + try { + journalEntry = await createJournalEntry(supabase, companyId, user.id, { + fiscal_period_id, + entry_date, + description, + source_type: 'bank_transaction', + source_id: id, + lines, + }) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, + { status: 400 } ) - ) { - return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { - details: { - expected_duplicate_transaction_id: expected_duplicate_transaction_id ?? null, - expected_duplicate_journal_entry_id: expected_duplicate_journal_entry_id ?? null, - detected_transaction_id: candidate?.transaction_id ?? null, - detected_journal_entry_id: candidate?.journal_entry_id ?? null, + } + + // Link transaction to the journal entry + const { error: updateError } = await supabase + .from('transactions') + .update({ + journal_entry_id: journalEntry.id, + is_business: true, + category: 'uncategorized', + }) + .eq('id', id) + + if (updateError) { + return NextResponse.json( + { error: 'Failed to update transaction' }, + { status: 500 } + ) + } + + // Emit event (non-blocking) + try { + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: transaction as Transaction, + account: lines[0]?.account_number || '', + taxCode: '', + userId: user.id, + companyId, }, }) - } else { - dupLog.warn('booking-time duplicate guard bypassed', { - reason: 'force=true', - transactionId: id, - dismissedTransactionId: candidate.transaction_id, - }) - // Persist the dismissal to behandlingshistorik (BFNAR 2013:2 kap 8): the - // decision to book over a DETECTED possible double-booking is a - // bookkeeping act that must leave a durable, queryable record; a warn in - // the application log is ephemeral and does not satisfy the requirement. - // Best-effort: a logging failure must never block a legitimate booking. - try { - await appendProcessingHistory({ - companyId, - correlationId: id, - aggregateType: 'BankTransaction', - aggregateId: id, - eventType: 'BankTransactionDuplicateDismissed', - payload: { - transaction_id: id, - dismissed_transaction_id: candidate.transaction_id, - dismissed_journal_entry_id: candidate.journal_entry_id, - amount_ore: Math.round(candidate.amount * 100), - entry_date: candidate.entry_date, - }, - actor: { type: 'user', id: user.id }, - occurredAt: new Date(), - }) - } catch (logErr) { - dupLog.error('failed to append duplicate-dismissal behandlingshistorik', logErr as Error) - } + } catch { + // Non-critical } - } catch (err) { - // Detection is fail-open for the non-force path; force requires a confirmed - // candidate, so a detection failure under force is rejected as a mismatch. - if (force) { - return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { - details: { detection_failed: true }, - }) - } - dupLog.warn('booking-time duplicate detection failed (continuing)', err as Error) - } - // Create journal entry via the engine - let journalEntry - try { - journalEntry = await createJournalEntry(supabase, companyId, user.id, { - fiscal_period_id, - entry_date, - description, - source_type: 'bank_transaction', - source_id: id, - lines, - }) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, - { status: 400 } - ) - } - - // Link transaction to the journal entry - const { error: updateError } = await supabase - .from('transactions') - .update({ + return NextResponse.json({ + data: journalEntry, journal_entry_id: journalEntry.id, - is_business: true, - category: 'uncategorized', + success: true, }) - .eq('id', id) - - if (updateError) { - return NextResponse.json( - { error: 'Failed to update transaction' }, - { status: 500 } - ) - } - - // Emit event (non-blocking) - try { - await eventBus.emit({ - type: 'transaction.categorized', - payload: { - transaction: transaction as Transaction, - account: lines[0]?.account_number || '', - taxCode: '', - userId: user.id, - companyId, - }, - }) - } catch { - // Non-critical - } - - return NextResponse.json({ - data: journalEntry, - journal_entry_id: journalEntry.id, - success: true, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/ignore/__tests__/route.test.ts b/app/api/transactions/[id]/ignore/__tests__/route.test.ts new file mode 100644 index 00000000..1d7776e0 --- /dev/null +++ b/app/api/transactions/[id]/ignore/__tests__/route.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST, DELETE } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/transactions/[id]/ignore', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + // No DB read/write happens when the role gate rejects. + expect(mockSupabase.from).not.toHaveBeenCalled() + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-999/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-999' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(404) + expect(body).toEqual({ error: 'Transaction not found' }) + }) + + it('returns 409 when the transaction is already booked', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1', is_ignored: false }, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(409) + expect(body.error).toContain('redan bokförd') + }) + + it('is idempotent when the transaction is already ignored', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: true }, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true, already_ignored: true }) + }) + + it('marks the transaction ignored (happy path)', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch + enqueue({ data: null, error: null }) // update + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true }) + }) + + it('returns 500 when the update fails', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch + enqueue({ data: null, error: { message: 'db down' } }) // update fails + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(body).toEqual({ error: 'db down' }) + }) +}) + +describe('DELETE /api/transactions/[id]/ignore', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + + it('clears the ignore flag (happy path)', async () => { + enqueue({ data: null, error: null }) // update + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true }) + }) + + it('returns 500 when the update fails', async () => { + enqueue({ data: null, error: { message: 'db down' } }) // update fails + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(body).toEqual({ error: 'db down' }) + }) +}) diff --git a/app/api/transactions/[id]/ignore/route.ts b/app/api/transactions/[id]/ignore/route.ts index b4be2ea8..93b469cd 100644 --- a/app/api/transactions/[id]/ignore/route.ts +++ b/app/api/transactions/[id]/ignore/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * POST /api/transactions/[id]/ignore @@ -15,57 +13,47 @@ import { requireWritePermission } from '@/lib/auth/require-write' * Refuses when the transaction is already booked; once a verifikation exists, * the proper way to revisit it is /uncategorize (storno). */ -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.ignore', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('id, journal_entry_id, is_ignored') + .eq('id', id) + .eq('company_id', companyId) + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + if (transaction.journal_entry_id) { + return NextResponse.json( + { error: 'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.' }, + { status: 409 } + ) + } - const { data: transaction, error: fetchError } = await supabase - .from('transactions') - .select('id, journal_entry_id, is_ignored') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (transaction.is_ignored) { + return NextResponse.json({ success: true, already_ignored: true }) + } - if (fetchError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } + const { error: updateError } = await supabase + .from('transactions') + .update({ is_ignored: true }) + .eq('id', id) + .eq('company_id', companyId) - if (transaction.journal_entry_id) { - return NextResponse.json( - { error: 'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.' }, - { status: 409 } - ) - } + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } - if (transaction.is_ignored) { - return NextResponse.json({ success: true, already_ignored: true }) - } - - const { error: updateError } = await supabase - .from('transactions') - .update({ is_ignored: true }) - .eq('id', id) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) /** * DELETE /api/transactions/[id]/ignore @@ -74,32 +62,22 @@ export async function POST( * no further side effects: we never created a verifikation, so there's * nothing to storno. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.unignore', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { error: updateError } = await supabase + .from('transactions') + .update({ is_ignored: false }) + .eq('id', id) + .eq('company_id', companyId) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { error: updateError } = await supabase - .from('transactions') - .update({ is_ignored: false }) - .eq('id', id) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts index 495d1629..da7bad4e 100644 --- a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -8,8 +9,10 @@ import { import { eventBus } from '@/lib/events' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -21,8 +24,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) const mockReverseEntry = vi.fn() @@ -39,11 +43,16 @@ describe('POST /api/transactions/[id]/uncategorize', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) @@ -53,6 +62,21 @@ describe('POST /api/transactions/[id]/uncategorize', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + expect(mockReverseEntry).not.toHaveBeenCalled() + }) + it('returns 404 when transaction not found', async () => { enqueue({ data: null, error: { message: 'Not found' } }) diff --git a/app/api/transactions/[id]/uncategorize/route.ts b/app/api/transactions/[id]/uncategorize/route.ts index 89b6da95..e8b02665 100644 --- a/app/api/transactions/[id]/uncategorize/route.ts +++ b/app/api/transactions/[id]/uncategorize/route.ts @@ -1,86 +1,74 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { reverseEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' 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' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.uncategorize', + async (_request, { supabase, user, companyId }, { params }) => { + const { id } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Fetch transaction + const { data: transaction, error: txError } = await supabase + .from('transactions') + .select('id, journal_entry_id') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (txError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const { id } = await params + if (!transaction.journal_entry_id) { + return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 }) + } - // Fetch transaction - const { data: transaction, error: txError } = await supabase - .from('transactions') - .select('id, journal_entry_id') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Verify journal entry is posted + const { data: entry, error: entryError } = await supabase + .from('journal_entries') + .select('id, status') + .eq('id', transaction.journal_entry_id) + .eq('company_id', companyId) + .single() - if (txError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } + if (entryError || !entry) { + return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 }) + } - if (!transaction.journal_entry_id) { - return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 }) - } + if (entry.status !== 'posted') { + return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 }) + } - // Verify journal entry is posted - const { data: entry, error: entryError } = await supabase - .from('journal_entries') - .select('id, status') - .eq('id', transaction.journal_entry_id) - .eq('company_id', companyId) - .single() + // Storno reversal (legally compliant: never deletes) + try { + await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + const message = err instanceof Error ? err.message : 'Reversal failed' + return NextResponse.json({ error: message }, { status: 500 }) + } - if (entryError || !entry) { - return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 }) - } + // Reset transaction categorization + const { error: updateError } = await supabase + .from('transactions') + .update({ + is_business: null, + category: null, + journal_entry_id: null, + }) + .eq('id', id) + .eq('company_id', companyId) - if (entry.status !== 'posted') { - return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 }) - } + if (updateError) { + return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 }) + } - // Storno reversal (legally compliant: never deletes) - try { - await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - const message = err instanceof Error ? err.message : 'Reversal failed' - return NextResponse.json({ error: message }, { status: 500 }) - } - - // Reset transaction categorization - const { error: updateError } = await supabase - .from('transactions') - .update({ - is_business: null, - category: null, - journal_entry_id: null, - }) - .eq('id', id) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/batch-match-invoices/__tests__/route.test.ts b/app/api/transactions/batch-match-invoices/__tests__/route.test.ts index 223134fa..d908191d 100644 --- a/app/api/transactions/batch-match-invoices/__tests__/route.test.ts +++ b/app/api/transactions/batch-match-invoices/__tests__/route.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) const mockGetBestInvoiceMatch = vi.fn() @@ -13,33 +16,60 @@ vi.mock('@/lib/invoices/invoice-matching', () => ({ vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) import { POST } from '../route' +function makeReq() { + return createMockRequest('/api/transactions/batch-match-invoices', { method: 'POST' }) +} + +const routeParams = { params: Promise.resolve({}) } + describe('POST /api/transactions/batch-match-invoices', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse(response) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled() + }) + it('calls the invoice matcher with companyId (not user.id) and records the match', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500 }) enqueue({ data: [tx], error: null }) // fetch uncategorized income transactions @@ -47,7 +77,7 @@ describe('POST /api/transactions/batch-match-invoices', () => { mockGetBestInvoiceMatch.mockResolvedValue({ invoice: { id: 'inv-1' }, confidence: 0.9 }) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response) expect(status).toBe(200) @@ -75,7 +105,7 @@ describe('POST /api/transactions/batch-match-invoices', () => { mockGetBestInvoiceMatch.mockResolvedValue(null) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response) expect(status).toBe(200) @@ -85,7 +115,7 @@ describe('POST /api/transactions/batch-match-invoices', () => { it('returns 500 when the transaction fetch fails', async () => { enqueue({ data: null, error: { message: 'boom' } }) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) diff --git a/app/api/transactions/batch-match-invoices/route.ts b/app/api/transactions/batch-match-invoices/route.ts index ab528da7..e1c7633b 100644 --- a/app/api/transactions/batch-match-invoices/route.ts +++ b/app/api/transactions/batch-match-invoices/route.ts @@ -1,72 +1,61 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { Transaction } from '@/types' /** * POST /api/transactions/batch-match-invoices * Run invoice matching for all uncategorized income transactions without potential_invoice_id */ -export async function POST() { - const supabase = await createClient() +export const POST = withRouteContext( + 'transaction.batch_match_invoices', + async (_request, { supabase, companyId }) => { + // Fetch uncategorized income transactions without a potential match + const { data: transactions, error: txError } = await supabase + .from('transactions') + .select('*') + .eq('company_id', companyId) + .is('is_business', null) + .gt('amount', 0) + .is('potential_invoice_id', null) + .is('invoice_id', null) + .order('date', { ascending: false }) + .limit(50) - 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) - - // Fetch uncategorized income transactions without a potential match - const { data: transactions, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('company_id', companyId) - .is('is_business', null) - .gt('amount', 0) - .is('potential_invoice_id', null) - .is('invoice_id', null) - .order('date', { ascending: false }) - .limit(50) - - if (txError || !transactions) { - return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) - } - - let matched = 0 - const matchedInvoiceIds = new Set() - - for (const tx of transactions) { - try { - const bestMatch = await getBestInvoiceMatch( - supabase, - companyId, - tx as Transaction, - 0.50 - ) - - if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) { - await supabase - .from('transactions') - .update({ potential_invoice_id: bestMatch.invoice.id }) - .eq('id', tx.id) - - matchedInvoiceIds.add(bestMatch.invoice.id) - matched++ - } - } catch { - // Continue with other transactions + if (txError || !transactions) { + return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) } - } - return NextResponse.json({ - processed: transactions.length, - matched, - }) -} + let matched = 0 + const matchedInvoiceIds = new Set() + + for (const tx of transactions) { + try { + const bestMatch = await getBestInvoiceMatch( + supabase, + companyId, + tx as Transaction, + 0.50 + ) + + if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) { + await supabase + .from('transactions') + .update({ potential_invoice_id: bestMatch.invoice.id }) + .eq('id', tx.id) + + matchedInvoiceIds.add(bestMatch.invoice.id) + matched++ + } + } catch { + // Continue with other transactions + } + } + + return NextResponse.json({ + processed: transactions.length, + matched, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/create-from-document/__tests__/route.test.ts b/app/api/transactions/create-from-document/__tests__/route.test.ts index cb0f02cc..f451ff53 100644 --- a/app/api/transactions/create-from-document/__tests__/route.test.ts +++ b/app/api/transactions/create-from-document/__tests__/route.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -11,8 +14,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -26,11 +30,14 @@ const mockUser = { id: 'user-1', email: 'test@test.se' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) const VALID_UUID = '11111111-1111-4111-8111-111111111111' +const routeParams = { params: Promise.resolve({}) } + function makeReq(body: unknown) { return new Request('http://localhost/api/transactions/create-from-document', { method: 'POST', @@ -56,28 +63,43 @@ function validBody(overrides: Partial<{ describe('POST /api/transactions/create-from-document', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) - const res = await POST(makeReq(validBody())) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await POST(makeReq(validBody()), routeParams) + const { status, body } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + it('returns 400 when the body is invalid', async () => { - const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 })) + const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 }), routeParams) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns 400 when amount is zero (schema refine)', async () => { - const res = await POST(makeReq(validBody({ amount: 0 }))) + const res = await POST(makeReq(validBody({ amount: 0 })), routeParams) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns 404 when the inbox item is not in the user company', async () => { enqueue({ data: null, error: null }) // inbox item lookup misses - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(404) expect(body.error).toBe('Inbox item not found') @@ -94,7 +116,7 @@ describe('POST /api/transactions/create-from-document', () => { }, error: null, }) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(409) expect(body.error).toMatch(/redan kopplad/) @@ -111,7 +133,7 @@ describe('POST /api/transactions/create-from-document', () => { }, error: null, }) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(409) expect(body.error).toMatch(/redan bokförd/) @@ -131,7 +153,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: { id: 'new-tx-1' }, error: null }) // insert enqueue({ data: [{ id: VALID_UUID }], error: null }) // inbox update: one row affected - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ data: { transaction_id: string; inbox_item_id: string; document_id: string } }>(res) @@ -159,7 +181,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: [], error: null }) // inbox update affects zero rows: lost the race enqueue({ data: null, error: null }) // rollback delete of the orphan - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(409) expect(body.error).toMatch(/parallell begäran/) @@ -182,7 +204,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: { id: 'new-tx-3' }, error: null }) enqueue({ data: [{ id: VALID_UUID }], error: null }) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status } = await parseJsonResponse(res) expect(status).toBe(200) }) @@ -201,7 +223,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: null, error: { message: 'db down' } }) // insert fails // Silence the console.error const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(500) expect(body.error).toMatch(/Kunde inte skapa transaktion/) @@ -222,7 +244,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: { id: 'new-tx-2' }, error: null }) // insert ok enqueue({ data: null, error: { message: 'rls' } }) // link update fails const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ data: { transaction_id: string; inbox_link_failed?: boolean } }>(res) diff --git a/app/api/transactions/create-from-document/route.ts b/app/api/transactions/create-from-document/route.ts index 3c600d5c..c2607c69 100644 --- a/app/api/transactions/create-from-document/route.ts +++ b/app/api/transactions/create-from-document/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateTransactionFromDocumentSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -20,120 +18,114 @@ ensureInitialized() * Use case: receipt in the inbox has no matching bank transaction * (cash purchase, personal-card expense, missed sync). */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'transaction.create_from_document', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, CreateTransactionFromDocumentSchema) + if (!validation.success) return validation.response + const { inbox_item_id, amount, transaction_date, description } = validation.data - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { data: item, error: itemError } = await supabase + .from('invoice_inbox_items') + .select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data') + .eq('id', inbox_item_id) + .eq('company_id', companyId) + .maybeSingle() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (itemError || !item) { + return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 }) + } + if (item.matched_transaction_id) { + return NextResponse.json( + { error: 'Inkorgsposten är redan kopplad till en transaktion.' }, + { status: 409 }, + ) + } + if (item.created_supplier_invoice_id) { + return NextResponse.json( + { error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' }, + { status: 409 }, + ) + } - const companyId = await requireCompanyId(supabase, user.id) + // Allowlist the currency: extracted_data.invoice.currency comes from the + // (deterministic, but still untrusted) PDF extractor, so an arbitrary + // string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to + // the transactions table and break later formatCurrency / journal-entry + // bookings (BFL 5 kap 6 §). Coerce anything outside the supported set + // to SEK; the user can change it manually on the transaction. + const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']) + const extractedCurrency = ( + item.extracted_data as { invoice?: { currency?: string } } | null + )?.invoice?.currency + const currency = + extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency) + ? extractedCurrency + : 'SEK' - const validation = await validateBody(request, CreateTransactionFromDocumentSchema) - if (!validation.success) return validation.response - const { inbox_item_id, amount, transaction_date, description } = validation.data + const { data: newTx, error: insertError } = await supabase + .from('transactions') + .insert({ + company_id: companyId, + user_id: user.id, + date: transaction_date, + description, + amount, + currency, + category: 'uncategorized', + is_business: null, + import_source: 'manual', + document_id: item.document_id, + }) + .select('id') + .single() - const { data: item, error: itemError } = await supabase - .from('invoice_inbox_items') - .select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data') - .eq('id', inbox_item_id) - .eq('company_id', companyId) - .maybeSingle() + if (insertError || !newTx) { + console.error('[create-from-document] Failed to insert transaction:', insertError) + return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 }) + } - if (itemError || !item) { - return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 }) - } - if (item.matched_transaction_id) { - return NextResponse.json( - { error: 'Inkorgsposten är redan kopplad till en transaktion.' }, - { status: 409 }, - ) - } - if (item.created_supplier_invoice_id) { - return NextResponse.json( - { error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' }, - { status: 409 }, - ) - } + // Concurrency guard: the .is('matched_transaction_id', null) predicate + + // the rows-affected check turn this into an optimistic-lock release. If + // two requests with the same inbox_item_id race past the earlier + // matched_transaction_id check, only the first UPDATE will match a row + // here. The loser's transaction insert is then an orphan we proactively + // delete so the user doesn't get a duplicate uncategorized row. + const { data: linked, error: linkError } = await supabase + .from('invoice_inbox_items') + .update({ matched_transaction_id: newTx.id }) + .eq('id', inbox_item_id) + .eq('company_id', companyId) + .is('matched_transaction_id', null) + .select('id') - // Allowlist the currency: extracted_data.invoice.currency comes from the - // (deterministic, but still untrusted) PDF extractor, so an arbitrary - // string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to - // the transactions table and break later formatCurrency / journal-entry - // bookings (BFL 5 kap 6 §). Coerce anything outside the supported set - // to SEK; the user can change it manually on the transaction. - const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']) - const extractedCurrency = ( - item.extracted_data as { invoice?: { currency?: string } } | null - )?.invoice?.currency - const currency = - extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency) - ? extractedCurrency - : 'SEK' + if (linkError) { + console.error('[create-from-document] Failed to link inbox item:', linkError) + // Transaction was created; surface a 200 with a warning so the user can + // still find it under Transaktioner: the inbox-link orphan is recoverable. + return NextResponse.json({ + data: { transaction_id: newTx.id, inbox_link_failed: true }, + }) + } - const { data: newTx, error: insertError } = await supabase - .from('transactions') - .insert({ - company_id: companyId, - user_id: user.id, - date: transaction_date, - description, - amount, - currency, - category: 'uncategorized', - is_business: null, - import_source: 'manual', - document_id: item.document_id, - }) - .select('id') - .single() + if (!linked || linked.length === 0) { + // Lost a race: another concurrent request linked the inbox item first. + // Roll back our newly-created transaction (only safe because we own it + // and it has no journal_entry_id yet) and return 409 so the client can + // refetch and reuse the winning transaction instead of creating a dupe. + // Re-assert company_id on the delete (defence in depth: newTx.id is a + // fresh UUID from a company-scoped insert above, but scoping the rollback + // makes the invariant explicit). + await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId) + return NextResponse.json( + { error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' }, + { status: 409 }, + ) + } - if (insertError || !newTx) { - console.error('[create-from-document] Failed to insert transaction:', insertError) - return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 }) - } - - // Concurrency guard: the .is('matched_transaction_id', null) predicate + - // the rows-affected check turn this into an optimistic-lock release. If - // two requests with the same inbox_item_id race past the earlier - // matched_transaction_id check, only the first UPDATE will match a row - // here. The loser's transaction insert is then an orphan we proactively - // delete so the user doesn't get a duplicate uncategorized row. - const { data: linked, error: linkError } = await supabase - .from('invoice_inbox_items') - .update({ matched_transaction_id: newTx.id }) - .eq('id', inbox_item_id) - .eq('company_id', companyId) - .is('matched_transaction_id', null) - .select('id') - - if (linkError) { - console.error('[create-from-document] Failed to link inbox item:', linkError) - // Transaction was created; surface a 200 with a warning so the user can - // still find it under Transaktioner: the inbox-link orphan is recoverable. return NextResponse.json({ - data: { transaction_id: newTx.id, inbox_link_failed: true }, + data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id }, }) - } - - if (!linked || linked.length === 0) { - // Lost a race: another concurrent request linked the inbox item first. - // Roll back our newly-created transaction (only safe because we own it - // and it has no journal_entry_id yet) and return 409 so the client can - // refetch and reuse the winning transaction instead of creating a dupe. - // Re-assert company_id on the delete (defence in depth: newTx.id is a - // fresh UUID from a company-scoped insert above, but scoping the rollback - // makes the invariant explicit). - await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId) - return NextResponse.json( - { error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' }, - { status: 409 }, - ) - } - - return NextResponse.json({ - data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index cbf7756f..c3d28261 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -1,115 +1,107 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { getSuggestedCategories, getSuggestedTemplates, buildMerchantHistory, merchantHistoryFor, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' -import { requireCompanyId } from '@/lib/company/context' import type { Transaction, EntityType } from '@/types' /** * POST /api/transactions/suggest-categories * Batch endpoint for getting category suggestions for multiple transactions */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'transaction.suggest_categories', + async (request, { supabase, companyId }) => { + const { transaction_ids } = await request.json() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { transaction_ids } = await request.json() - - if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) { - return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 }) - } - - // Limit batch size - const ids = transaction_ids.slice(0, 50) - - // Fetch transactions - const { data: transactions, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('company_id', companyId) - .in('id', ids) - - if (txError || !transactions) { - return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) - } - - // Fetch user's mapping rules (once, for all transactions) - const { data: mappingRules } = await supabase - .from('mapping_rules') - .select('*') - .or(`company_id.eq.${companyId},company_id.is.null`) - .eq('is_active', true) - .order('priority', { ascending: false }) - - // Counterparty-keyed history from past categorized transactions: the - // suggestion engine only surfaces history tied to the SAME merchant - // (global frequency padding produced identical low-confidence spreads). - const { data: historicalTxns } = await supabase - .from('transactions') - .select('category, merchant_name') - .eq('company_id', companyId) - .not('is_business', 'is', null) - .neq('category', 'uncategorized') - .neq('category', 'private') - .limit(200) - - const merchantHistory = buildMerchantHistory(historicalTxns ?? []) - - // Fetch entity type for template matching - const { data: settings } = await supabase - .from('company_settings') - .select('entity_type') - .eq('company_id', companyId) - .single() - const entityType = (settings?.entity_type as EntityType) || undefined - - // Batch counterparty template matching (1 DB query, in-memory matching) - const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[]) - - // Generate initial suggestions for each transaction - const suggestions: Record = {} - const template_suggestions: Record = {} - - for (const tx of transactions) { - suggestions[tx.id] = getSuggestedCategories( - tx as Transaction, - mappingRules || [], - merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name) - ) - template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined) - } - - // Inject counterparty template matches as top suggestions - for (const tx of transactions) { - const cpMatch = counterpartyMatches.get(tx.id) - if (!cpMatch) continue - - const tmpl = cpMatch.template - const cpSuggestion: SuggestedTemplate = { - template_id: toCounterpartyTemplateId(tmpl.id), - name_sv: formatCounterpartyName(tmpl.counterparty_name), - name_en: formatCounterpartyName(tmpl.counterparty_name), - group: 'counterparty', - debit_account: tmpl.debit_account, - credit_account: tmpl.credit_account, - confidence: cpMatch.confidence, - description_sv: `${tmpl.occurrence_count} tidigare bokföringar`, - risk_level: 'NONE', - requires_review: false, - line_pattern: tmpl.line_pattern ?? null, + if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) { + return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 }) } - const existing = template_suggestions[tx.id] || [] - template_suggestions[tx.id] = [cpSuggestion, ...existing] - } + // Limit batch size + const ids = transaction_ids.slice(0, 50) + + // Fetch transactions + const { data: transactions, error: txError } = await supabase + .from('transactions') + .select('*') + .eq('company_id', companyId) + .in('id', ids) + + if (txError || !transactions) { + return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) + } + + // Fetch user's mapping rules (once, for all transactions) + const { data: mappingRules } = await supabase + .from('mapping_rules') + .select('*') + .or(`company_id.eq.${companyId},company_id.is.null`) + .eq('is_active', true) + .order('priority', { ascending: false }) + + // Counterparty-keyed history from past categorized transactions: the + // suggestion engine only surfaces history tied to the SAME merchant + // (global frequency padding produced identical low-confidence spreads). + const { data: historicalTxns } = await supabase + .from('transactions') + .select('category, merchant_name') + .eq('company_id', companyId) + .not('is_business', 'is', null) + .neq('category', 'uncategorized') + .neq('category', 'private') + .limit(200) + + const merchantHistory = buildMerchantHistory(historicalTxns ?? []) + + // Fetch entity type for template matching + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .single() + const entityType = (settings?.entity_type as EntityType) || undefined + + // Batch counterparty template matching (1 DB query, in-memory matching) + const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[]) + + // Generate initial suggestions for each transaction + const suggestions: Record = {} + const template_suggestions: Record = {} + + for (const tx of transactions) { + suggestions[tx.id] = getSuggestedCategories( + tx as Transaction, + mappingRules || [], + merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name) + ) + template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined) + } + + // Inject counterparty template matches as top suggestions + for (const tx of transactions) { + const cpMatch = counterpartyMatches.get(tx.id) + if (!cpMatch) continue + + const tmpl = cpMatch.template + const cpSuggestion: SuggestedTemplate = { + template_id: toCounterpartyTemplateId(tmpl.id), + name_sv: formatCounterpartyName(tmpl.counterparty_name), + name_en: formatCounterpartyName(tmpl.counterparty_name), + group: 'counterparty', + debit_account: tmpl.debit_account, + credit_account: tmpl.credit_account, + confidence: cpMatch.confidence, + description_sv: `${tmpl.occurrence_count} tidigare bokföringar`, + risk_level: 'NONE', + requires_review: false, + line_pattern: tmpl.line_pattern ?? null, + } + + const existing = template_suggestions[tx.id] || [] + template_suggestions[tx.id] = [cpSuggestion, ...existing] + } - return NextResponse.json({ suggestions, template_suggestions }) -} + return NextResponse.json({ suggestions, template_suggestions }) + }, +) diff --git a/app/api/vat/validate/__tests__/route.test.ts b/app/api/vat/validate/__tests__/route.test.ts index 3741713a..4715ef79 100644 --- a/app/api/vat/validate/__tests__/route.test.ts +++ b/app/api/vat/validate/__tests__/route.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' -// Mock Supabase -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +// Exercised through the real withRouteContext wrapper: mock its auth/company +// dependencies and inject the Supabase client via requireAuth. +const { supabase } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -17,30 +22,32 @@ vi.mock('@/lib/vat/vies-client', () => ({ validateVatNumber: (...args: unknown[]) => mockValidateVatNumber(...args), })) -import { createClient } from '@/lib/supabase/server' -import { POST } from '../route' +// Company is not a sandbox, so VIES calls proceed. +vi.mock('@/lib/sandbox/guard', () => ({ + guardSandbox: vi.fn().mockResolvedValue(null), +})) -const mockCreateClient = vi.mocked(createClient) +import { POST } from '../route' describe('POST /api/vat/validate', () => { beforeEach(() => { vi.clearAllMocks() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) }) it('returns 401 when not authenticated', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: null }, - error: { message: 'Not authenticated' }, + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }) - mockCreateClient.mockResolvedValue(supabase as never) const req = createMockRequest('/api/vat/validate', { method: 'POST', body: { vat_number: 'DE123456789' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) @@ -48,50 +55,30 @@ describe('POST /api/vat/validate', () => { }) it('returns 400 when vat_number is missing', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) - const req = createMockRequest('/api/vat/validate', { method: 'POST', body: {}, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns 400 when vat_number is too short', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) - const req = createMockRequest('/api/vat/validate', { method: 'POST', body: { vat_number: 'DE' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns valid result from VIES', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: true, name: 'Test GmbH', @@ -105,7 +92,7 @@ describe('POST /api/vat/validate', () => { body: { vat_number: 'DE123456789' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -119,12 +106,6 @@ describe('POST /api/vat/validate', () => { }) it('returns invalid result from VIES', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: false, country_code: 'DE', @@ -136,7 +117,7 @@ describe('POST /api/vat/validate', () => { body: { vat_number: 'DE000000000' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -144,12 +125,6 @@ describe('POST /api/vat/validate', () => { }) it('updates customer when customer_id provided and valid', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: true, name: 'Test GmbH', @@ -165,7 +140,7 @@ describe('POST /api/vat/validate', () => { }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -173,12 +148,6 @@ describe('POST /api/vat/validate', () => { }) it('does not update customer when validation fails', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: false, error: 'Invalid VAT number format', @@ -192,7 +161,7 @@ describe('POST /api/vat/validate', () => { }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -200,12 +169,6 @@ describe('POST /api/vat/validate', () => { }) it('handles VIES service error gracefully', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: false, error: 'Could not verify VAT number. Service temporarily unavailable.', @@ -216,7 +179,7 @@ describe('POST /api/vat/validate', () => { body: { vat_number: 'DE123456789' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) diff --git a/app/api/vat/validate/route.ts b/app/api/vat/validate/route.ts index 5ad1cf58..a49879fe 100644 --- a/app/api/vat/validate/route.ts +++ b/app/api/vat/validate/route.ts @@ -1,22 +1,11 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { ValidateVatNumberSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' -import { requireCompanyId } from '@/lib/company/context' import { guardSandbox } from '@/lib/sandbox/guard' -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const POST = withRouteContext('vat.validate', async (request, { supabase, companyId }) => { // VIES is a live external call to the EU Commission: block in the sandbox // so the demo can't generate background traffic against it. const blocked = await guardSandbox(supabase, companyId) @@ -42,4 +31,4 @@ export async function POST(request: Request) { } return NextResponse.json(validation) -} +}) diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx index 6a92e86d..1949c759 100644 --- a/components/bookkeeping/BookingTemplatePicker.tsx +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -17,7 +17,7 @@ import type { BookingTemplateLibrary, BookingTemplateCategory, EntityType } from import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' interface Props { - onApply: (lines: FormLine[], description: string) => void + onApply: (lines: FormLine[], description: string, category?: BookingTemplateCategory) => void entityType?: EntityType /** Prefill the "total amount" field when the caller already knows it (e.g. * booking from an underlag with a known total). The user can still edit it. */ @@ -114,7 +114,7 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou const lines = applyTemplate(selected.lines, totalAmount) // Fire-and-forget MRU bump so this template surfaces at the top next time. fetch(`/api/settings/booking-templates/${selected.id}/touch`, { method: 'POST' }).catch(() => {}) - onApply(lines, selected.name) + onApply(lines, selected.name, selected.category) setOpen(false) setSelectedId(null) setAmount('') @@ -135,14 +135,15 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou {/* Search + category filter */} -
-
- +
+
+ setSearch(e.target.value)} placeholder="Sök mall..." className="pl-9" + autoFocus />
diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 26efe9ea..aacadc6b 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -21,6 +21,7 @@ import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library' +import { sourceTypeForTemplateCategory } from '@/lib/bookkeeping/template-source-type' import { TemplateForm } from '@/components/settings/TemplateForm' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' @@ -37,7 +38,7 @@ import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCompany } from '@/contexts/CompanyContext' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' -import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary } from '@/types' +import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types' import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' const CURRENCIES: { value: Currency; label: string }[] = [ @@ -134,6 +135,23 @@ export default function JournalEntryForm({ initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A') + // The source_type the entry will be committed with. Seeded from the prop + // (undefined -> 'manual' for the standalone form). Applying a booking template + // whose category maps to a dedicated source type (e.g. VAT -> vat_settlement) + // flips this so the entry lands in that type's configured voucher series. + const [effectiveSourceType, setEffectiveSourceType] = useState( + sourceType ?? 'manual', + ) + // Cache of the company's series config so template routing can re-resolve the + // default series without re-fetching /api/settings. Populated by the settings + // effect below. + const seriesMapRef = useRef | null>(null) + const defaultSeriesRef = useRef('A') + // Mirror of effectiveSourceType for the settings-fetch callback: if a template + // routed the source type before /api/settings resolved, the late callback must + // re-apply the series for the ROUTED type, not the mount-time base (otherwise + // the entry submits as vat_settlement in the manual series). + const effectiveSourceTypeRef = useRef(sourceType ?? 'manual') const [nextVoucherNumber, setNextVoucherNumber] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the @@ -222,6 +240,15 @@ export default function JournalEntryForm({ setAccounts(data || []) } + // Resolve + set the default voucher series for a source type from the cached + // company config: prefer the per-source-type mapping, fall back to the legacy + // default_voucher_series, then to 'A'. Reads refs (stable), so it can run both + // on load and when template application changes the source type. + const applySeriesForSourceType = useCallback((st: JournalEntrySourceType) => { + const perSource = resolveDefaultSeriesForSource(seriesMapRef.current, st) + setVoucherSeries(perSource !== 'A' ? perSource : defaultSeriesRef.current || 'A') + }, []) + useEffect(() => { fetchPeriods() fetchAccounts() @@ -235,17 +262,14 @@ export default function JournalEntryForm({ fetch('/api/settings').then(r => r.json()).then(({ data }) => { if (!data) return setDimensionsEnabled(data.dimensions_enabled === true) + seriesMapRef.current = + (data.default_voucher_series_per_source_type as Record | null) ?? null + defaultSeriesRef.current = data.default_voucher_series || 'A' if (!embedded && !editEntryId) { - const effectiveSourceType = sourceType ?? 'manual' - const perSource = resolveDefaultSeriesForSource( - data as { default_voucher_series_per_source_type?: Record | null } | null, - effectiveSourceType, - ) - const fallback = data.default_voucher_series || 'A' - setVoucherSeries(perSource !== 'A' ? perSource : fallback) + applySeriesForSourceType(effectiveSourceTypeRef.current) } }).catch(() => {/* keep 'A' + hidden dimension affordances */}) - }, [embedded, sourceType, editEntryId]) + }, [embedded, sourceType, editEntryId, applySeriesForSourceType]) // Auto-select period when entry date changes useEffect(() => { @@ -650,15 +674,36 @@ export default function JournalEntryForm({ const selectedPeriodObj = periods.find((p) => p.id === selectedPeriod) const selectedPeriodLocked = !!(selectedPeriodObj?.locked_at || selectedPeriodObj?.is_closed) - const handleTemplateApply = (templateLines: FormLine[], templateDescription: string) => { + const handleTemplateApply = ( + templateLines: FormLine[], + templateDescription: string, + category?: BookingTemplateCategory, + ) => { setLines(templateLines) if (!description) setDescription(templateDescription) + // Route templates whose category maps to a dedicated source type (VAT -> + // vat_settlement) so the entry books into that type's configured series. + // Create mode only: embedded/edit keep their caller-provided source type. + // Non-mapped categories fall back to the form's base source type, which + // also reverts a prior VAT routing if the user swaps templates. + if (!embedded && !editEntryId) { + const base = sourceType ?? 'manual' + const routed = sourceTypeForTemplateCategory(category) ?? base + if (routed !== effectiveSourceType) { + setEffectiveSourceType(routed) + effectiveSourceTypeRef.current = routed + applySeriesForSourceType(routed) + } + } } // Wipe the form back to a blank entry. Mirrors the post-submit reset: it // clears the data the user typed (lines, description, note, attachments, - // currency) but keeps the contextual defaults (period, date, series) so the - // form is immediately ready for the next entry. + // currency) but keeps the contextual defaults (period, date) so the form is + // immediately ready for the next entry. Template-routed source type does NOT + // survive a clear: the next entry is hand-typed, and a sticky vat_settlement + // would tag it into the moms series and skip the manual-entry underlag + // tracking. Posted entries are immutable, so that mistag is storno-only. const handleClearAll = () => { setDescription('') setNotes('') @@ -668,6 +713,14 @@ export default function JournalEntryForm({ setEntryCurrency('SEK') setExchangeRate('') setForeignAmount('') + if (!embedded && !editEntryId) { + const base = sourceType ?? 'manual' + if (base !== effectiveSourceType) { + setEffectiveSourceType(base) + effectiveSourceTypeRef.current = base + applySeriesForSourceType(base) + } + } } const handleOpenCreateAccount = (lineIndex: number, prefill: string) => { @@ -810,7 +863,7 @@ export default function JournalEntryForm({ fiscal_period_id: selectedPeriod, entry_date: entryDate, description, - source_type: sourceType ?? 'manual', + source_type: effectiveSourceType, source_id: sourceId, voucher_series: voucherSeries || 'A', notes: notes || undefined, @@ -822,7 +875,7 @@ export default function JournalEntryForm({ }), }) return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } - }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes]) + }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postJournalEntry) diff --git a/components/bookkeeping/JournalEntryStatusBadge.tsx b/components/bookkeeping/JournalEntryStatusBadge.tsx index 8720f558..90b3d217 100644 --- a/components/bookkeeping/JournalEntryStatusBadge.tsx +++ b/components/bookkeeping/JournalEntryStatusBadge.tsx @@ -36,6 +36,7 @@ const SOURCE_TYPES = [ 'supplier_invoice_cash_payment', 'currency_revaluation', 'reminder_fee', + 'vat_settlement', ] as const /** diff --git a/components/reports/ReportExportMenu.tsx b/components/reports/ReportExportMenu.tsx index 76a954c0..2e9b153f 100644 --- a/components/reports/ReportExportMenu.tsx +++ b/components/reports/ReportExportMenu.tsx @@ -1,6 +1,6 @@ 'use client' -import { Download, FileSpreadsheet, FileText, Table } from 'lucide-react' +import { Download, FileCode, FileSpreadsheet, FileText, Table } from 'lucide-react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { @@ -10,8 +10,11 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -/** pdf/xlsx for reports; csv is additionally used by register exports. */ -export type ExportMenuFormat = 'pdf' | 'xlsx' | 'csv' +/** + * pdf/xlsx for reports; csv is additionally used by register exports; xml is the + * Skatteverket eSKD momsdeklaration file. + */ +export type ExportMenuFormat = 'pdf' | 'xlsx' | 'csv' | 'xml' export interface ReportExportItem { format: ExportMenuFormat @@ -56,6 +59,8 @@ export function ReportExportMenu({ ) : item.format === 'csv' ? ( + ) : item.format === 'xml' ? ( + ) : ( )} @@ -63,7 +68,9 @@ export function ReportExportMenu({ ? t('download_pdf') : item.format === 'csv' ? t('download_csv') - : t('download_excel')} + : item.format === 'xml' + ? t('download_xml') + : t('download_excel')} ))} diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 477b74eb..e13c3900 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileDown, Percent } from 'lucide-react' +import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { Skeleton } from '@/components/ui/skeleton' import { EmptyState } from '@/components/ui/empty-state' @@ -1052,11 +1052,12 @@ const SKATTEVERKET_MOMS_URL = * Manual-filing affordance shown directly under the calculated momsdeklaration. * The report is generated purely from the bookkeeping and never depends on the * Skatteverket connection, so every user (including core builds with the - * skatteverket extension disabled) can file manually. This card makes that - * explicit and offers the momsdeklaration as a PDF (in hela kronor) to read off - * while filling in the form at skatteverket.se. + * skatteverket extension disabled) can file manually. Two paths are offered: + * an eSKD XML file to upload directly under "Deklarera via fil" (the fast path), + * and a PDF (in hela kronor) to read off if the user would rather type the + * boxes into the form. A PDF cannot be uploaded to Skatteverket, only the XML. */ -function VatManualFilingCard({ pdfHref }: { pdfHref: string }) { +function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: string }) { return ( @@ -1065,15 +1066,21 @@ function VatManualFilingCard({ pdfHref }: { pdfHref: string }) {

Deklarationen ovan är klar. Du behöver inte vara ansluten till - Skatteverket för att lämna in den, du kan fylla i beloppen direkt hos - Skatteverket. + Skatteverket för att lämna in den.

- Ladda ner momsdeklarationen som PDF (belopp i hela kronor). Logga sedan - in på skatteverket.se med BankID, öppna Moms- och - arbetsgivardeklarationer och skriv in rutorna. + Enklast: ladda ner filen (XML), logga in på skatteverket.se med BankID, + öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil. + Ladda upp filen, granska och signera. Vill du hellre fylla i rutorna + för hand laddar du ner PDF:en och skriver av beloppen (i hela kronor).

+