Files
accounted/lib/reconciliation/attachments.ts
T
Jakob Wennberg c62321988b feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2)

A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an
account was reconciled against to (account_key, through_date), before or
after the sign-off, from every account body on the Avstämning page. Rows
live in account_reconciliation_attachments (append-only, removal stamp by
trigger, RLS like account_reconciliations), bytes in the documents bucket
under the company prefix so its RLS applies unchanged, and the full
archive copies them into bilagor/ with a hash manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867)

* feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3)

The bokslut checklist is a catalogue in code with one state row per period
(bokslut_checklist_items): the steps the system can judge (sign-offs through
balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are
computed live and a stored row only overrides them; the manual steps are
the konsult's ticks, with who and when. It sits on the wizard's Kontroll
step and is dumped into the full archive.

A hole between fiscal years (one-file SIE migrations) is now named on the
bokslut readiness screen and on the import result screen, where the next
file is one click away. Non-adjacent period links are #1849's fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:34:05 +02:00

183 lines
7.0 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import { ISO_DATE_RE } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
import {
ALLOWED_DOCUMENT_TYPES,
DOCUMENTS_BUCKET,
computeSHA256,
validateDocumentFile,
validateDocumentMagicBytes,
} from '@/lib/core/documents/document-service'
import { parseAccountKey, type ReconciliationAttachment } from './schemas'
import {
getAttachmentRow,
insertAttachmentRow,
listAttachmentRows,
stampAttachmentRemoved,
toPublicAttachment,
type AttachmentRow,
} from './attachments-store'
const log = createLogger('reconciliation/attachments')
/**
* Underlag on a reconciliation balansdag: the bytes go to the company-scoped
* `documents` bucket (same validation and hashing as every other document in
* the archive), the row to account_reconciliation_attachments. The policy
* layer over attachments-store.ts: what may be attached, where it lives, and
* that removal is a stamp, never a delete.
*
* Storage keys start with `documents/<company_id>/` on purpose: the bucket's
* RLS grants INSERT/SELECT on the second path segment being one of the
* caller's companies, so the auth-bound client can upload without the
* service role. Reads for the inline route and the archive go through the
* service role after the row has authorized the caller.
*/
export const MAX_ATTACHMENT_NOTE_LENGTH = 500
export type AttachmentErrorCode = 'INVALID_ACCOUNT_KEY' | 'INVALID_DATE' | 'INVALID_FILE' | 'NOTE_TOO_LONG' | 'ALREADY_REMOVED'
export class ReconciliationAttachmentError extends Error {
readonly code: AttachmentErrorCode
constructor(message: string, code: AttachmentErrorCode) {
super(message)
this.name = 'ReconciliationAttachmentError'
this.code = code
}
}
function sanitizeFileName(name: string): string {
const trimmed = name.trim().replace(/[/\\]/g, '_').replace(/[-]/g, '')
return (trimmed || 'underlag').slice(0, 180)
}
/** `manual:2350` is a fine key but a poor path segment; keep the key readable without the colon. */
export function attachmentStoragePath(
companyId: string,
accountKey: string,
throughDate: string,
fileName: string,
id: string = randomUUID(),
): string {
const keySegment = accountKey.replace(':', '_')
return `documents/${companyId}/reconciliation/${keySegment}/${throughDate}/${id}_${sanitizeFileName(fileName)}`
}
function assertScope(accountKey: string, throughDate: string): void {
if (!parseAccountKey(accountKey)) {
throw new ReconciliationAttachmentError('Okänt konto.', 'INVALID_ACCOUNT_KEY')
}
if (!ISO_DATE_RE.test(throughDate) || Number.isNaN(Date.parse(throughDate))) {
throw new ReconciliationAttachmentError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'INVALID_DATE')
}
}
export async function listAttachments(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
throughDate: string,
options: { includeRemoved?: boolean } = {},
): Promise<ReconciliationAttachment[]> {
assertScope(accountKey, throughDate)
const rows = await listAttachmentRows(supabase, companyId, accountKey, throughDate, options)
return rows.map(toPublicAttachment)
}
export interface AttachInput {
through_date: string
file: { name: string; type: string; size: number; buffer: ArrayBuffer }
note?: string | null
}
/**
* Validate, hash, upload, record. The row is written after the object so a
* failed upload leaves nothing behind; a failed insert after a successful
* upload is logged with the key (the object is harmless: nothing points at it).
*/
export async function attachUnderlag(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
input: AttachInput,
): Promise<ReconciliationAttachment> {
assertScope(accountKey, input.through_date)
const note = input.note?.trim() ? input.note.trim() : null
if (note && note.length > MAX_ATTACHMENT_NOTE_LENGTH) {
throw new ReconciliationAttachmentError('Noteringen är för lång.', 'NOTE_TOO_LONG')
}
const sizeError = validateDocumentFile({ size: input.file.size, type: input.file.type })
if (sizeError) throw new ReconciliationAttachmentError(sizeError, 'INVALID_FILE')
if (!ALLOWED_DOCUMENT_TYPES.includes(input.file.type)) {
throw new ReconciliationAttachmentError('Filtypen stöds inte. Ladda upp PDF, JPEG, PNG eller WebP.', 'INVALID_FILE')
}
const magicError = validateDocumentMagicBytes(input.file.buffer, input.file.type)
if (magicError) throw new ReconciliationAttachmentError(magicError, 'INVALID_FILE')
const sha256 = await computeSHA256(input.file.buffer)
const storagePath = attachmentStoragePath(companyId, accountKey, input.through_date, input.file.name)
const { error: uploadError } = await supabase.storage
.from(DOCUMENTS_BUCKET)
.upload(storagePath, input.file.buffer, { contentType: input.file.type, upsert: false })
if (uploadError) {
throw new Error(`Kunde inte ladda upp underlaget: ${uploadError.message}`)
}
try {
const row = await insertAttachmentRow(supabase, companyId, {
account_key: accountKey,
through_date: input.through_date,
file_name: sanitizeFileName(input.file.name),
mime_type: input.file.type,
size_bytes: input.file.size,
storage_bucket: DOCUMENTS_BUCKET,
storage_path: storagePath,
sha256,
note,
uploaded_by: userId,
})
return toPublicAttachment(row)
} catch (err) {
log.error('attachment row insert failed after upload', err, { companyId, accountKey, storagePath })
throw err
}
}
/** Removal keeps the row and the object; the stamp says who and why. Null when the id does not resolve. */
export async function removeUnderlag(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
attachmentId: string,
input: { reason?: string | null } = {},
): Promise<ReconciliationAttachment | null> {
if (!parseAccountKey(accountKey)) return null
const existing = await getAttachmentRow(supabase, companyId, accountKey, attachmentId)
if (!existing) return null
if (existing.removed_at) {
throw new ReconciliationAttachmentError('Underlaget är redan borttaget.', 'ALREADY_REMOVED')
}
const reason = input.reason?.trim() ? input.reason.trim() : null
const updated = await stampAttachmentRemoved(supabase, companyId, attachmentId, { removed_by: userId, reason })
return updated ? toPublicAttachment(updated) : null
}
/**
* The bytes of one attachment, for the inline route and the archive. Takes
* the row (already authorized through the caller's client) and a service
* client for the bucket read.
*/
export async function downloadUnderlag(
serviceClient: SupabaseClient,
row: Pick<AttachmentRow, 'storage_bucket' | 'storage_path'>,
): Promise<{ blob: Blob | null; error: Error | null }> {
const { data, error } = await serviceClient.storage.from(row.storage_bucket).download(row.storage_path)
if (error || !data) return { blob: null, error: error ? new Error(error.message) : new Error('Download returned no data') }
return { blob: data, error: null }
}