Fix/attributes config (#926)

* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

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

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

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

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

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

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

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

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

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

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

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

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

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

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

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

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

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

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

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

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-08 09:54:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 63e05c4eec
commit abe9ac9d8c
204 changed files with 9841 additions and 6362 deletions
+7
View File
@@ -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
+4
View File
@@ -37,3 +37,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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<string, unknown>
@@ -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 }
}
+4 -9
View File
@@ -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
+3 -3
View File
@@ -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.
+3 -4
View File
@@ -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.
+10 -4
View File
@@ -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)
@@ -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<string, unknown> | null
const model = data.extraction_model as string | null
const extractedAt = data.extracted_at as string | null
const extractedData = data.extracted_data as Record<string, unknown> | 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,
},
})
}
)
+63 -86
View File
@@ -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 }
)
@@ -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')
})
})
+18 -32
View File
@@ -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 }
)
@@ -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' }),
)
})
})
+63 -87
View File
@@ -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 })
}
)
+12 -5
View File
@@ -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)
+7 -7
View File
@@ -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.
@@ -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)
})
})
@@ -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 },
)
@@ -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 })
})
})
@@ -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 },
)
@@ -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 })
},
)
@@ -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')
})
})
+51 -77
View File
@@ -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 },
)
@@ -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)
})
})
+75 -88
View File
@@ -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 },
)
@@ -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)
})
})
+95 -139
View File
@@ -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 },
)
+30 -40
View File
@@ -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,
})
},
)
@@ -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' }),
+7 -20
View File
@@ -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 },
)
@@ -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' })
})
+7 -22
View File
@@ -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 },
)
+6 -16
View File
@@ -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 }
)
}
}
},
)
+3 -14
View File
@@ -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 }
)
}
}
})
@@ -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<string, string[]> } }>(res)
expect(status).toBe(200)
expect(body.data.accountOverrides.some_kpi).toEqual(['3001'])
})
})
+48 -61
View File
@@ -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<KPIPreferences>) ?? {})
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<KPIPreferences>
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<KPIPreferences>
// 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 }
)
@@ -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, string>): string {
const url = new URL('http://localhost/api/mcp-oauth/authorize')
@@ -31,9 +31,18 @@ function buildAuthorizeUrl(params: Record<string, string>): 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<typeof vi.fn>).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)
})
})
+36
View File
@@ -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<Response | null> {
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))) {
@@ -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',
+49 -61
View File
@@ -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 },
)
@@ -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' } })
+70 -82
View File
@@ -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 },
)
+159 -168
View File
@@ -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<typeof PatchSchema>
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<string, unknown>) ?? {}
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<typeof PatchSchema>
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<string, unknown>) ?? {}
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<string, unknown>) ?? {}
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<string, unknown>) ?? {}
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 },
)
@@ -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',
+58 -68
View File
@@ -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 },
)
+25 -31
View File
@@ -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 })
},
)
@@ -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',
)
})
})
+22 -32
View File
@@ -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 },
)
@@ -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,
})
})
})
@@ -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 },
)
@@ -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 }),
)
})
})
+65 -75
View File
@@ -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 },
)
+41 -48
View File
@@ -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 })
},
)
@@ -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')
})
})
+15 -25
View File
@@ -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 },
)
@@ -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 })
},
)
@@ -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'
@@ -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,
},
})
}
})
+3 -14
View File
@@ -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,
},
})
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
@@ -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],
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -10
View File
@@ -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 })
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
@@ -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,
+3 -13
View File
@@ -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()
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -14
View File
@@ -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 })
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -10
View File
@@ -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 })
}
})
+3 -10
View File
@@ -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
+3 -13
View File
@@ -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 })
}
}
})
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -10
View File
@@ -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 })
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -14
View File
@@ -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,
},
})
}
})
@@ -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'
)
@@ -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,
},
})
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
@@ -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<typeof vi.fn> }
interface SupabaseShape {
from: ReturnType<typeof vi.fn>
}
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(
@@ -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,
},
})
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -13
View File
@@ -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 }
)
}
}
})
+3 -10
View File
@@ -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 })
}
}
})
@@ -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('<eSKDUpload Version="6.0">')
expect(body).toContain('<OrgNr>556677-8899</OrgNr>')
expect(body).toContain('<Period>202609</Period>')
expect(body).toContain('<MomsBetala>21800</MomsBetala>')
expect(calculateVatDeclaration).toHaveBeenCalledOnce()
})
})
@@ -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}"`,
},
})
},
)
@@ -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<string, unknown>) {
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),
)
})
})
@@ -179,4 +179,5 @@ export const POST = withRouteContext(
})
}
},
{ requireWrite: true },
)
@@ -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<typeof vi.fn>
}
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',

Some files were not shown because too many files have changed in this diff Show More