Fix/invoice delivery and payment accounts (#1116)
* fix: reconcile annual reports with final closing entries * test: cover annual report depreciation and VAT balances * Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch * fix: show exact invoice delivery details * fix: use currency account in invoice emails * fix: address invoice delivery review feedback * fix: harden invoice delivery and payment accounts * test: assert RLS-denied zero-row updates * fix: close remaining invoice compliance gaps * fix: harden invoice archive authorization * fix: close invoice delivery review findings * fix: verify delivery finalization results * fix: cap combined invoice email recipients * fix: close final invoice compliance findings * fix: prevent stale payment account saves * test: prove invoice delivery isolation * fix: close invoice privacy review findings * test: normalize delivery retention dates
This commit is contained in:
@@ -1,9 +1,28 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const { mockLogInfo, mockLogWarn, mockLogError } = vi.hoisted(() => ({
|
||||
mockLogInfo: vi.fn(),
|
||||
mockLogWarn: vi.fn(),
|
||||
mockLogError: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: mockLogInfo,
|
||||
warn: mockLogWarn,
|
||||
error: mockLogError,
|
||||
child: vi.fn().mockReturnThis(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const {
|
||||
supabase: archiveSupabase,
|
||||
enqueue: enqueueArchive,
|
||||
reset: resetArchive,
|
||||
} = createQueuedMockSupabase()
|
||||
const createServiceClientMock = vi.fn(() => archiveSupabase)
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
@@ -20,6 +39,10 @@ vi.mock('@/lib/reports/full-archive-export', () => ({
|
||||
estimateArchiveSize: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => createServiceClientMock(),
|
||||
}))
|
||||
|
||||
import {
|
||||
generateFullArchive,
|
||||
estimateArchiveSize,
|
||||
@@ -43,7 +66,11 @@ function unauthed() {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
resetArchive()
|
||||
authed()
|
||||
enqueue({ data: { role: 'admin' }, error: null })
|
||||
enqueueArchive({ data: { role: 'admin' }, error: null })
|
||||
})
|
||||
|
||||
describe('GET /api/reports/full-archive', () => {
|
||||
@@ -56,6 +83,58 @@ describe('GET /api/reports/full-archive', () => {
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 403 for a member without archive-audit access', async () => {
|
||||
reset()
|
||||
enqueue({ data: { role: 'member' }, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await GET(createMockRequest('/api/reports/full-archive')),
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('FORBIDDEN')
|
||||
expect(mockLogWarn).toHaveBeenCalledWith('full archive access denied', {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
role: 'member',
|
||||
})
|
||||
expect(createServiceClientMock).not.toHaveBeenCalled()
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the verified user is not a member of the selected company', async () => {
|
||||
reset()
|
||||
resetArchive()
|
||||
enqueue({ data: { role: 'admin' }, error: null })
|
||||
enqueueArchive({ data: null, error: null })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await GET(createMockRequest('/api/reports/full-archive')),
|
||||
)
|
||||
|
||||
expect(status).toBe(403)
|
||||
expect(body.error.code).toBe('FORBIDDEN')
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 500 when the service-role membership verification fails', async () => {
|
||||
reset()
|
||||
resetArchive()
|
||||
enqueue({ data: { role: 'admin' }, error: null })
|
||||
enqueueArchive({ data: null, error: new Error('database unavailable') })
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await GET(createMockRequest('/api/reports/full-archive')),
|
||||
)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns estimate-only response when ?estimate=1', async () => {
|
||||
mockEstimate.mockResolvedValue({
|
||||
total_bytes: 10_000_000,
|
||||
@@ -63,23 +142,24 @@ describe('GET /api/reports/full-archive', () => {
|
||||
document_count: 7,
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest('/api/reports/full-archive', {
|
||||
searchParams: { estimate: '1', scope: 'all' },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: {
|
||||
total_bytes: number
|
||||
size_limit_bytes: number
|
||||
within_limit: boolean
|
||||
}
|
||||
}>(
|
||||
await GET(
|
||||
createMockRequest('/api/reports/full-archive', {
|
||||
searchParams: { estimate: '1', scope: 'all' },
|
||||
})
|
||||
)
|
||||
)
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.total_bytes).toBe(10_000_000)
|
||||
expect(body.data.within_limit).toBe(true)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockEstimate).toHaveBeenCalledWith(archiveSupabase, 'company-1', 'all', undefined)
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -102,6 +182,7 @@ describe('GET /api/reports/full-archive', () => {
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(413)
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(body.error).toBe('archive_too_large')
|
||||
expect(body.size_bytes).toBe(200 * 1024 * 1024)
|
||||
expect(body.size_limit_bytes).toBe(80 * 1024 * 1024)
|
||||
@@ -123,7 +204,12 @@ describe('GET /api/reports/full-archive', () => {
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockLogInfo).toHaveBeenCalledWith('full archive generated', expect.objectContaining({
|
||||
filename: expect.stringMatching(/^arkiv_full_company-1_\d{8}\.zip$/),
|
||||
sizeBytes: 1024,
|
||||
}))
|
||||
expect(response.headers.get('Content-Type')).toBe('application/zip')
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockGenerate).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
@@ -172,15 +258,15 @@ describe('GET /api/reports/full-archive', () => {
|
||||
})
|
||||
|
||||
it('returns 400 when scope=period without period_id', async () => {
|
||||
const { status, body } = await parseJsonResponse(
|
||||
await GET(
|
||||
createMockRequest('/api/reports/full-archive', {
|
||||
searchParams: { scope: 'period' },
|
||||
})
|
||||
)
|
||||
const response = await GET(
|
||||
createMockRequest('/api/reports/full-archive', {
|
||||
searchParams: { scope: 'period' },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body).toEqual({ error: 'period_id is required when scope=period' })
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
expect(mockGenerate).not.toHaveBeenCalled()
|
||||
expect(mockEstimate).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -193,14 +279,14 @@ describe('GET /api/reports/full-archive', () => {
|
||||
})
|
||||
mockGenerate.mockRejectedValue(new Error('Fiscal period not found'))
|
||||
|
||||
const { status, body } = await parseJsonResponse(
|
||||
await GET(
|
||||
createMockRequest('/api/reports/full-archive', {
|
||||
searchParams: { scope: 'period', period_id: 'nope' },
|
||||
})
|
||||
)
|
||||
const response = await GET(
|
||||
createMockRequest('/api/reports/full-archive', {
|
||||
searchParams: { scope: 'period', period_id: 'nope' },
|
||||
}),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
expect(status).toBe(404)
|
||||
expect(body).toEqual({ error: 'Något gick fel. Försök igen.' })
|
||||
expect(response.headers.get('Cache-Control')).toBe('private, no-store')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,13 +6,22 @@ import {
|
||||
} from '@/lib/reports/full-archive-export'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const maxDuration = 300
|
||||
|
||||
const SIZE_LIMIT_BYTES = 80 * 1024 * 1024
|
||||
const PRIVATE_NO_STORE_HEADERS = { 'Cache-Control': 'private, no-store' }
|
||||
|
||||
export const GET = withRouteContext('report.full_archive', async (request, { supabase, companyId }) => {
|
||||
function privateNoStore(response: NextResponse): NextResponse {
|
||||
response.headers.set('Cache-Control', 'private, no-store')
|
||||
return response
|
||||
}
|
||||
|
||||
export const GET = withRouteContext('report.full_archive', async (request, ctx) => {
|
||||
const { supabase, companyId, user, log, requestId } = ctx
|
||||
const { searchParams } = new URL(request.url)
|
||||
const scopeParam = searchParams.get('scope')
|
||||
const periodId = searchParams.get('period_id')
|
||||
@@ -26,26 +35,85 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup
|
||||
if (scope === 'period' && !periodId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'period_id is required when scope=period' },
|
||||
{ status: 400 }
|
||||
{ status: 400, headers: PRIVATE_NO_STORE_HEADERS }
|
||||
)
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (membershipError) {
|
||||
log.error('failed to authorize full archive export', membershipError, {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
|
||||
}
|
||||
if (!membership || !['owner', 'admin'].includes(membership.role)) {
|
||||
log.warn('full archive access denied', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
role: membership?.role ?? null,
|
||||
})
|
||||
return privateNoStore(errorResponseFromCode('FORBIDDEN', log, {
|
||||
requestId,
|
||||
details: { required_roles: ['owner', 'admin'] },
|
||||
}))
|
||||
}
|
||||
|
||||
// The complete statutory archive includes exact delivery evidence from all
|
||||
// company senders. Only this owner/admin server path receives a service-role
|
||||
// client; normal delivery history remains data-minimized by RLS. companyId
|
||||
// comes from withRouteContext's authenticated active-company resolution,
|
||||
// never from a request parameter, and is verified again below.
|
||||
const archiveClient = createServiceClient()
|
||||
const { data: verifiedMembership, error: verificationError } = await archiveClient
|
||||
.from('company_members')
|
||||
.select('role')
|
||||
.eq('company_id', companyId)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
if (verificationError) {
|
||||
log.error('failed to verify full archive export with service role', verificationError, {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
})
|
||||
return privateNoStore(errorResponseFromCode('INTERNAL_ERROR', log, { requestId }))
|
||||
}
|
||||
if (!verifiedMembership || !['owner', 'admin'].includes(verifiedMembership.role)) {
|
||||
log.warn('full archive service-role verification denied', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
role: verifiedMembership?.role ?? null,
|
||||
})
|
||||
return privateNoStore(errorResponseFromCode('FORBIDDEN', log, {
|
||||
requestId,
|
||||
details: { required_roles: ['owner', 'admin'] },
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
const estimate = await estimateArchiveSize(
|
||||
supabase,
|
||||
archiveClient,
|
||||
companyId,
|
||||
scope,
|
||||
scope === 'period' ? periodId! : undefined
|
||||
)
|
||||
|
||||
if (estimateOnly) {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...estimate,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
within_limit: estimate.total_bytes <= SIZE_LIMIT_BYTES,
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
...estimate,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
within_limit: estimate.total_bytes <= SIZE_LIMIT_BYTES,
|
||||
},
|
||||
},
|
||||
})
|
||||
{ headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
|
||||
if (includeDocuments && estimate.total_bytes > SIZE_LIMIT_BYTES) {
|
||||
@@ -55,12 +123,12 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup
|
||||
size_bytes: estimate.total_bytes,
|
||||
size_limit_bytes: SIZE_LIMIT_BYTES,
|
||||
},
|
||||
{ status: 413 }
|
||||
{ status: 413, headers: PRIVATE_NO_STORE_HEADERS }
|
||||
)
|
||||
}
|
||||
|
||||
const zipBuffer = await generateFullArchive(
|
||||
supabase,
|
||||
archiveClient,
|
||||
companyId,
|
||||
scope === 'period'
|
||||
? { scope: 'period', period_id: periodId!, include_documents: includeDocuments }
|
||||
@@ -72,17 +140,36 @@ export const GET = withRouteContext('report.full_archive', async (request, { sup
|
||||
? `arkiv_${periodId}.zip`
|
||||
: `arkiv_full_${companyId}_${formatDateStamp(new Date())}.zip`
|
||||
|
||||
log.info('full archive generated', {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
scope,
|
||||
includeDocuments,
|
||||
filename,
|
||||
sizeBytes: zipBuffer.byteLength,
|
||||
})
|
||||
|
||||
return new NextResponse(zipBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'private, no-store',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('full archive generation failed', err as Error, {
|
||||
userId: user.id,
|
||||
companyId,
|
||||
scope,
|
||||
includeDocuments,
|
||||
})
|
||||
const message = err instanceof Error ? err.message : 'Failed to generate archive'
|
||||
const status = message.includes('not found') ? 404 : 500
|
||||
return NextResponse.json({ error: getErrorMessage(err) }, { status })
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(err) },
|
||||
{ status, headers: PRIVATE_NO_STORE_HEADERS },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user