Bug/year end failure (#575)

* feat: implement findNextPeriod function and integrate into year-end closing logic

* feat: add integrity check for PDF documents and enhance user feedback for corrupt files

* Refactor year-end service and period creation logic for improved UTC handling and error messaging

- Update `validateYearEndReadiness` to assert on stable warning messages without interpolating period names.
- Modify `createNextPeriod` to ensure date calculations are performed in UTC, preventing DST-related issues.
- Enhance error handling in `validateYearEndReadiness` and `executeYearEndClosing` to avoid exposing database details.
- Introduce structured error messages for year-end processes in `structured-errors.ts`.
- Add tests for document integrity checks, ensuring proper authentication and error handling.
- Implement GUC checks in document versioning to prevent unauthorized modifications and ensure company membership.
- Update migration scripts to reflect changes in document immutability enforcement.

* fix: add comment to clarify GUC behavior in document supersession logic
This commit is contained in:
Mattsson
2026-05-27 14:19:30 +02:00
committed by GitHub
parent a2a556d837
commit 8979f6eda3
14 changed files with 1214 additions and 70 deletions
@@ -171,6 +171,9 @@ export default function YearEndPage() {
})
const body = await res.json()
if (!res.ok) {
// body.error.message is the localized Swedish message picked by
// the structured-error registry. Do NOT interpolate raw details
// here — they can contain DB-sourced strings (V2.3 finding).
setExecuteError(body?.error?.message ?? 'Bokslutet kunde inte verkställas')
return
}
@@ -27,10 +27,7 @@ export const GET = withRouteContext(
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, {
requestId,
details: { reason: message },
})
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, { requestId })
}
},
)
@@ -49,17 +46,18 @@ export const POST = withRouteContext(
} catch (err) {
opLog.error('year-end execution failed', err as Error)
const message = err instanceof Error ? err.message : ''
// The downstream errors below are matched on stable English keywords
// emitted by year-end-service. Do NOT include the raw message in
// details — it may contain DB-sourced names; UI surfacing relies on
// the structured message_sv / message_en pair.
if (/Next fiscal period already has opening balance/i.test(message)) {
return errorResponseFromCode('YEAR_END_NEXT_PERIOD_HAS_IB', opLog, { requestId })
}
if (/prior.*open/i.test(message)) {
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, {
requestId,
details: { reason: message },
})
return errorResponseFromCode('YEAR_END_PRIOR_PERIOD_OPEN', opLog, { requestId })
}
if (/not balanced|unbalanced/i.test(message)) {
return errorResponseFromCode('YEAR_END_UNBALANCED_TRIAL', opLog, {
requestId,
details: { reason: message },
})
return errorResponseFromCode('YEAR_END_UNBALANCED_TRIAL', opLog, { requestId })
}
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
@@ -67,10 +65,7 @@ export const POST = withRouteContext(
// Fall through bookkeeping/Zod/etc to errorResponse, but cap to YEAR_END_FAILED.
const fallback = errorResponse(err, opLog, { requestId })
if (fallback.status === 500) {
return errorResponseFromCode('YEAR_END_FAILED', opLog, {
requestId,
details: { reason: message },
})
return errorResponseFromCode('YEAR_END_FAILED', opLog, { requestId })
}
return fallback
}
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
parseJsonResponse,
createMockRouteParams,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
validateDocumentMagicBytes: vi.fn(),
}))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateDocumentMagicBytes } from '@/lib/core/documents/document-service'
import { NextResponse } from 'next/server'
// v4 UUIDs (variant 'a' / 'b' in the 4th group) so Zod's stricter validators
// accept them — the looser `2222...` style fails on the variant check.
const mockUser = { id: '11111111-1111-4111-a111-111111111111' }
const validDocId = '22222222-2222-4222-a222-222222222222'
const companyId = '33333333-3333-4333-a333-333333333333'
beforeEach(() => {
vi.clearAllMocks()
reset()
vi.mocked(requireAuth).mockResolvedValue({
user: mockUser as never,
supabase: mockSupabase as never,
error: null,
})
})
function req() {
return new Request(`http://localhost/api/documents/${validDocId}/integrity`)
}
describe('GET /api/documents/[id]/integrity', () => {
it('returns 401 when the user is not authenticated', async () => {
vi.mocked(requireAuth).mockResolvedValue({
user: null as never,
supabase: mockSupabase as never,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(401)
})
it('returns 400 when id is not a UUID', async () => {
const res = await GET(req(), createMockRouteParams({ id: 'not-a-uuid' }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(400)
expect(body.error).toMatch(/invalid/i)
})
it('returns 404 when document is not found or is superseded', async () => {
enqueue({ data: null, error: null })
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
const { status } = await parseJsonResponse(res)
expect(status).toBe(404)
})
it('returns 404 when caller is not a member of the document company', async () => {
enqueue({
data: {
id: validDocId,
company_id: companyId,
mime_type: 'application/pdf',
storage_path: 'documents/foo/bar.pdf',
},
error: null,
})
enqueue({ data: null, error: null }) // membership check returns null
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(404)
// The endpoint deliberately returns the same 404 as missing-document so
// the response cannot be used to enumerate documents across companies.
expect(body.error).toBe('Document not found')
})
it('returns { valid: true } and skips download when mime_type is null', async () => {
enqueue({
data: {
id: validDocId,
company_id: companyId,
mime_type: null,
storage_path: 'documents/foo/bar.pdf',
},
error: null,
})
enqueue({ data: { company_id: companyId }, error: null }) // membership
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
const { status, body } = await parseJsonResponse<{ data: { valid: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.valid).toBe(true)
expect(mockSupabase.storage.from).not.toHaveBeenCalled()
})
it('returns { valid: true } when the bytes match the declared mime type', async () => {
enqueue({
data: {
id: validDocId,
company_id: companyId,
mime_type: 'application/pdf',
storage_path: 'documents/foo/bar.pdf',
},
error: null,
})
enqueue({ data: { company_id: companyId }, error: null })
const pdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37])
const blob = new Blob([pdfBytes], { type: 'application/pdf' })
mockSupabase.storage.from.mockReturnValue({
download: vi.fn().mockResolvedValue({ data: blob, error: null }),
} as never)
vi.mocked(validateDocumentMagicBytes).mockReturnValue(null)
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
const { status, body } = await parseJsonResponse<{ data: { valid: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.valid).toBe(true)
expect(Object.keys(body.data)).toEqual(['valid'])
})
it('returns { valid: false } without leaking the reason text', async () => {
enqueue({
data: {
id: validDocId,
company_id: companyId,
mime_type: 'application/pdf',
storage_path: 'documents/foo/bar.pdf',
},
error: null,
})
enqueue({ data: { company_id: companyId }, error: null })
const blob = new Blob([new Uint8Array([0x00, 0x00, 0x00, 0x00])])
mockSupabase.storage.from.mockReturnValue({
download: vi.fn().mockResolvedValue({ data: blob, error: null }),
} as never)
vi.mocked(validateDocumentMagicBytes).mockReturnValue('Internal /storage/v1/object error 42')
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(res)
expect(status).toBe(200)
expect(body.data.valid).toBe(false)
// The whole point of the V1.2.5 / Art 25(2) hardening — internal text
// never appears in the response.
expect(body.data.reason).toBeUndefined()
expect(JSON.stringify(body)).not.toContain('storage/v1/object')
})
it('returns a generic 500 without leaking the underlying storage error', async () => {
enqueue({
data: {
id: validDocId,
company_id: companyId,
mime_type: 'application/pdf',
storage_path: 'documents/foo/bar.pdf',
},
error: null,
})
enqueue({ data: { company_id: companyId }, error: null })
mockSupabase.storage.from.mockReturnValue({
download: vi.fn().mockResolvedValue({
data: null,
error: { message: 'storage internal: bucket=documents object=secret/path.pdf' },
}),
} as never)
const res = await GET(req(), createMockRouteParams({ id: validDocId }))
const { status, body } = await parseJsonResponse<{ error: string }>(res)
expect(status).toBe(500)
expect(body.error).toBe('Integrity check unavailable')
expect(JSON.stringify(body)).not.toContain('secret/path.pdf')
})
it('does not import the service-role supabase client', async () => {
// The download must go through the per-request user-scoped client so
// RLS on storage.objects can act as defense-in-depth. Statically
// verifying the source is the cleanest check — runtime mocking of the
// service-client export would not catch a future regression where
// someone added an import but conditionally used it.
const fs = await import('node:fs/promises')
const path = await import('node:path')
const routePath = path.resolve(__dirname, '../route.ts')
const source = await fs.readFile(routePath, 'utf8')
expect(source).not.toMatch(/createServiceClient(\b|NoCookies)/)
})
it('filters the document lookup to the current version', async () => {
// The route's first `from('document_attachments')` chain must include
// `.eq('is_current_version', true)` so superseded versions cannot have
// their bytes probed via this surface. Source-level check rather than
// runtime spying — the proxy-based queued mock collapses every chained
// method into the same handler, so introspecting individual .eq calls
// is not feasible without rebuilding the mock.
const fs = await import('node:fs/promises')
const path = await import('node:path')
const routePath = path.resolve(__dirname, '../route.ts')
const source = await fs.readFile(routePath, 'utf8')
expect(source).toMatch(/\.eq\(['"]is_current_version['"],\s*true\)/)
})
})
+109
View File
@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth/require-auth'
import { validateDocumentMagicBytes } from '@/lib/core/documents/document-service'
import { createLogger } from '@/lib/logger'
const log = createLogger('documents.integrity')
const ParamsSchema = z.object({ id: z.string().uuid() })
/**
* GET /api/documents/:id/integrity
*
* Probes the actual stored bytes against the declared MIME type. Used by the
* Bilagor modal to surface a clear "this file is corrupt — please re-upload"
* warning instead of relying on the browser's PDF viewer error UI, which
* only fires after the user has already tried to view the file.
*
* Some legacy MCP uploads landed with non-PDF bytes under
* `mime_type = 'application/pdf'` because magic-byte validation was added
* after those rows were written. This endpoint lets the UI detect and steer
* the user toward replacing them.
*
* Response shape is intentionally minimal — { valid: boolean } only. The
* reason for an invalid result is logged server-side rather than returned
* to the client to avoid information disclosure (V1.2.5 / GDPR Art 25(2))
* and to keep this from being a probe surface for storage internals.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { user, supabase, error } = await requireAuth()
if (error) return error
const rawParams = await params
const parsed = ParamsSchema.safeParse(rawParams)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid document id' }, { status: 400 })
}
const { id } = parsed.data
// Filter to the current version. The integrity check is meaningful only
// on the live file; superseded versions are archived bytes and should
// not be re-probed (they're already preserved in the version chain
// exactly as uploaded).
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, company_id, mime_type, storage_path')
.eq('id', id)
.eq('is_current_version', true)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Tenant membership: even with the user-scoped supabase client below,
// we want a clear 404 rather than relying on a storage-layer RLS deny
// (which can present as a generic error). RLS on document_attachments
// is the primary control; this is defense in depth.
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('company_id', doc.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
if (!doc.mime_type) {
return NextResponse.json({ data: { valid: true } })
}
// Use the user-scoped supabase client so the storage download is subject
// to RLS on storage.objects, not just the application-layer membership
// check above. A logic bug in the membership check would still be
// arrested at the storage layer.
const { data: blob, error: downloadError } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (downloadError || !blob) {
log.error('storage download failed for integrity check', downloadError as Error, {
documentId: id,
companyId: doc.company_id,
})
return NextResponse.json({ error: 'Integrity check unavailable' }, { status: 500 })
}
// Only the first 16 bytes are needed for magic-byte detection (PDF/PNG
// use ≤8, WebP needs 12). Trimming here doesn't change bandwidth — the
// full blob is already downloaded — but it makes the intent explicit and
// keeps memory churn off the hot path for large PDFs.
const headerBuffer = await blob.slice(0, 16).arrayBuffer()
const magicError = validateDocumentMagicBytes(headerBuffer, doc.mime_type)
if (magicError) {
log.warn('document failed magic-byte integrity check', {
documentId: id,
companyId: doc.company_id,
reason: magicError,
})
}
return NextResponse.json({ data: { valid: magicError === null } })
}
+127 -24
View File
@@ -41,6 +41,15 @@ interface AttachmentPreviewSheetProps {
onOpenChange: (open: boolean) => void
}
// Tri-state integrity result so a transport/parse failure does not get
// collapsed into "valid". Document bytes are immutable (WORM), so once we
// have a definitive valid/invalid we can also memoise across re-opens of
// the sheet — the integrity probe is the most expensive call on this
// surface and there's no need to re-run it for the same document twice in
// a session.
type IntegrityState = 'valid' | 'invalid' | 'error'
const integrityCache = new Map<string, IntegrityState>()
function isImageType(type: string | null): boolean {
return type?.startsWith('image/') ?? false
}
@@ -65,6 +74,7 @@ export default function AttachmentPreviewSheet({
const { toast } = useToast()
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(false)
const [integrity, setIntegrity] = useState<Record<string, IntegrityState>>({})
const [blockedDoc, setBlockedDoc] = useState<DocumentRecord | null>(null)
const [replacingDocId, setReplacingDocId] = useState<string | null>(null)
@@ -97,8 +107,61 @@ export default function AttachmentPreviewSheet({
})
)
setDocuments(enriched)
// Probe storage bytes for PDFs that we haven't already classified in
// this session. Legacy MCP uploads from before magic-byte validation
// can have non-PDF bytes stored under mime_type='application/pdf';
// Chrome's PDF viewer surfaces this as "Failed to load PDF document"
// only after the user has tried to view the file, so we want a
// clearer warning up front.
//
// Why per-session caching: document bytes are immutable (WORM) once
// uploaded, so a definitive valid/invalid result never changes for
// the same document id. Re-running the probe every time the sheet
// opens would be wasted bandwidth and unnecessary processing of
// financial documents (GDPR Art. 5(1)(b) data minimisation).
const seeded: Record<string, IntegrityState> = {}
const needsProbe: DocumentRecord[] = []
for (const doc of enriched) {
if (doc.mime_type !== 'application/pdf') continue
const cached = integrityCache.get(doc.id)
if (cached) {
seeded[doc.id] = cached
} else {
needsProbe.push(doc)
}
}
if (Object.keys(seeded).length > 0) {
setIntegrity(seeded)
}
const results = await Promise.all(
needsProbe.map(async (doc) => {
try {
const r = await fetch(`/api/documents/${doc.id}/integrity`)
if (!r.ok) {
// Server reachable but returned a non-2xx — treat as unknown.
// Caching the error would stick across reloads of the sheet,
// which is not what we want for transient 5xx.
return [doc.id, 'error' as const, false] as const
}
const { data } = await r.json()
const state: IntegrityState = data?.valid === false ? 'invalid' : 'valid'
return [doc.id, state, true] as const
} catch {
return [doc.id, 'error' as const, false] as const
}
})
)
const next: Record<string, IntegrityState> = { ...seeded }
for (const [docId, state, cache] of results) {
next[docId] = state
if (cache) integrityCache.set(docId, state)
}
setIntegrity(next)
} catch {
setDocuments([])
setIntegrity({})
} finally {
setLoading(false)
}
@@ -109,6 +172,7 @@ export default function AttachmentPreviewSheet({
fetchAttachments(entryId)
} else if (!open) {
setDocuments([])
setIntegrity({})
setBlockedDoc(null)
}
}, [open, entryId, fetchAttachments])
@@ -243,36 +307,75 @@ export default function AttachmentPreviewSheet({
</div>
</div>
{isPdfType(doc.mime_type) && (
{isPdfType(doc.mime_type) && integrity[doc.id] === 'invalid' && (
<div className="flex h-[70vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 p-6 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-warning/15">
<AlertTriangle className="h-5 w-5 text-warning-foreground" />
</div>
<div className="max-w-md space-y-2">
<p className="text-sm font-medium">{t('corrupt_title')}</p>
<p className="text-sm text-muted-foreground">{t('corrupt_body')}</p>
</div>
<Button
onClick={() => handleOpenReplacePicker(doc.id)}
disabled={isReplacing}
>
{isReplacing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{tj('replace_uploading')}
</>
) : (
t('corrupt_replace_cta')
)}
</Button>
</div>
)}
{isPdfType(doc.mime_type) && integrity[doc.id] !== 'invalid' && (
// <object> + type="application/pdf" invokes Chrome's PDF
// plugin directly. <iframe> went through Chrome's frame
// pipeline first and intermittently surfaced
// "Det här innehållet har blockerats" even with a
// permissive CSP. Firefox/Edge handled both fine; Chrome
// is the odd one. See crbug.com/271452.
<object
data={inlineSrc}
type="application/pdf"
aria-label={doc.file_name}
className="h-[70vh] w-full rounded-lg border border-border"
>
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
{t('not_previewable')}
{doc.download_url && (
<>
{' — '}
<a
href={doc.download_url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t('open_in_new_tab')}
</a>
</>
)}
</div>
</object>
<>
{integrity[doc.id] === 'error' && (
// Non-blocking indicator when the integrity probe
// could not complete (network blip, 5xx). The PDF
// preview still renders; the user can fall back to
// Chrome's own viewer error UI if the bytes are
// also corrupt. Surfacing this rather than falling
// through silently to "valid" satisfies SOC 2 CC8.1
// — failed checks must not be masked.
<p className="text-xs text-muted-foreground">
{t('integrity_unknown')}
</p>
)}
<object
data={inlineSrc}
type="application/pdf"
aria-label={doc.file_name}
className="h-[70vh] w-full rounded-lg border border-border"
>
<div className="flex h-[70vh] w-full items-center justify-center rounded-lg border border-border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
{t('not_previewable')}
{doc.download_url && (
<>
{' — '}
<a
href={doc.download_url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t('open_in_new_tab')}
</a>
</>
)}
</div>
</object>
</>
)}
{isImageType(doc.mime_type) && (
@@ -29,7 +29,7 @@ function makeClient() {
}
}
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod } from '../period-service'
import { lockPeriod, unlockPeriod, closePeriod, createNextPeriod, findNextPeriod } from '../period-service'
beforeEach(() => {
vi.clearAllMocks()
@@ -230,3 +230,78 @@ describe('createNextPeriod', () => {
expect(result.period_end).toBe('2025-06-30')
})
})
describe('findNextPeriod', () => {
it('returns the period chained via previous_period_id', async () => {
const current = makeFiscalPeriod({
id: 'fp-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
})
const next = makeFiscalPeriod({
id: 'fp-2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
previous_period_id: 'fp-2024',
})
results = [
{ data: current, error: null }, // fetch current
{ data: next, error: null }, // chained lookup (.maybeSingle)
]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
expect(result?.id).toBe('fp-2025')
})
it('falls back to period_start lookup when chain is missing', async () => {
const current = makeFiscalPeriod({
id: 'fp-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
})
const next = makeFiscalPeriod({
id: 'fp-2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
previous_period_id: null,
})
results = [
{ data: current, error: null }, // fetch current
{ data: null, error: null }, // chained lookup misses
{ data: next, error: null }, // date lookup hits
]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
expect(result?.id).toBe('fp-2025')
})
it('returns null when no next period exists', async () => {
const current = makeFiscalPeriod({
id: 'fp-2024',
period_start: '2024-01-01',
period_end: '2024-12-31',
})
results = [
{ data: current, error: null },
{ data: null, error: null },
{ data: null, error: null },
]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'fp-2024')
expect(result).toBeNull()
})
it('returns null when current period not found', async () => {
results = [{ data: null, error: { message: 'not found' } }]
const supabase = makeClient()
const result = await findNextPeriod(supabase as never, 'company-1', 'missing')
expect(result).toBeNull()
})
})
@@ -56,11 +56,13 @@ vi.mock('../period-service', () => ({
lockPeriod: vi.fn(),
closePeriod: vi.fn(),
createNextPeriod: vi.fn(),
findNextPeriod: vi.fn().mockResolvedValue(null),
}))
import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-service'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { findNextPeriod } from '../period-service'
beforeEach(() => {
vi.clearAllMocks()
@@ -345,6 +347,55 @@ describe('validateYearEndReadiness', () => {
expect(result.warnings.some((w: string) => w.includes('Sequence counter ahead'))).toBe(true)
expect(result.sequenceMismatches).toHaveLength(1)
})
it('warns (not errors) when next period already exists without IB', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = noGapResults(period)
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
vi.mocked(findNextPeriod).mockResolvedValueOnce({
id: 'fp-2',
name: 'FY 2025',
opening_balance_entry_id: null,
} as never)
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(true)
// Period name intentionally not interpolated into the warning — see
// year-end-service for rationale. We assert on the stable English
// substring instead.
expect(result.warnings.some((w: string) => w.includes('Next fiscal period already exists'))).toBe(true)
})
it('blocks when next period already has opening balances posted', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
results = noGapResults(period)
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [],
isBalanced: true,
totalDebit: 10000,
totalCredit: 10000,
} as never)
vi.mocked(findNextPeriod).mockResolvedValueOnce({
id: 'fp-2',
name: 'FY 2025',
opening_balance_entry_id: 'ib-1',
} as never)
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('already has opening balances'))).toBe(true)
})
})
describe('previewYearEndClosing', () => {
+75 -14
View File
@@ -215,20 +215,22 @@ export async function createNextPeriod(
throw new Error('Current fiscal period not found')
}
// Compute next period start (day after current end)
const nextStart = new Date(current.period_end)
nextStart.setDate(nextStart.getDate() + 1)
// Compute next period start (day after current end) in pure UTC — see
// findNextPeriod for the DST off-by-one rationale.
const nextStart = new Date(current.period_end + 'T00:00:00Z')
nextStart.setUTCDate(nextStart.getUTCDate() + 1)
// After a broken first fiscal year, subsequent years should always be
// 12 months (standard fiscal year). The first year is the only one that
// can be longer/shorter than 12 months per BFL 3 kap.
const nextEnd = new Date(nextStart)
nextEnd.setMonth(nextEnd.getMonth() + 12)
// Go to last day of that month
nextEnd.setDate(0)
nextEnd.setUTCMonth(nextEnd.getUTCMonth() + 12)
// Go to last day of the previous month — setUTCDate(0) rolls back into
// the prior month's last day.
nextEnd.setUTCDate(0)
const nextStartStr = nextStart.toISOString().split('T')[0]
const nextEndStr = nextEnd.toISOString().split('T')[0]
const nextStartStr = nextStart.toISOString().slice(0, 10)
const nextEndStr = nextEnd.toISOString().slice(0, 10)
// Validate period duration — subsequent periods always start on 1st of month
const durationError = validatePeriodDuration(nextStartStr, nextEndStr, { isFirstPeriod: false })
@@ -250,8 +252,8 @@ export async function createNextPeriod(
}
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
const startYear = nextStart.getFullYear()
const endYear = nextEnd.getFullYear()
const startYear = nextStart.getUTCFullYear()
const endYear = nextEnd.getUTCFullYear()
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
const { data: newPeriod, error: insertError } = await supabase
@@ -274,6 +276,68 @@ export async function createNextPeriod(
return newPeriod as FiscalPeriod
}
/**
* Look up the next fiscal period after the given one without creating it.
*
* Used by year-end closing to handle the common case where the next period
* was already created (e.g. by SIE import, manual creation, or a previous
* partial year-end run). Returns null when no such period exists.
*
* Matches first on previous_period_id chain, then falls back to a
* period_start = (current.period_end + 1 day) lookup so periods created
* before the chain was wired up are still recognised.
*/
export async function findNextPeriod(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string
): Promise<FiscalPeriod | null> {
const { data: current, error: fetchError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', currentPeriodId)
.eq('company_id', companyId)
.single()
if (fetchError || !current) {
return null
}
const { data: chained } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', companyId)
.eq('previous_period_id', currentPeriodId)
.maybeSingle()
if (chained) {
return chained as FiscalPeriod
}
// UTC-only arithmetic: anchor the date string at UTC midnight, then
// advance via setUTCDate. Using Date(string) + setDate/getDate causes an
// off-by-one on servers in TZ+ when the day after period_end crosses a
// DST spring-forward, because setDate(local) writes local-time fields
// and toISOString() converts back through the shifted offset.
const expectedStartStr = addDaysUTC(current.period_end, 1)
const { data: byDate } = await supabase
.from('fiscal_periods')
.select('*')
.eq('company_id', companyId)
.eq('period_start', expectedStartStr)
.maybeSingle()
return (byDate as FiscalPeriod | null) ?? null
}
/** Add `days` to a YYYY-MM-DD string in pure UTC and return YYYY-MM-DD. */
function addDaysUTC(isoDate: string, days: number): string {
const d = new Date(isoDate + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
/**
* Create a previous fiscal period before the given one.
* Computes a 12-month period ending the day before the given period starts.
@@ -466,10 +530,7 @@ export async function getPeriodStatus(
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'draft')
// Check if next period exists
const nextStart = new Date(period.period_end)
nextStart.setDate(nextStart.getDate() + 1)
// Check if next period exists via the chain pointer
const { data: nextPeriod } = await supabase
.from('fiscal_periods')
.select('id')
+56 -12
View File
@@ -7,7 +7,7 @@ import { createLogger } from '@/lib/logger'
const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
@@ -253,6 +253,25 @@ export async function validateYearEndReadiness(
errors.push('Opening balance continuity check failed for this period — resolve discrepancies before closing')
}
// Check: next period state. A pre-existing next period (from SIE import,
// manual creation, or a prior partial run) is fine — we'll reuse it — but
// one with opening balances already booked blocks closing because we
// can't post a second IB on top.
//
// The period name is not interpolated into the message — although the
// name is user-supplied at create time and confined to the company,
// surfacing DB-sourced strings through error paths is the kind of
// injection footgun we'd rather close at the source than rely on the UI
// to escape (text rendering and aria-label propagation differ).
const nextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
if (nextPeriod) {
if (nextPeriod.opening_balance_entry_id) {
errors.push('Next fiscal period already has opening balances posted')
} else {
warnings.push('Next fiscal period already exists — opening balances will be booked into it')
}
}
return {
ready: errors.length === 0,
errors,
@@ -397,12 +416,15 @@ export async function previewYearEndClosing(
* Execute year-end closing for a fiscal period.
*
* 1. Validate readiness
* 2. Create closing entry (zeros class 3-8 accounts)
* 3. Set closing_entry_id on the period
* 4. Lock the period
* 5. Close the period
* 6. Create next fiscal period
* 7. Generate opening balances in next period
* 2. Run currency revaluation (FX gains/losses to 3960/7960)
* 3. Generate closing preview and check öre balance
* 4. Create closing entry (zeros class 3-8 accounts)
* 5. Set closing_entry_id on the period
* 6. Resolve next fiscal period (reuse existing or create new)
* 7. Lock the period
* 8. Close the period (irreversible — every guard must run before this)
* 9. Generate opening balances in next period
* 10. Validate IB/UB continuity
*/
export async function executeYearEndClosing(
supabase: SupabaseClient,
@@ -509,15 +531,37 @@ export async function executeYearEndClosing(
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
}
// 6. Lock the period
// 6. Resolve the next period BEFORE locking/closing this one. A pre-existing
// next period is common (SIE import, manual creation, prior partial
// year-end run); reusing it is fine as long as no IB has been booked
// into it. Doing this check after closePeriod would leave the books in
// a half-closed state if a concurrent process posted IB into the next
// period between validateYearEndReadiness and step 8 (TOCTOU race).
//
// The thrown error is intentionally a stable English string with no
// DB-sourced data interpolated — the route layer maps it to a
// structured error code, and the next period name (if any) is surfaced
// only through the structured details payload after explicit checks.
const existingNextPeriod = await findNextPeriod(supabase, companyId, fiscalPeriodId)
let nextPeriod
if (existingNextPeriod) {
if (existingNextPeriod.opening_balance_entry_id) {
throw new Error(
'Next fiscal period already has opening balance entry posted; reverse it before re-running year-end'
)
}
nextPeriod = existingNextPeriod
} else {
nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
}
// 7. Lock the period
await lockPeriod(supabase, companyId, userId, fiscalPeriodId)
// 7. Close the period
// 8. Close the period — irreversible per BFL. Every guard that can fail
// on prior state must run before this point.
await closePeriod(supabase, companyId, userId, fiscalPeriodId)
// 8. Create next period
const nextPeriod = await createNextPeriod(supabase, companyId, userId, fiscalPeriodId)
// 9. Generate opening balances in next period
const openingBalanceEntry = await generateOpeningBalances(
supabase,
@@ -1,8 +1,11 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { getPool } from '@/tests/pg/setup'
import { getPool, withUserContext } from '@/tests/pg/setup'
import {
insertAuthUser,
insertBalancedLines,
insertCompany,
insertCompanyMember,
insertDraftJournalEntry,
seedCompany,
} from '@/tests/pg/fixtures'
@@ -231,3 +234,266 @@ describe('document-immutability.pg — BFL retention bypass guards', () => {
}
})
})
// The supersession flow needs to flip the OLD row from is_current_version=true
// to false. The metadata-immutability trigger blocks that change for docs
// linked to posted entries unless the gnubok.allow_supersede GUC is set —
// which create_document_version sets before its UPDATE. Without this, users
// have no way to replace a corrupt underlag (e.g. a bad PDF uploaded via the
// MCP server before magic-byte validation).
describe('document-immutability.pg — version supersession on posted entries', () => {
it('allows create_document_version on a doc linked to a posted entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
// create_document_version is SECURITY DEFINER but enforces auth.uid()
// matches p_user_id and that the caller is a company member, so the
// call must run under withUserContext(userId).
const newId = await withUserContext(userId, async (client) => {
const result = await client.query<{ new_id: string }>(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
) AS new_id`,
[
userId,
docId,
`documents/${userId}/replacement.pdf`,
'replacement.pdf',
2048,
'application/pdf',
'b'.repeat(64),
],
)
const id = result.rows[0]!.new_id
// The transaction is rolled back by withUserContext, so we have to
// assert state from within the same client/transaction.
const oldRow = await client.query<{
is_current_version: boolean
superseded_by_id: string | null
journal_entry_id: string | null
}>(
`SELECT is_current_version, superseded_by_id, journal_entry_id
FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(oldRow.rows[0]!.is_current_version).toBe(false)
expect(oldRow.rows[0]!.superseded_by_id).toBe(id)
expect(oldRow.rows[0]!.journal_entry_id).toBe(entryId)
const newRow = await client.query<{
is_current_version: boolean
version: number
journal_entry_id: string | null
prev_version_hash: string | null
}>(
`SELECT is_current_version, version, journal_entry_id, prev_version_hash
FROM public.document_attachments WHERE id = $1`,
[id],
)
expect(newRow.rows[0]!.is_current_version).toBe(true)
expect(newRow.rows[0]!.version).toBe(2)
expect(newRow.rows[0]!.journal_entry_id).toBe(entryId)
expect(newRow.rows[0]!.prev_version_hash).toBe('a'.repeat(64))
return id
})
expect(newId).toBeDefined()
})
it('allows create_document_version on a doc linked to a reversed entry', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1, status: 'reversed',
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await withUserContext(userId, async (client) => {
const result = await client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
userId, docId,
`documents/${userId}/replacement.pdf`,
'replacement.pdf', 2048, 'application/pdf', 'c'.repeat(64),
],
)
expect(result.rows).toHaveLength(1)
})
})
// Cross-tenant attack: a member of company A calls create_document_version
// with a document id that belongs to company B. Before the auth+membership
// check landed, the SECURITY DEFINER function would happily mutate the
// foreign company's row because the GUC bypass disarmed the immutability
// trigger. The membership guard inside the function is the only line of
// defence — PostgREST exposes the RPC to all authenticated users.
it('rejects create_document_version when caller is not a member of the document company', async () => {
const { userId: aliceId, companyId: aliceCompanyId, fiscalPeriodId: aliceFp } =
await seedCompany()
const entryId = await insertEntryAtStatus({
userId: aliceId,
companyId: aliceCompanyId,
fiscalPeriodId: aliceFp,
voucherNumber: 1,
})
const docId = await insertDocument({
userId: aliceId,
companyId: aliceCompanyId,
journalEntryId: entryId,
})
// Bob has his own company; he is NOT a member of Alice's company.
const bobId = await insertAuthUser()
const bobCompany = await insertCompany({ createdBy: bobId })
await insertCompanyMember({ companyId: bobCompany, userId: bobId, role: 'owner' })
await withUserContext(bobId, async (client) => {
await expect(
client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
bobId, docId,
`documents/${bobId}/exfil.pdf`,
'exfil.pdf', 2048, 'application/pdf', 'd'.repeat(64),
],
),
).rejects.toThrow(/not a member/i)
})
})
// Identity-spoofing attack: caller passes p_user_id ≠ auth.uid(). Allowing
// this would let any authenticated user manufacture supersession events
// attributed to a different user — useful for audit-log forgery even when
// the supersession itself is legitimate.
it('rejects create_document_version when p_user_id does not match auth.uid()', async () => {
const { userId: aliceId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId: aliceId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({
userId: aliceId, companyId, journalEntryId: entryId,
})
// Mallory is also a member of the company — so the membership check
// alone would not catch a spoofed p_user_id.
const malloryId = await insertAuthUser()
await insertCompanyMember({ companyId, userId: malloryId, role: 'member' })
await withUserContext(malloryId, async (client) => {
await expect(
client.query(
`SELECT public.create_document_version(
$1::uuid, $2::uuid, $3::text, $4::text, $5::bigint, $6::text, $7::text
)`,
[
// Spoofing aliceId as the actor while auth.uid() is malloryId.
aliceId, docId,
`documents/${aliceId}/spoofed.pdf`,
'spoofed.pdf', 2048, 'application/pdf', 'e'.repeat(64),
],
),
).rejects.toThrow(/does not match authenticated user/i)
})
})
// The narrowed bypass: even with allow_supersede set, a direct UPDATE that
// tries to change sha256_hash, journal_entry_id, or any other audit field
// must still be blocked. Before the narrowing the GUC was a blanket
// bypass that disarmed the entire trigger.
it('keeps blocking sha256_hash mutation even when allow_supersede is set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await expect(
client.query(
`UPDATE public.document_attachments SET sha256_hash = $2 WHERE id = $1`,
[docId, 'f'.repeat(64)],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('keeps blocking journal_entry_id mutation even when allow_supersede is set', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryA = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const entryB = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 2,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryA })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await expect(
client.query(
`UPDATE public.document_attachments SET journal_entry_id = $1 WHERE id = $2`,
[entryB, docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('rejects direct UPDATE flipping is_current_version without the GUC', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
await expect(
getPool().query(
`UPDATE public.document_attachments SET is_current_version = false WHERE id = $1`,
[docId],
),
).rejects.toThrow(BFL_RETENTION_ERROR)
})
it('respects gnubok.allow_supersede bypass on direct UPDATE', async () => {
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const entryId = await insertEntryAtStatus({
userId, companyId, fiscalPeriodId, voucherNumber: 1,
})
const docId = await insertDocument({ userId, companyId, journalEntryId: entryId })
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(`SELECT set_config('gnubok.allow_supersede', 'true', true)`)
await client.query(
`UPDATE public.document_attachments SET is_current_version = false WHERE id = $1`,
[docId],
)
const after = await client.query<{ is_current_version: boolean }>(
`SELECT is_current_version FROM public.document_attachments WHERE id = $1`,
[docId],
)
expect(after.rows[0]!.is_current_version).toBe(false)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})
+5
View File
@@ -770,6 +770,11 @@ const YEAR_END: Record<string, StructuredErrorEntry> = {
message_sv: 'Resultaträkningens debet och kredit balanserar inte. Granska verifikationerna innan bokslut.',
message_en: 'Trial balance does not balance.',
},
YEAR_END_NEXT_PERIOD_HAS_IB: {
httpStatus: 400,
message_sv: 'Nästa räkenskapsperiod har redan ingående balanser bokförda. Storno dem innan du kör om bokslutet.',
message_en: 'Next fiscal period already has opening balances posted; reverse them before re-running year-end.',
},
}
const OPENING_BAL: Record<string, StructuredErrorEntry> = {
+5 -1
View File
@@ -2680,7 +2680,11 @@
"open_in_new_tab": "Open in new tab",
"not_previewable": "Preview is not available for this file type.",
"remove": "Remove",
"replace": "Replace with new version"
"replace": "Replace with new version",
"corrupt_title": "File appears to be corrupt",
"corrupt_body": "The file cannot be read as a PDF. Upload a new version to replace it — the old one is kept in the version history.",
"corrupt_replace_cta": "Upload new version",
"integrity_unknown": "File integrity could not be verified — try again later if the preview does not appear."
},
"journal_attachments": {
"loading": "Loading documents...",
+5 -1
View File
@@ -2680,7 +2680,11 @@
"open_in_new_tab": "Öppna i nytt fönster",
"not_previewable": "Förhandsvisning är inte tillgänglig för denna filtyp.",
"remove": "Ta bort",
"replace": "Ersätt med ny version"
"replace": "Ersätt med ny version",
"corrupt_title": "Filen verkar vara skadad",
"corrupt_body": "Filen kan inte läsas som PDF. Ladda upp en ny version så ersätter den den trasiga — den gamla bevaras i versionshistoriken.",
"corrupt_replace_cta": "Ladda upp ny version",
"integrity_unknown": "Filens integritet kunde inte verifieras — försök igen senare om förhandsgranskningen inte visas."
},
"journal_attachments": {
"loading": "Laddar underlag...",
@@ -0,0 +1,204 @@
-- Restore document version supersession on posted entries.
--
-- Background: 20260506150000 extended enforce_document_metadata_immutability
-- to also block changes to journal_entry_id, journal_entry_line_id, AND
-- is_current_version when the document is linked to a posted/reversed entry.
-- The journal_entry_id / line_id additions are correct — they close a real
-- BFL 7 kap 2§ bypass (UPDATE journal_entry_id = NULL → DELETE).
--
-- The is_current_version addition was overreach: the create_document_version
-- RPC must flip the OLD row from is_current_version = true to false (and
-- set superseded_by_id) as part of the legitimate WORM-compliant supersession
-- flow. Every replace attempt on a doc linked to a posted verifikat now
-- raises "Cannot modify metadata or journal entry link of document linked
-- to a posted journal entry (BFL 7 kap)" — which surfaces in the Bilagor
-- modal as "Kunde inte ladda upp ny version".
--
-- This blocks the only path users have to fix corrupt underlag: a PDF that
-- was uploaded with bad bytes (e.g. via the MCP server before magic-byte
-- validation landed in 20260526) is now permanently unreadable on a posted
-- entry, with no replacement possible.
--
-- Fix: introduce a transaction-local gnubok.allow_supersede GUC. Unlike
-- gnubok.allow_delete, this is NOT a blanket bypass — the trigger continues
-- to enforce immutability on every field except is_current_version and
-- superseded_by_id even when the GUC is set. Combined with caller-identity
-- checks inside create_document_version (auth.uid() match + company
-- membership), an attacker who sets the GUC manually still cannot mutate
-- journal_entry_id, sha256_hash, storage_path, or any other audit-critical
-- field on a posted document.
CREATE OR REPLACE FUNCTION public.enforce_document_metadata_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_entry_status text;
v_allow_supersede boolean;
BEGIN
IF current_setting('gnubok.allow_delete', true) = 'true' THEN
RETURN NEW;
END IF;
v_allow_supersede := current_setting('gnubok.allow_supersede', true) = 'true';
IF OLD.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
SELECT status INTO v_entry_status
FROM public.journal_entries
WHERE id = OLD.journal_entry_id;
IF v_entry_status IS NULL OR v_entry_status NOT IN ('posted', 'reversed') THEN
RETURN NEW;
END IF;
-- Even with allow_supersede, every field other than is_current_version and
-- superseded_by_id remains immutable. The bypass is intentionally narrow
-- so that a session that obtains the GUC (e.g. via SQL injection) cannot
-- mutate journal_entry_id, sha256_hash, storage_path, or any other field
-- the BFL 7 kap audit trail depends on.
IF NEW.file_name IS DISTINCT FROM OLD.file_name
OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
OR NEW.file_size_bytes IS DISTINCT FROM OLD.file_size_bytes
OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
OR NEW.sha256_hash IS DISTINCT FROM OLD.sha256_hash
OR NEW.upload_source IS DISTINCT FROM OLD.upload_source
OR NEW.digitization_date IS DISTINCT FROM OLD.digitization_date
OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
OR NEW.version IS DISTINCT FROM OLD.version
OR NEW.original_id IS DISTINCT FROM OLD.original_id
OR NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id
OR NEW.journal_entry_line_id IS DISTINCT FROM OLD.journal_entry_line_id
THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked metadata or link modification of document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify metadata or journal entry link of document linked to a % journal entry (BFL 7 kap)', v_entry_status;
END IF;
-- is_current_version and superseded_by_id may only be changed under the
-- supersede GUC. Without it, those flips are also blocked.
IF NOT v_allow_supersede
AND (NEW.is_current_version IS DISTINCT FROM OLD.is_current_version
OR NEW.superseded_by_id IS DISTINCT FROM OLD.superseded_by_id)
THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked is_current_version/superseded_by_id flip without supersede GUC on document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify is_current_version of document linked to a % journal entry without supersede GUC (BFL 7 kap)', v_entry_status;
END IF;
RETURN NEW;
END;
$function$;
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid,
p_original_doc_id uuid,
p_storage_path text,
p_file_name text,
p_file_size_bytes bigint,
p_mime_type text,
p_sha256_hash text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
DECLARE
v_caller uuid := auth.uid();
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
v_is_member boolean;
BEGIN
-- Caller identity: the RPC is SECURITY DEFINER, so without this guard a
-- direct PostgREST call from any authenticated user could supplant
-- p_user_id with an arbitrary UUID. Reject any mismatch — the route layer
-- already passes the user's own id, so a mismatch is always malicious.
IF v_caller IS NULL THEN
RAISE EXCEPTION 'Authentication required to create document version';
END IF;
IF p_user_id IS DISTINCT FROM v_caller THEN
RAISE EXCEPTION 'p_user_id does not match authenticated user';
END IF;
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
-- Company membership: the caller must be a member of the document's
-- company. RLS would block a direct SELECT in non-DEFINER contexts, but
-- inside this SECURITY DEFINER function we bypass RLS and must enforce
-- the tenant boundary ourselves.
SELECT EXISTS (
SELECT 1
FROM public.company_members cm
WHERE cm.company_id = v_current.company_id
AND cm.user_id = v_caller
) INTO v_is_member;
IF NOT v_is_member THEN
-- Audit the cross-tenant attempt so it shows up in security review.
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (v_caller, v_current.company_id, 'SECURITY_EVENT', 'document_attachments', p_original_doc_id,
'Blocked cross-company create_document_version attempt');
RAISE EXCEPTION 'User is not a member of the document''s company';
END IF;
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
-- Insert new version. The supersede GUC is set *before* both DML statements
-- so the trigger sees a consistent state across the whole supersession.
PERFORM set_config('gnubok.allow_supersede', 'true', true);
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash
)
RETURNING id INTO v_new_id;
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
-- Audit the legitimate supersession. BFL 7 kap requires the supersession
-- chain to be reconstructible from immutable storage; the row chain itself
-- carries the data, but an explicit audit row makes the event visible to
-- SOC 2 monitoring without joining version timelines.
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, actor_id, description)
VALUES (
v_caller, v_current.company_id, 'UPDATE', 'document_attachments', p_original_doc_id, v_caller,
'Document superseded: v' || v_current.version || ' (' || v_current.sha256_hash || ') → v' || v_next_version || ' (' || p_sha256_hash || '); new id=' || v_new_id
);
RETURN v_new_id;
END;
$function$;
NOTIFY pgrst, 'reload schema';