Bug/gh issues fiz (#1103)

* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
This commit is contained in:
Mattsson
2026-07-21 23:00:15 +02:00
committed by GitHub
parent 702512437a
commit e11f70b347
467 changed files with 17569 additions and 2221 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* DELETE /api/settings/api-keys/[id]: Revoke an API key (soft delete)
@@ -18,7 +19,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.is('revoked_at', null)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
+2 -1
View File
@@ -8,6 +8,7 @@ import {
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { ApiKeyMode, ApiKeyScope } from '@/lib/auth/api-keys'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/** GET /api/settings/api-keys: list the company's API keys (key value never returned). */
export const GET = withRouteContext(
@@ -123,7 +124,7 @@ export const POST = withRouteContext(
log.error('api_key insert failed', error)
return errorResponseFromCode('API_KEY_CREATE_FAILED', log, {
requestId,
details: { reason: error.message },
details: { reason: getUserErrorMessage(error) },
})
}
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
@@ -46,7 +47,7 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
return NextResponse.json({ data })
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* POST /api/settings/booking-templates/[id]/touch
@@ -28,7 +29,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data: { success: true } })
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/settings/booking-templates/export
@@ -20,7 +21,7 @@ export const GET = withRouteContext(
.order('category')
.order('name')
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), {
headers: {
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ImportLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
@@ -70,7 +71,7 @@ export const POST = withRouteContext(
.insert(rows)
.select()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 })
},
+4 -3
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
// The GET scope below builds a PostgREST .or() filter by string interpolation.
// Guard every interpolated id against a strict UUID shape so a tainted value
@@ -87,7 +88,7 @@ export const GET = withRouteContext(
])
if (templatesRes.error) {
return NextResponse.json({ error: templatesRes.error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(templatesRes.error) }, { status: 500 })
}
// usage lookup failing is non-fatal: we just fall back to default ordering
const usageByTemplate = new Map<string, string>()
@@ -155,7 +156,7 @@ export const POST = withRouteContext(
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data }, { status: 201 })
},
@@ -186,7 +187,7 @@ export const DELETE = withRouteContext(
.update({ is_active: false })
.eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data: { success: true } })
},
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'counterparty_template.list',
@@ -11,7 +12,7 @@ export const GET = withRouteContext(
.eq('is_active', true)
.order('occurrence_count', { ascending: false })
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data })
},
@@ -35,7 +36,7 @@ export const DELETE = withRouteContext(
.eq('id', id)
.eq('company_id', companyId)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
return NextResponse.json({ data: { success: true } })
},
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, 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 storageBucket = {
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
list: vi.fn().mockResolvedValue({ data: [], error: null }),
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: () => ({
storage: { from: vi.fn().mockReturnValue(storageBucket) },
}),
}))
import { DELETE, POST } from '../route'
function makeFontRequest(
size = 8,
signature: number[] = [0x00, 0x01, 0x00, 0x00],
name = 'brand.ttf',
): Request {
const bytes = new Uint8Array(size)
bytes.set(signature.slice(0, size))
const formData = new FormData()
formData.append('file', new File([bytes], name, { type: 'font/ttf' }))
return new Request('http://localhost/api/settings/invoice-font', {
method: 'POST',
body: formData,
})
}
describe('POST /api/settings/invoice-font', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
expect(response.status).toBe(401)
})
it('returns 403 for a viewer without write permission', async () => {
requireWriteMock.mockResolvedValue({
ok: false,
response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }),
})
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
expect(response.status).toBe(403)
})
it('returns 400 for a file with an invalid font signature', async () => {
const response = await POST(
makeFontRequest(8, [0x25, 0x50, 0x44, 0x46]),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(400)
})
it('returns 400 when the font exceeds 5 MB', async () => {
const response = await POST(
makeFontRequest(5 * 1024 * 1024 + 1),
{ params: Promise.resolve({}) },
)
const { body } = await parseJsonResponse<{ error: string }>(response)
expect(response.status).toBe(400)
expect(body.error).toContain('5 MB')
})
it('returns 404 when company settings do not exist', async () => {
enqueue({ error: { code: 'PGRST116', message: 'No rows returned' } })
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
expect(response.status).toBe(404)
expect(storageBucket.remove).toHaveBeenCalled()
})
it('uploads a valid TTF and selects it for invoice PDFs', async () => {
enqueue({ error: null })
const response = await POST(makeFontRequest(), { params: Promise.resolve({}) })
const { body } = await parseJsonResponse<{
data: { invoice_font_family: string; invoice_custom_font_name: string }
}>(response)
expect(response.status).toBe(200)
expect(body.data).toMatchObject({
invoice_font_family: 'Custom',
invoice_custom_font_name: 'brand.ttf',
})
expect(storageBucket.upload).toHaveBeenCalledWith(
expect.stringMatching(/^company-1\/invoice-font-\d+\.ttf$/),
expect.any(Buffer),
expect.objectContaining({ contentType: 'font/ttf' }),
)
})
it('uploads a valid WOFF with its normalized content type', async () => {
enqueue({ error: null })
const response = await POST(
makeFontRequest(8, [0x77, 0x4f, 0x46, 0x46], 'brand.woff'),
{ params: Promise.resolve({}) },
)
expect(response.status).toBe(200)
expect(storageBucket.upload).toHaveBeenCalledWith(
expect.stringMatching(/^company-1\/invoice-font-\d+\.woff$/),
expect.any(Buffer),
expect.objectContaining({ contentType: 'font/woff' }),
)
})
})
describe('DELETE /api/settings/invoice-font', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null })
requireWriteMock.mockResolvedValue({ ok: true })
})
it('removes the custom font and restores Helvetica', async () => {
enqueue({ error: null })
const response = await DELETE(
new Request('http://localhost/api/settings/invoice-font', { method: 'DELETE' }),
{ params: Promise.resolve({}) },
)
const { body } = await parseJsonResponse<{
data: { invoice_font_family: string; invoice_custom_font_path: null }
}>(response)
expect(response.status).toBe(200)
expect(body.data).toEqual({
invoice_font_family: 'Helvetica',
invoice_custom_font_path: null,
invoice_custom_font_name: null,
})
})
})
+151
View File
@@ -0,0 +1,151 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createServiceClient } from '@/lib/supabase/server'
import {
INVOICE_FONT_UPLOAD_MAX_BYTES,
INVOICE_FONT_UPLOAD_MAX_MB,
} from '@/lib/invoices/branding-constants'
import {
detectInvoiceFontFileFormat,
getInvoiceFontContentType,
} from '@/lib/invoices/font-files'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
const ALLOWED_EXTENSIONS = new Set(['ttf', 'woff'])
function safeDisplayName(fileName: string, extension: string): string {
const baseName = fileName.split(/[\\/]/).pop() ?? `invoice-font.${extension}`
const cleaned = baseName.replace(/[\u0000-\u001f\u007f]/g, '').trim()
return (cleaned || `invoice-font.${extension}`).slice(0, 200)
}
export const POST = withRouteContext(
'settings.invoice-font.upload',
async (request, { supabase, companyId }) => {
const formData = await request.formData()
const file = formData.get('file')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Ingen typsnittsfil angiven.' }, { status: 400 })
}
const extension = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!ALLOWED_EXTENSIONS.has(extension)) {
return NextResponse.json(
{ error: 'Otillåten filtyp. Tillåtna format är TTF och WOFF.' },
{ status: 400 },
)
}
if (file.size > INVOICE_FONT_UPLOAD_MAX_BYTES) {
return NextResponse.json(
{ error: `Filen är för stor (max ${INVOICE_FONT_UPLOAD_MAX_MB} MB).` },
{ status: 400 },
)
}
const bytes = Buffer.from(await file.arrayBuffer())
const format = detectInvoiceFontFileFormat(bytes)
if (!format || format !== extension) {
return NextResponse.json(
{ error: 'Filen är inte ett giltigt TTF- eller WOFF-typsnitt.' },
{ status: 400 },
)
}
const fileName = `invoice-font-${Date.now()}.${format}`
const storagePath = `${companyId}/${fileName}`
const serviceClient = createServiceClient()
const bucket = serviceClient.storage.from('invoice-fonts')
const { error: uploadError } = await bucket.upload(storagePath, bytes, {
contentType: getInvoiceFontContentType(format),
upsert: false,
})
if (uploadError) {
return NextResponse.json(
{ error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` },
{ status: 500 },
)
}
const displayName = safeDisplayName(file.name, format)
const { error: updateError } = await supabase
.from('company_settings')
.update({
invoice_font_family: 'Custom',
invoice_custom_font_path: storagePath,
invoice_custom_font_name: displayName,
})
.eq('company_id', companyId)
.select('company_id')
.single()
if (updateError) {
await bucket.remove([storagePath])
if (updateError.code === 'PGRST116') {
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
}
return NextResponse.json(
{ error: getUserErrorMessage(updateError) },
{ status: 500 },
)
}
const { data: existing } = await bucket.list(companyId)
const obsoletePaths = (existing ?? [])
.filter((stored) => stored.name !== fileName)
.map((stored) => `${companyId}/${stored.name}`)
if (obsoletePaths.length > 0) await bucket.remove(obsoletePaths)
return NextResponse.json({
data: {
invoice_font_family: 'Custom',
invoice_custom_font_path: storagePath,
invoice_custom_font_name: displayName,
},
})
},
{ requireWrite: true },
)
export const DELETE = withRouteContext(
'settings.invoice-font.delete',
async (_request, { supabase, companyId }) => {
const { error: updateError } = await supabase
.from('company_settings')
.update({
invoice_font_family: 'Helvetica',
invoice_custom_font_path: null,
invoice_custom_font_name: null,
})
.eq('company_id', companyId)
.select('company_id')
.single()
if (updateError) {
if (updateError.code === 'PGRST116') {
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
}
return NextResponse.json(
{ error: getUserErrorMessage(updateError) },
{ status: 500 },
)
}
const serviceClient = createServiceClient()
const bucket = serviceClient.storage.from('invoice-fonts')
const { data: existing } = await bucket.list(companyId)
if (existing && existing.length > 0) {
await bucket.remove(existing.map((stored) => `${companyId}/${stored.name}`))
}
return NextResponse.json({
data: {
invoice_font_family: 'Helvetica',
invoice_custom_font_path: null,
invoice_custom_font_name: null,
},
})
},
{ requireWrite: true },
)
+39 -2
View File
@@ -34,9 +34,13 @@ vi.mock('@/lib/supabase/server', () => ({
import { POST } from '../route'
function makeFormRequest(): Request {
function makeFormRequest(
size = 3,
type = 'image/png',
name = 'logo.png',
): Request {
const fd = new FormData()
fd.append('file', new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' }))
fd.append('file', new File([new Uint8Array(size)], name, { type }))
return new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd })
}
@@ -73,6 +77,39 @@ describe('POST /api/settings/logo', () => {
expect(status).toBe(403)
})
it('returns 400 for an unsupported file type', async () => {
const response = await POST(
makeFormRequest(3, 'application/pdf', 'logo.pdf'),
{ params: Promise.resolve({}) },
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
})
it('returns 400 when the logo exceeds 10 MB', async () => {
const response = await POST(
makeFormRequest(10 * 1024 * 1024 + 1),
{ params: Promise.resolve({}) },
)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('10 MB')
})
it('accepts a logo larger than the previous 2 MB limit', async () => {
enqueue({ error: null }) // company_settings update
const response = await POST(
makeFormRequest(2 * 1024 * 1024 + 1),
{ params: Promise.resolve({}) },
)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('uploads the logo and returns its public url on the happy path', async () => {
enqueue({ error: null }) // company_settings update
+5 -4
View File
@@ -1,8 +1,9 @@
import { NextResponse } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { LOGO_UPLOAD_MAX_BYTES, LOGO_UPLOAD_MAX_MB } from '@/lib/invoices/branding-constants'
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
export const POST = withRouteContext(
@@ -19,8 +20,8 @@ export const POST = withRouteContext(
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
}
if (file.size > MAX_SIZE) {
return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
if (file.size > LOGO_UPLOAD_MAX_BYTES) {
return NextResponse.json({ error: `Filen är för stor (max ${LOGO_UPLOAD_MAX_MB} MB).` }, { status: 400 })
}
const buffer = Buffer.from(await file.arrayBuffer())
@@ -53,7 +54,7 @@ export const POST = withRouteContext(
})
if (uploadError) {
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
return NextResponse.json({ error: `Uppladdning misslyckades: ${getUserErrorMessage(uploadError)}` }, { status: 500 })
}
const { data: urlData } = serviceClient.storage
+2 -1
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* DELETE /api/settings/oauth-clients/[id]: revoke a redirect URI
@@ -26,7 +27,7 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
.select('id, redirect_uri, client_name')
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (!rows || rows.length === 0) {
+3 -2
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { z } from 'zod'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
/**
* GET /api/settings/oauth-clients: list the current user's registered
@@ -38,7 +39,7 @@ export const GET = withRouteContext(
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
@@ -80,7 +81,7 @@ export const POST = withRouteContext(
{ status: 409 }
)
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ data })
+4 -3
View File
@@ -9,6 +9,7 @@ import {
} from '@/lib/tax/deadline-generator'
import { validateBody } from '@/lib/api/validate'
import { UpdateSettingsSchema } from '@/lib/api/schemas'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
export const GET = withRouteContext(
'settings.get',
@@ -20,7 +21,7 @@ export const GET = withRouteContext(
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Fall back to companies.entity_type if company_settings.entity_type is null
@@ -99,7 +100,7 @@ export const PUT = withRouteContext(
// Fail closed: a failed check must not let the basis change through
// and orphan open vacation-ledger rows.
if (openRowsError) {
return NextResponse.json({ error: openRowsError.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(openRowsError) }, { status: 500 })
}
if ((openRows ?? 0) > 0) {
return NextResponse.json(
@@ -182,7 +183,7 @@ export const PUT = withRouteContext(
if (error.code === 'PGRST116') {
return NextResponse.json({ error: 'Inställningarna hittades inte.' }, { status: 404 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
// Regenerate when the save touches tax-relevant fields: the statutory