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
@@ -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,
})
},
)