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:
Jakob Wennberg
2026-06-08 20:41:30 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 64991eb3c9
commit 4dfd790de5
16 changed files with 1327 additions and 228 deletions
+45
View File
@@ -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') {
+4
View File
@@ -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
}
/**