4dfd790de5
* 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>
134 lines
4.7 KiB
TypeScript
134 lines
4.7 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
|
import { suggestMappings } from '@/lib/import/account-mapper'
|
|
import { executeSIEImport, checkDuplicateImport } from '@/lib/import/sie-import'
|
|
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
|
|
|
|
// SIE imports with many vouchers need extended execution time
|
|
export const maxDuration = 300
|
|
|
|
/** POST /api/import/sie/execute — execute the SIE import. */
|
|
export const POST = withRouteContext(
|
|
'sie_import.execute',
|
|
async (request, ctx) => {
|
|
const { user, supabase, companyId, log, requestId } = ctx
|
|
|
|
const formData = await request.formData()
|
|
const file = formData.get('file') as File | null
|
|
const mappingsJson = formData.get('mappings') as string | null
|
|
const optionsJson = formData.get('options') as string | null
|
|
|
|
if (!file) {
|
|
return errorResponseFromCode('SIE_PARSE_NO_FILE', log, { requestId })
|
|
}
|
|
|
|
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
|
|
|
|
try {
|
|
// The voucherSeries option is a fallback for vouchers that arrive without
|
|
// a series (SIE4I subsystem files); the import engine preserves each
|
|
// #VER's source series per voucher.
|
|
const parsedOptions = optionsJson ? JSON.parse(optionsJson) : null
|
|
const { data: companySettings } = await supabase
|
|
.from('company_settings')
|
|
.select('default_voucher_series')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
const companyDefaultSeries = companySettings?.default_voucher_series || 'B'
|
|
|
|
const options = parsedOptions ?? {
|
|
createFiscalPeriod: true,
|
|
importOpeningBalances: true,
|
|
importTransactions: true,
|
|
voucherSeries: companyDefaultSeries,
|
|
updateAccountNames: true,
|
|
}
|
|
|
|
const arrayBuffer = await file.arrayBuffer()
|
|
const encoding = detectEncoding(arrayBuffer)
|
|
const content = decodeBuffer(arrayBuffer, encoding)
|
|
|
|
const parsed = parseSIEFile(content)
|
|
|
|
const duplicate = await checkDuplicateImport(supabase, companyId!, content)
|
|
if (duplicate) {
|
|
return errorResponseFromCode('SIE_DUPLICATE_FILE', opLog, {
|
|
requestId,
|
|
details: { importId: duplicate.id, importedAt: duplicate.imported_at },
|
|
})
|
|
}
|
|
|
|
let mappings: AccountMapping[]
|
|
|
|
if (mappingsJson) {
|
|
mappings = JSON.parse(mappingsJson)
|
|
} else {
|
|
const { data: storedMappings } = await supabase
|
|
.from('sie_account_mappings')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
|
|
mappings = suggestMappings(
|
|
parsed.accounts,
|
|
BAS_REFERENCE,
|
|
(storedMappings as SIEAccountMappingRecord[]) || undefined,
|
|
)
|
|
}
|
|
|
|
const unmapped = mappings.filter((m) => !m.targetAccount)
|
|
if (unmapped.length > 0) {
|
|
return errorResponseFromCode('SIE_IMPORT_UNMAPPED_ACCOUNTS', opLog, {
|
|
requestId,
|
|
details: {
|
|
unmappedCount: unmapped.length,
|
|
unmappedAccounts: unmapped.slice(0, 5).map((m) => ({
|
|
account: m.sourceAccount,
|
|
name: m.sourceName,
|
|
})),
|
|
},
|
|
})
|
|
}
|
|
|
|
// Account creation (and #KONTO renames) happen inside executeSIEImport
|
|
// via syncMappedAccounts — the pre-create block that used to live here
|
|
// was a duplicate of that logic.
|
|
const result = await executeSIEImport(
|
|
supabase,
|
|
companyId!,
|
|
user.id,
|
|
parsed,
|
|
mappings,
|
|
{
|
|
filename: file.name,
|
|
fileContent: content,
|
|
createFiscalPeriod: options.createFiscalPeriod,
|
|
importOpeningBalances: options.importOpeningBalances,
|
|
importTransactions: options.importTransactions,
|
|
voucherSeries: options.voucherSeries || companyDefaultSeries,
|
|
updateAccountNames: options.updateAccountNames ?? true,
|
|
markImportedNoDocRequired: options.markImportedNoDocRequired ?? false,
|
|
},
|
|
)
|
|
|
|
if (!result.success) {
|
|
return errorResponseFromCode('SIE_IMPORT_FAILED', opLog, {
|
|
requestId,
|
|
details: { result },
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ success: true, result })
|
|
} catch (err) {
|
|
opLog.error('sie execute unexpected error', err as Error)
|
|
return errorResponseFromCode('SIE_IMPORT_UNEXPECTED', opLog, {
|
|
requestId,
|
|
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
|
})
|
|
}
|
|
},
|
|
{ requireWrite: true },
|
|
)
|