feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions (#698)
* feat(bookkeeping): Ny verifikat modal, ledger-style list, SIE no-underlag exemptions Verifikat UX - "Ny verifikat" opens in a modal (NewJournalEntryDialog) instead of an inline tab; the review step renders inline in the dialog rather than stacking a second dialog. - JournalEntryForm: konteringsrader are the focus, with a compact pre-filled metadata bar (datum/serie/text/valuta/period) on top; verifikationstext auto-fills from the first row's account. - JournalEntryList: belopp shown on collapsed rows; expanded view is an aligned Konto/Benämning/Debet/Kredit table. SIE imports no longer flood "Att hantera: saknade underlag" - Import gains an opt-in (off by default) toggle to mark imported verifikat as "Inget underlag krävs"; a "Rekommenderas vid migrering" badge nudges it for historical years. - Multi-select batch-mark in the list for selective cleanup. - Filter-scoped bulk mark (POST /api/bookkeeping/no-doc-required/bulk-missing): marks every missing-doc verifikat matching the active filters across all pages, with a dry_run count to confirm scope — the scalable remedy for a post-import flood. - Shared helper markEntriesNoDocRequired + per-entry batch route. Tests: no-doc helper, batch route, bulk-missing route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): address PR #698 review findings - JournalEntryForm: restore the explicit "no underlag" acknowledgement in the modal's inline review. When no document is attached, the confirm button reads "Bokför utan underlag" (BFL 5 kap 6-7 §§), equivalent to the blocking dialog the non-bare flow shows — the bare path no longer posts behind only a passive banner. - batch no-doc route: guard the ownership query with source_type IN NEEDS_DOC_SOURCE_TYPES so a crafted request can't exempt non-document-requiring entries (defense in depth on top of company + posted scoping). - bulk-missing route: resolve doc/exemption status by querying only the candidate ids (chunked) instead of loading the company's full document_attachments and journal_entry_no_doc_required tables into memory — data minimisation + bounded memory for large migrations (the most-repeated reviewer finding). Triaged as non-issues (left as-is): partial-import exemption (gated on result.success == zero errors), reason write-back (sidecar row is FK-linked and carries the reason), and "bulk-exempting manual entries" (consistent with the existing per-entry NoDocRequiredToggle). No DB migration — reuses the existing journal_entry_no_doc_required table. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(bookkeeping): centralize bulk-missing date/series validation in Zod Move the ISO-date and verifikationsserie format checks into the Zod schema so malformed input is rejected with a clean 400 instead of being silently nulled (or, for a shaped-but-invalid date, throwing a 500 via fetchAllRows). The date refinement rejects values like 9999-99-99 / 2026-02-30 that a bare /^\d{4}-\d{2}-\d{2}$/ regex lets through. Addresses the PR #698 reviewer nit on split schema-vs-runtime validation. +2 route tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
64991eb3c9
commit
4dfd790de5
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
function makeMock() {
|
||||
const upsert = vi.fn().mockResolvedValue({ error: null })
|
||||
const from = vi.fn().mockReturnValue({ upsert })
|
||||
return { supabase: { from } as unknown as SupabaseClient, upsert, from }
|
||||
}
|
||||
|
||||
describe('markEntriesNoDocRequired', () => {
|
||||
it('writes nothing and returns 0 for an empty list', async () => {
|
||||
const { supabase, from } = makeMock()
|
||||
const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', [], null)
|
||||
expect(n).toBe(0)
|
||||
expect(from).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inserts one chunk with the right rows, reason and conflict options', async () => {
|
||||
const { supabase, upsert, from } = makeMock()
|
||||
const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', ['a', 'b'], 'Importerad')
|
||||
expect(n).toBe(2)
|
||||
expect(from).toHaveBeenCalledWith('journal_entry_no_doc_required')
|
||||
expect(upsert).toHaveBeenCalledTimes(1)
|
||||
const [rows, opts] = upsert.mock.calls[0]
|
||||
expect(rows).toEqual([
|
||||
{ journal_entry_id: 'a', company_id: 'c1', user_id: 'u1', reason: 'Importerad' },
|
||||
{ journal_entry_id: 'b', company_id: 'c1', user_id: 'u1', reason: 'Importerad' },
|
||||
])
|
||||
expect(opts).toEqual({ onConflict: 'journal_entry_id', ignoreDuplicates: true })
|
||||
})
|
||||
|
||||
it('de-dupes ids and defaults reason to null', async () => {
|
||||
const { supabase, upsert } = makeMock()
|
||||
const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', ['a', 'a', 'b'], null)
|
||||
expect(n).toBe(2)
|
||||
const [rows] = upsert.mock.calls[0]
|
||||
expect(rows.map((r: { journal_entry_id: string }) => r.journal_entry_id)).toEqual(['a', 'b'])
|
||||
expect(rows[0].reason).toBeNull()
|
||||
})
|
||||
|
||||
it('chunks large id lists into batches of 500', async () => {
|
||||
const { supabase, upsert } = makeMock()
|
||||
const ids = Array.from({ length: 1200 }, (_, i) => `id-${i}`)
|
||||
const n = await markEntriesNoDocRequired(supabase, 'c1', 'u1', ids, null)
|
||||
expect(n).toBe(1200)
|
||||
expect(upsert).toHaveBeenCalledTimes(3) // 500 + 500 + 200
|
||||
expect(upsert.mock.calls[0][0]).toHaveLength(500)
|
||||
expect(upsert.mock.calls[1][0]).toHaveLength(500)
|
||||
expect(upsert.mock.calls[2][0]).toHaveLength(200)
|
||||
})
|
||||
|
||||
it('throws when an upsert errors', async () => {
|
||||
const upsert = vi.fn().mockResolvedValue({ error: { message: 'boom' } })
|
||||
const supabase = { from: vi.fn().mockReturnValue({ upsert }) } as unknown as SupabaseClient
|
||||
await expect(
|
||||
markEntriesNoDocRequired(supabase, 'c1', 'u1', ['a'], null),
|
||||
).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const CHUNK_SIZE = 500
|
||||
|
||||
/**
|
||||
* Bulk-mark posted journal entries as "Inget underlag krävs" (no supporting
|
||||
* document required) by inserting rows into journal_entry_no_doc_required.
|
||||
*
|
||||
* The flag lives in a sidecar table so the verifikation itself stays immutable
|
||||
* per BFL — same write the single-entry route performs, just batched. Inserts
|
||||
* are chunked (Postgres/PostgREST payload safety) and idempotent: rows that
|
||||
* already exist are left untouched (`ignoreDuplicates`).
|
||||
*
|
||||
* The caller is responsible for passing only entry IDs that belong to
|
||||
* `companyId` and are eligible (posted, document-requiring source type); RLS on
|
||||
* the table is the security backstop. Used by the SIE-import opt-in auto-exempt
|
||||
* flow and the batch-mark endpoint.
|
||||
*
|
||||
* @returns the number of entry IDs processed (deduped), not the number of new rows.
|
||||
*/
|
||||
export async function markEntriesNoDocRequired(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
entryIds: string[],
|
||||
reason: string | null,
|
||||
): Promise<number> {
|
||||
if (entryIds.length === 0) return 0
|
||||
|
||||
// De-dupe so a chunk can never carry the same id twice (ON CONFLICT target).
|
||||
const uniqueIds = Array.from(new Set(entryIds))
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += CHUNK_SIZE) {
|
||||
const chunk = uniqueIds.slice(i, i + CHUNK_SIZE)
|
||||
const rows = chunk.map((journal_entry_id) => ({
|
||||
journal_entry_id,
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
reason: reason ?? null,
|
||||
}))
|
||||
|
||||
const { error } = await supabase
|
||||
.from('journal_entry_no_doc_required')
|
||||
.upsert(rows, { onConflict: 'journal_entry_id', ignoreDuplicates: true })
|
||||
|
||||
if (error) throw new Error(error.message)
|
||||
}
|
||||
|
||||
return uniqueIds.length
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { classifyAccount } from '@/lib/bookkeeping/account-classifier'
|
||||
import { computeSRUCode } from '@/lib/bookkeeping/bas-data/sru-mapping'
|
||||
import { populateTemplatesFromSieVouchers } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { markEntriesNoDocRequired } from '@/lib/bookkeeping/no-doc-required'
|
||||
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
|
||||
|
||||
/**
|
||||
@@ -871,6 +872,10 @@ export async function importVouchers(
|
||||
): Promise<{
|
||||
created: number
|
||||
ids: string[]
|
||||
// Subset of `ids` whose entries were inserted with source_type='import' (i.e.
|
||||
// excludes #VER vouchers re-tagged as opening_balance). Used to scope the
|
||||
// opt-in "Inget underlag krävs" auto-exemption to genuinely migrated vouchers.
|
||||
importTypedIds: string[]
|
||||
errors: string[]
|
||||
skippedEmpty: number
|
||||
skippedSingleLine: number
|
||||
@@ -898,6 +903,7 @@ export async function importVouchers(
|
||||
const results = {
|
||||
created: 0,
|
||||
ids: [] as string[],
|
||||
importTypedIds: [] as string[],
|
||||
errors: [] as string[],
|
||||
skippedEmpty: 0,
|
||||
skippedSingleLine: 0,
|
||||
@@ -1287,6 +1293,11 @@ export async function importVouchers(
|
||||
})
|
||||
|
||||
results.ids.push(entryId)
|
||||
// #VER vouchers re-tagged as opening_balance never need an underlag and
|
||||
// aren't in NEEDS_DOC_SOURCE_TYPES, so keep them out of the exempt set.
|
||||
if (voucher.sourceType === 'import') {
|
||||
results.importTypedIds.push(entryId)
|
||||
}
|
||||
results.created++
|
||||
}
|
||||
|
||||
@@ -1864,6 +1875,10 @@ export async function executeSIEImport(
|
||||
voucherSeries?: string
|
||||
onExistingPeriod?: 'block' | 'replace'
|
||||
updateAccountNames?: boolean
|
||||
// Opt-in: mark every imported (source_type='import') verifikat as "Inget
|
||||
// underlag krävs" so a multi-year migration doesn't flood "Att hantera:
|
||||
// saknade underlag" with thousands of items. OFF by default.
|
||||
markImportedNoDocRequired?: boolean
|
||||
}
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
@@ -1878,6 +1893,11 @@ export async function executeSIEImport(
|
||||
replacedPriorImport: null,
|
||||
}
|
||||
|
||||
// Collected source_type='import' entry ids (vouchers + migration adjustment),
|
||||
// used only when options.markImportedNoDocRequired is set. Kept separate from
|
||||
// result.journalEntryIds because that also holds opening_balance entries.
|
||||
const importTypedEntryIds: string[] = []
|
||||
|
||||
const onExistingPeriod = options.onExistingPeriod ?? 'block'
|
||||
const updateAccountNames = options.updateAccountNames ?? true
|
||||
|
||||
@@ -2285,6 +2305,7 @@ export async function executeSIEImport(
|
||||
|
||||
result.journalEntriesCreated += voucherResults.created
|
||||
result.journalEntryIds.push(...voucherResults.ids)
|
||||
importTypedEntryIds.push(...voucherResults.importTypedIds)
|
||||
result.errors.push(...voucherResults.errors)
|
||||
voucherNumberMapping = voucherResults.voucherNumberMapping
|
||||
voucherSeriesUsed = voucherResults.seriesUsed
|
||||
@@ -2346,6 +2367,8 @@ export async function executeSIEImport(
|
||||
if (adjustment.entryId) {
|
||||
result.journalEntriesCreated++
|
||||
result.journalEntryIds.push(adjustment.entryId)
|
||||
// The omföringsverifikation is source_type='import' too.
|
||||
importTypedEntryIds.push(adjustment.entryId)
|
||||
result.warnings.push(
|
||||
`Migreringsjustering skapad: ${adjustment.deltaAccounts} konton justerade för att matcha UB/RES från källsystemet`
|
||||
)
|
||||
@@ -2501,6 +2524,28 @@ export async function executeSIEImport(
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in: mark imported verifikat as "Inget underlag krävs" (non-blocking).
|
||||
// Migrated vouchers carry their underlag in the source system, so the user
|
||||
// can choose to keep all of them out of "Att hantera: saknade underlag" in
|
||||
// one go instead of clearing thousands of items by hand. Data is already
|
||||
// committed at this point, so a failure here only loses the convenience.
|
||||
if (result.success && options.markImportedNoDocRequired && importTypedEntryIds.length > 0) {
|
||||
try {
|
||||
await markEntriesNoDocRequired(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
importTypedEntryIds,
|
||||
'Importerad från tidigare system (SIE)',
|
||||
)
|
||||
} catch (exemptError) {
|
||||
console.error('[sie-import] Failed to mark imported entries no-doc-required (non-fatal):', exemptError)
|
||||
result.warnings.push(
|
||||
'Kunde inte markera importerade verifikat som "Inget underlag krävs" — du kan markera dem manuellt i bokföringslistan.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Add warnings for any issues
|
||||
for (const issue of parsed.issues) {
|
||||
if (issue.severity === 'warning') {
|
||||
|
||||
@@ -234,6 +234,10 @@ export interface ImportOptions {
|
||||
|
||||
// Voucher series to use for imported entries
|
||||
voucherSeries?: string
|
||||
|
||||
// Opt-in: mark imported verifikat as "Inget underlag krävs" so a migration
|
||||
// doesn't flood "Att hantera: saknade underlag". OFF by default.
|
||||
markImportedNoDocRequired?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user