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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import {
|
||||
getAttachmentRow,
|
||||
insertAttachmentRow,
|
||||
listAttachmentRows,
|
||||
listAttachmentRowsInRange,
|
||||
stampAttachmentRemoved,
|
||||
toPublicAttachment,
|
||||
} from '../attachments-store'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const TABLE = 'account_reconciliation_attachments'
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'a1',
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
file_name: 'engagemangsbesked.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: '12345',
|
||||
storage_bucket: 'documents',
|
||||
storage_path: 'company-1/reconciliation/manual:2350/2026-12-31/a1.pdf',
|
||||
sha256: 'ab'.repeat(32),
|
||||
note: null,
|
||||
uploaded_by: 'u1',
|
||||
uploaded_at: '2027-01-10T08:00:00Z',
|
||||
removed_at: null,
|
||||
removed_by: null,
|
||||
removed_reason: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('attachments-store', () => {
|
||||
it('lists active files for one account and balansdag, coercing size to a number', async () => {
|
||||
const { supabase, enqueue, findCalls, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: [row()] })
|
||||
const rows = await listAttachmentRows(supabase as never, COMPANY, 'manual:2350', '2026-12-31')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].size_bytes).toBe(12345)
|
||||
const eqs = findCalls(TABLE, 'eq').map((a) => a[0])
|
||||
expect(eqs).toEqual(['company_id', 'account_key', 'through_date'])
|
||||
expect(findCall(TABLE, 'is')).toEqual(['removed_at', null])
|
||||
})
|
||||
|
||||
it('includes removed rows only on request', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: [row({ removed_at: '2027-01-11T08:00:00Z', removed_by: 'u1' })] })
|
||||
const rows = await listAttachmentRows(supabase as never, COMPANY, 'manual:2350', '2026-12-31', { includeRemoved: true })
|
||||
expect(rows[0].removed_at).toBe('2027-01-11T08:00:00Z')
|
||||
expect(findCall(TABLE, 'is')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists a date range for the pärm', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: [row(), row({ id: 'a2', account_key: 'skattekonto' })] })
|
||||
const rows = await listAttachmentRowsInRange(supabase as never, COMPANY, '2026-01-01', '2026-12-31')
|
||||
expect(rows.map((r) => r.id)).toEqual(['a1', 'a2'])
|
||||
expect(findCall(TABLE, 'gte')).toEqual(['through_date', '2026-01-01'])
|
||||
expect(findCall(TABLE, 'lte')).toEqual(['through_date', '2026-12-31'])
|
||||
})
|
||||
|
||||
it('strips bucket and path from the public shape', () => {
|
||||
const pub = toPublicAttachment({ ...row(), size_bytes: 12345 } as never)
|
||||
expect(pub).not.toHaveProperty('storage_path')
|
||||
expect(pub).not.toHaveProperty('storage_bucket')
|
||||
expect(pub.file_name).toBe('engagemangsbesked.pdf')
|
||||
})
|
||||
|
||||
it('inserts with the company id and returns the row', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: row() })
|
||||
const r = await insertAttachmentRow(supabase as never, COMPANY, {
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
file_name: 'engagemangsbesked.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 12345,
|
||||
storage_bucket: 'documents',
|
||||
storage_path: 'company-1/reconciliation/manual:2350/2026-12-31/a1.pdf',
|
||||
sha256: 'ab'.repeat(32),
|
||||
note: null,
|
||||
uploaded_by: 'u1',
|
||||
})
|
||||
expect(r.id).toBe('a1')
|
||||
expect(findCall(TABLE, 'insert')?.[0]).toMatchObject({ company_id: COMPANY, account_key: 'manual:2350', uploaded_by: 'u1' })
|
||||
})
|
||||
|
||||
it('stamps removal only on an active row and returns null otherwise', async () => {
|
||||
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
expect(await stampAttachmentRemoved(supabase as never, COMPANY, 'a1', { removed_by: 'u1', reason: null })).toBeNull()
|
||||
expect(findCall(TABLE, 'update')?.[0]).toMatchObject({ removed_by: 'u1', removed_reason: null })
|
||||
expect(findCall(TABLE, 'is')).toEqual(['removed_at', null])
|
||||
})
|
||||
|
||||
it('reads one row by id within the account scope', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: row() })
|
||||
const r = await getAttachmentRow(supabase as never, COMPANY, 'manual:2350', 'a1')
|
||||
expect(r?.storage_path).toContain('reconciliation')
|
||||
enqueue({ data: null })
|
||||
expect(await getAttachmentRow(supabase as never, COMPANY, 'manual:2350', 'nope')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const listRowsMock = vi.fn()
|
||||
const insertRowMock = vi.fn()
|
||||
const getRowMock = vi.fn()
|
||||
const stampMock = vi.fn()
|
||||
vi.mock('../attachments-store', async () => {
|
||||
const actual = await vi.importActual<typeof import('../attachments-store')>('../attachments-store')
|
||||
return {
|
||||
...actual,
|
||||
listAttachmentRows: (...args: unknown[]) => listRowsMock(...args),
|
||||
insertAttachmentRow: (...args: unknown[]) => insertRowMock(...args),
|
||||
getAttachmentRow: (...args: unknown[]) => getRowMock(...args),
|
||||
stampAttachmentRemoved: (...args: unknown[]) => stampMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
attachUnderlag,
|
||||
attachmentStoragePath,
|
||||
listAttachments,
|
||||
ReconciliationAttachmentError,
|
||||
removeUnderlag,
|
||||
} from '../attachments'
|
||||
|
||||
const COMPANY = 'company-1'
|
||||
const USER = 'user-1'
|
||||
// %PDF-1.4 header followed by padding: passes the magic-byte check.
|
||||
const PDF_BYTES = new TextEncoder().encode('%PDF-1.4\n%âãÏÓ\n1 0 obj\n<<>>\nendobj\n').buffer as ArrayBuffer
|
||||
|
||||
function row(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
file_name: 'engagemangsbesked.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: PDF_BYTES.byteLength,
|
||||
storage_bucket: 'documents',
|
||||
storage_path: `documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/x_engagemangsbesked.pdf`,
|
||||
sha256: 'ab'.repeat(32),
|
||||
note: null,
|
||||
uploaded_by: USER,
|
||||
uploaded_at: '2027-01-10T08:00:00Z',
|
||||
removed_at: null,
|
||||
removed_by: null,
|
||||
removed_reason: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
listRowsMock.mockReset()
|
||||
insertRowMock.mockReset()
|
||||
getRowMock.mockReset()
|
||||
stampMock.mockReset()
|
||||
})
|
||||
|
||||
describe('attachmentStoragePath', () => {
|
||||
it('lives under documents/<company>/ so the bucket RLS applies, with the key colon replaced', () => {
|
||||
const p = attachmentStoragePath(COMPANY, 'manual:2350', '2026-12-31', 'Kontoutdrag dec/2026.pdf', 'abc')
|
||||
expect(p).toBe(`documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/abc_Kontoutdrag dec_2026.pdf`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listAttachments', () => {
|
||||
it('returns the public shape and rejects a bad scope', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
listRowsMock.mockResolvedValue([row()])
|
||||
const list = await listAttachments(supabase as never, COMPANY, 'manual:2350', '2026-12-31')
|
||||
expect(list[0]).not.toHaveProperty('storage_path')
|
||||
expect(list[0].file_name).toBe('engagemangsbesked.pdf')
|
||||
await expect(listAttachments(supabase as never, COMPANY, '2350', '2026-12-31')).rejects.toMatchObject({ code: 'INVALID_ACCOUNT_KEY' })
|
||||
await expect(listAttachments(supabase as never, COMPANY, 'manual:2350', '31/12/2026')).rejects.toMatchObject({ code: 'INVALID_DATE' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachUnderlag', () => {
|
||||
it('validates, hashes, uploads to the documents bucket, then records the row', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
insertRowMock.mockImplementation(async (_s: unknown, _c: unknown, input: Record<string, unknown>) => row(input))
|
||||
const result = await attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
note: ' Engagemangsbesked ',
|
||||
file: { name: 'engagemangsbesked.pdf', type: 'application/pdf', size: PDF_BYTES.byteLength, buffer: PDF_BYTES },
|
||||
})
|
||||
const upload = supabase.storage.from('documents').upload as ReturnType<typeof vi.fn>
|
||||
expect(supabase.storage.from).toHaveBeenCalledWith('documents')
|
||||
const [path, , opts] = upload.mock.calls[0] as [string, unknown, { contentType: string; upsert: boolean }]
|
||||
expect(path).toMatch(new RegExp(`^documents/${COMPANY}/reconciliation/manual_2350/2026-12-31/`))
|
||||
expect(opts).toEqual({ contentType: 'application/pdf', upsert: false })
|
||||
expect(insertRowMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
COMPANY,
|
||||
expect.objectContaining({
|
||||
account_key: 'manual:2350',
|
||||
through_date: '2026-12-31',
|
||||
note: 'Engagemangsbesked',
|
||||
uploaded_by: USER,
|
||||
storage_bucket: 'documents',
|
||||
sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
}),
|
||||
)
|
||||
expect(result.file_name).toBe('engagemangsbesked.pdf')
|
||||
})
|
||||
|
||||
it('refuses unsupported types and content that does not match the declared type without uploading', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
file: { name: 'x.csv', type: 'text/csv', size: 10, buffer: new ArrayBuffer(10) },
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ReconciliationAttachmentError)
|
||||
await expect(
|
||||
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
file: { name: 'x.pdf', type: 'application/pdf', size: 10, buffer: new ArrayBuffer(10) },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'INVALID_FILE' })
|
||||
expect(supabase.storage.from('documents').upload).not.toHaveBeenCalled()
|
||||
expect(insertRowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not record a row when the upload fails', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
;(supabase.storage.from('documents').upload as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
data: null,
|
||||
error: { message: 'bucket down' },
|
||||
})
|
||||
await expect(
|
||||
attachUnderlag(supabase as never, COMPANY, USER, 'manual:2350', {
|
||||
through_date: '2026-12-31',
|
||||
file: { name: 'x.pdf', type: 'application/pdf', size: PDF_BYTES.byteLength, buffer: PDF_BYTES },
|
||||
}),
|
||||
).rejects.toThrow(/bucket down/)
|
||||
expect(insertRowMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeUnderlag', () => {
|
||||
it('stamps an active row, refuses a removed one, and returns null for an unknown id', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
getRowMock.mockResolvedValue(row())
|
||||
stampMock.mockResolvedValue(row({ removed_at: '2027-01-11T08:00:00Z', removed_by: USER, removed_reason: 'fel fil' }))
|
||||
const removed = await removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id, { reason: ' fel fil ' })
|
||||
expect(removed?.removed_reason).toBe('fel fil')
|
||||
expect(stampMock).toHaveBeenCalledWith(supabase, COMPANY, row().id, { removed_by: USER, reason: 'fel fil' })
|
||||
|
||||
getRowMock.mockResolvedValue(row({ removed_at: '2027-01-11T08:00:00Z', removed_by: USER }))
|
||||
await expect(removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id)).rejects.toMatchObject({ code: 'ALREADY_REMOVED' })
|
||||
|
||||
getRowMock.mockResolvedValue(null)
|
||||
expect(await removeUnderlag(supabase as never, COMPANY, USER, 'manual:2350', row().id)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Binary file not shown.
@@ -80,6 +80,24 @@ export const ReconciliationSignoffSchema = z.object({
|
||||
})
|
||||
export type ReconciliationSignoff = z.infer<typeof ReconciliationSignoffSchema>
|
||||
|
||||
/** One underlag file attached to an account's balansdag (account_reconciliation_attachments). */
|
||||
export const ReconciliationAttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
account_key: AccountKeySchema,
|
||||
through_date: z.string(),
|
||||
file_name: z.string(),
|
||||
mime_type: z.string(),
|
||||
size_bytes: z.number().int(),
|
||||
sha256: z.string(),
|
||||
note: z.string().nullable(),
|
||||
uploaded_by: z.string(),
|
||||
uploaded_at: z.string(),
|
||||
removed_at: z.string().nullable(),
|
||||
removed_by: z.string().nullable(),
|
||||
removed_reason: z.string().nullable(),
|
||||
})
|
||||
export type ReconciliationAttachment = z.infer<typeof ReconciliationAttachmentSchema>
|
||||
|
||||
export const ReconciliationAccountSchema = z.object({
|
||||
account_key: AccountKeySchema,
|
||||
kind: ReconciliationKindSchema,
|
||||
|
||||
Reference in New Issue
Block a user