Files
accounted/lib/reconciliation/attachments-store.ts
T
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

167 lines
6.4 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { ReconciliationAttachment } from './schemas'
/**
* The account_reconciliation_attachments table, read and written in one
* place. Pure storage rows: the file bytes live in Supabase Storage and are
* handled by attachments.ts, so the pärm and the archive can list underlag
* without touching the bucket.
*/
export interface AttachmentRow extends ReconciliationAttachment {
storage_bucket: string
storage_path: string
}
function mapRow(row: Record<string, unknown>): AttachmentRow {
return {
id: row.id as string,
account_key: row.account_key as string,
through_date: row.through_date as string,
file_name: row.file_name as string,
mime_type: row.mime_type as string,
size_bytes: Number(row.size_bytes ?? 0),
storage_bucket: row.storage_bucket as string,
storage_path: row.storage_path as string,
sha256: row.sha256 as string,
note: (row.note as string | null) ?? null,
uploaded_by: row.uploaded_by as string,
uploaded_at: row.uploaded_at as string,
removed_at: (row.removed_at as string | null) ?? null,
removed_by: (row.removed_by as string | null) ?? null,
removed_reason: (row.removed_reason as string | null) ?? null,
}
}
/** The public shape: no bucket or path (those are served through the file route). */
export function toPublicAttachment(row: AttachmentRow): ReconciliationAttachment {
const { storage_bucket: _bucket, storage_path: _path, ...rest } = row
void _bucket
void _path
return rest
}
export interface ListAttachmentsOptions {
includeRemoved?: boolean
}
/** Files for one account and balansdag, oldest first (the order they were attached). */
export async function listAttachmentRows(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
throughDate: string,
options: ListAttachmentsOptions = {},
): Promise<AttachmentRow[]> {
let query = supabase
.from('account_reconciliation_attachments')
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.eq('company_id', companyId)
.eq('account_key', accountKey)
.eq('through_date', throughDate)
.order('uploaded_at', { ascending: true })
if (!options.includeRemoved) query = query.is('removed_at', null)
const { data, error } = await query
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
}
/** Every active file with a balansdag inside [from, to], for the pärm and the archive. */
export async function listAttachmentRowsInRange(
supabase: SupabaseClient,
companyId: string,
from: string,
to: string,
options: ListAttachmentsOptions = {},
): Promise<AttachmentRow[]> {
let query = supabase
.from('account_reconciliation_attachments')
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.eq('company_id', companyId)
.gte('through_date', from)
.lte('through_date', to)
.order('account_key', { ascending: true })
.order('through_date', { ascending: true })
.order('uploaded_at', { ascending: true })
.limit(2000)
if (!options.includeRemoved) query = query.is('removed_at', null)
const { data, error } = await query
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
return ((data ?? []) as Record<string, unknown>[]).map(mapRow)
}
export async function getAttachmentRow(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
attachmentId: string,
): Promise<AttachmentRow | null> {
const { data, error } = await supabase
.from('account_reconciliation_attachments')
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.eq('company_id', companyId)
.eq('account_key', accountKey)
.eq('id', attachmentId)
.maybeSingle()
if (error) throw new Error(`Kunde inte hämta underlag: ${error.message}`)
return data ? mapRow(data as Record<string, unknown>) : null
}
export interface InsertAttachmentInput {
account_key: string
through_date: string
file_name: string
mime_type: string
size_bytes: number
storage_bucket: string
storage_path: string
sha256: string
note: string | null
uploaded_by: string
}
export async function insertAttachmentRow(
supabase: SupabaseClient,
companyId: string,
input: InsertAttachmentInput,
): Promise<AttachmentRow> {
const { data, error } = await supabase
.from('account_reconciliation_attachments')
.insert({
company_id: companyId,
account_key: input.account_key,
through_date: input.through_date,
file_name: input.file_name,
mime_type: input.mime_type,
size_bytes: input.size_bytes,
storage_bucket: input.storage_bucket,
storage_path: input.storage_path,
sha256: input.sha256,
note: input.note,
uploaded_by: input.uploaded_by,
})
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.single()
if (error) throw new Error(`Kunde inte spara underlag: ${error.message}`)
return mapRow(data as Record<string, unknown>)
}
/** Removal is a stamp; the row and the file stay (BFL 7 kap.). Null when already removed or missing. */
export async function stampAttachmentRemoved(
supabase: SupabaseClient,
companyId: string,
attachmentId: string,
input: { removed_by: string; reason: string | null },
): Promise<AttachmentRow | null> {
const { data, error } = await supabase
.from('account_reconciliation_attachments')
.update({ removed_at: new Date().toISOString(), removed_by: input.removed_by, removed_reason: input.reason })
.eq('company_id', companyId)
.eq('id', attachmentId)
.is('removed_at', null)
.select('id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, note, uploaded_by, uploaded_at, removed_at, removed_by, removed_reason')
.maybeSingle()
if (error) throw new Error(`Kunde inte ta bort underlag: ${error.message}`)
return data ? mapRow(data as Record<string, unknown>) : null
}