Fix/attributes config (#926)

* fix(git): pin LF on generated extension registry and vitest snapshots

setup:extensions and vitest write these files with LF; with
core.autocrlf=true git expects CRLF and flags them as phantom
modifications on every dev/build run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route

mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt.

Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route transactions endpoints through withRouteContext

Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route SIE import and bank reconciliation through withRouteContext

Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route salary endpoints through withRouteContext

Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route report endpoints through withRouteContext

Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext

Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route documents, events, team and account endpoints through withRouteContext

Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): route settings and pending-operations endpoints through withRouteContext

Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration

Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil"

Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by
upload instead of typing every ruta into the form. Extract buildFiledAmounts()
as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so
the XML file and the manual-filing PDF can never disagree. Adds the /eskd API
route, an XML option in the report export menu, and the upload button on the
manual-filing card. Strings in sv + en.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(vat): add 'vat_settlement' source type and update related components

* fix(booking): adjust search input layout and enable autofocus

* fix(vat): support 12-digit org numbers and adjust emission order for eSKD file

* fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-08 09:54:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 63e05c4eec
commit abe9ac9d8c
204 changed files with 9841 additions and 6362 deletions
@@ -17,7 +17,7 @@ import type { BookingTemplateLibrary, BookingTemplateCategory, EntityType } from
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
interface Props {
onApply: (lines: FormLine[], description: string) => void
onApply: (lines: FormLine[], description: string, category?: BookingTemplateCategory) => void
entityType?: EntityType
/** Prefill the "total amount" field when the caller already knows it (e.g.
* booking from an underlag with a known total). The user can still edit it. */
@@ -114,7 +114,7 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou
const lines = applyTemplate(selected.lines, totalAmount)
// Fire-and-forget MRU bump so this template surfaces at the top next time.
fetch(`/api/settings/booking-templates/${selected.id}/touch`, { method: 'POST' }).catch(() => {})
onApply(lines, selected.name)
onApply(lines, selected.name, selected.category)
setOpen(false)
setSelectedId(null)
setAmount('')
@@ -135,14 +135,15 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou
</DialogHeader>
{/* Search + category filter */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<div className="flex flex-col gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Sök mall..."
className="pl-9"
autoFocus
/>
</div>
<div className="flex gap-1 flex-wrap">
+67 -14
View File
@@ -21,6 +21,7 @@ import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library'
import { sourceTypeForTemplateCategory } from '@/lib/bookkeeping/template-source-type'
import { TemplateForm } from '@/components/settings/TemplateForm'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
@@ -37,7 +38,7 @@ import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCompany } from '@/contexts/CompanyContext'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary } from '@/types'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
const CURRENCIES: { value: Currency; label: string }[] = [
@@ -134,6 +135,23 @@ export default function JournalEntryForm({
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
)
const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A')
// The source_type the entry will be committed with. Seeded from the prop
// (undefined -> 'manual' for the standalone form). Applying a booking template
// whose category maps to a dedicated source type (e.g. VAT -> vat_settlement)
// flips this so the entry lands in that type's configured voucher series.
const [effectiveSourceType, setEffectiveSourceType] = useState<JournalEntrySourceType>(
sourceType ?? 'manual',
)
// Cache of the company's series config so template routing can re-resolve the
// default series without re-fetching /api/settings. Populated by the settings
// effect below.
const seriesMapRef = useRef<Record<string, string> | null>(null)
const defaultSeriesRef = useRef<string>('A')
// Mirror of effectiveSourceType for the settings-fetch callback: if a template
// routed the source type before /api/settings resolved, the late callback must
// re-apply the series for the ROUTED type, not the mount-time base (otherwise
// the entry submits as vat_settlement in the manual series).
const effectiveSourceTypeRef = useRef<JournalEntrySourceType>(sourceType ?? 'manual')
const [nextVoucherNumber, setNextVoucherNumber] = useState<number | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the
@@ -222,6 +240,15 @@ export default function JournalEntryForm({
setAccounts(data || [])
}
// Resolve + set the default voucher series for a source type from the cached
// company config: prefer the per-source-type mapping, fall back to the legacy
// default_voucher_series, then to 'A'. Reads refs (stable), so it can run both
// on load and when template application changes the source type.
const applySeriesForSourceType = useCallback((st: JournalEntrySourceType) => {
const perSource = resolveDefaultSeriesForSource(seriesMapRef.current, st)
setVoucherSeries(perSource !== 'A' ? perSource : defaultSeriesRef.current || 'A')
}, [])
useEffect(() => {
fetchPeriods()
fetchAccounts()
@@ -235,17 +262,14 @@ export default function JournalEntryForm({
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
if (!data) return
setDimensionsEnabled(data.dimensions_enabled === true)
seriesMapRef.current =
(data.default_voucher_series_per_source_type as Record<string, string> | null) ?? null
defaultSeriesRef.current = data.default_voucher_series || 'A'
if (!embedded && !editEntryId) {
const effectiveSourceType = sourceType ?? 'manual'
const perSource = resolveDefaultSeriesForSource(
data as { default_voucher_series_per_source_type?: Record<string, string> | null } | null,
effectiveSourceType,
)
const fallback = data.default_voucher_series || 'A'
setVoucherSeries(perSource !== 'A' ? perSource : fallback)
applySeriesForSourceType(effectiveSourceTypeRef.current)
}
}).catch(() => {/* keep 'A' + hidden dimension affordances */})
}, [embedded, sourceType, editEntryId])
}, [embedded, sourceType, editEntryId, applySeriesForSourceType])
// Auto-select period when entry date changes
useEffect(() => {
@@ -650,15 +674,36 @@ export default function JournalEntryForm({
const selectedPeriodObj = periods.find((p) => p.id === selectedPeriod)
const selectedPeriodLocked = !!(selectedPeriodObj?.locked_at || selectedPeriodObj?.is_closed)
const handleTemplateApply = (templateLines: FormLine[], templateDescription: string) => {
const handleTemplateApply = (
templateLines: FormLine[],
templateDescription: string,
category?: BookingTemplateCategory,
) => {
setLines(templateLines)
if (!description) setDescription(templateDescription)
// Route templates whose category maps to a dedicated source type (VAT ->
// vat_settlement) so the entry books into that type's configured series.
// Create mode only: embedded/edit keep their caller-provided source type.
// Non-mapped categories fall back to the form's base source type, which
// also reverts a prior VAT routing if the user swaps templates.
if (!embedded && !editEntryId) {
const base = sourceType ?? 'manual'
const routed = sourceTypeForTemplateCategory(category) ?? base
if (routed !== effectiveSourceType) {
setEffectiveSourceType(routed)
effectiveSourceTypeRef.current = routed
applySeriesForSourceType(routed)
}
}
}
// Wipe the form back to a blank entry. Mirrors the post-submit reset: it
// clears the data the user typed (lines, description, note, attachments,
// currency) but keeps the contextual defaults (period, date, series) so the
// form is immediately ready for the next entry.
// currency) but keeps the contextual defaults (period, date) so the form is
// immediately ready for the next entry. Template-routed source type does NOT
// survive a clear: the next entry is hand-typed, and a sticky vat_settlement
// would tag it into the moms series and skip the manual-entry underlag
// tracking. Posted entries are immutable, so that mistag is storno-only.
const handleClearAll = () => {
setDescription('')
setNotes('')
@@ -668,6 +713,14 @@ export default function JournalEntryForm({
setEntryCurrency('SEK')
setExchangeRate('')
setForeignAmount('')
if (!embedded && !editEntryId) {
const base = sourceType ?? 'manual'
if (base !== effectiveSourceType) {
setEffectiveSourceType(base)
effectiveSourceTypeRef.current = base
applySeriesForSourceType(base)
}
}
}
const handleOpenCreateAccount = (lineIndex: number, prefill: string) => {
@@ -810,7 +863,7 @@ export default function JournalEntryForm({
fiscal_period_id: selectedPeriod,
entry_date: entryDate,
description,
source_type: sourceType ?? 'manual',
source_type: effectiveSourceType,
source_id: sourceId,
voucher_series: voucherSeries || 'A',
notes: notes || undefined,
@@ -822,7 +875,7 @@ export default function JournalEntryForm({
}),
})
return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string }
}, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes])
}, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes])
const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } =
useSubmitWithAccountActivation(postJournalEntry)
@@ -36,6 +36,7 @@ const SOURCE_TYPES = [
'supplier_invoice_cash_payment',
'currency_revaluation',
'reminder_fee',
'vat_settlement',
] as const
/**
+11 -4
View File
@@ -1,6 +1,6 @@
'use client'
import { Download, FileSpreadsheet, FileText, Table } from 'lucide-react'
import { Download, FileCode, FileSpreadsheet, FileText, Table } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
@@ -10,8 +10,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
/** pdf/xlsx for reports; csv is additionally used by register exports. */
export type ExportMenuFormat = 'pdf' | 'xlsx' | 'csv'
/**
* pdf/xlsx for reports; csv is additionally used by register exports; xml is the
* Skatteverket eSKD momsdeklaration file.
*/
export type ExportMenuFormat = 'pdf' | 'xlsx' | 'csv' | 'xml'
export interface ReportExportItem {
format: ExportMenuFormat
@@ -56,6 +59,8 @@ export function ReportExportMenu({
<FileText className="h-4 w-4 mr-2" />
) : item.format === 'csv' ? (
<Table className="h-4 w-4 mr-2" />
) : item.format === 'xml' ? (
<FileCode className="h-4 w-4 mr-2" />
) : (
<FileSpreadsheet className="h-4 w-4 mr-2" />
)}
@@ -63,7 +68,9 @@ export function ReportExportMenu({
? t('download_pdf')
: item.format === 'csv'
? t('download_csv')
: t('download_excel')}
: item.format === 'xml'
? t('download_xml')
: t('download_excel')}
</DropdownMenuItem>
))}
</DropdownMenuContent>
+19 -10
View File
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileDown, Percent } from 'lucide-react'
import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
import { Skeleton } from '@/components/ui/skeleton'
import { EmptyState } from '@/components/ui/empty-state'
@@ -1052,11 +1052,12 @@ const SKATTEVERKET_MOMS_URL =
* Manual-filing affordance shown directly under the calculated momsdeklaration.
* The report is generated purely from the bookkeeping and never depends on the
* Skatteverket connection, so every user (including core builds with the
* skatteverket extension disabled) can file manually. This card makes that
* explicit and offers the momsdeklaration as a PDF (in hela kronor) to read off
* while filling in the form at skatteverket.se.
* skatteverket extension disabled) can file manually. Two paths are offered:
* an eSKD XML file to upload directly under "Deklarera via fil" (the fast path),
* and a PDF (in hela kronor) to read off if the user would rather type the
* boxes into the form. A PDF cannot be uploaded to Skatteverket, only the XML.
*/
function VatManualFilingCard({ pdfHref }: { pdfHref: string }) {
function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: string }) {
return (
<Card>
<CardHeader>
@@ -1065,15 +1066,21 @@ function VatManualFilingCard({ pdfHref }: { pdfHref: string }) {
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Deklarationen ovan är klar. Du behöver inte vara ansluten till
Skatteverket för att lämna in den, du kan fylla i beloppen direkt hos
Skatteverket.
Skatteverket för att lämna in den.
</p>
<p className="text-sm text-muted-foreground">
Ladda ner momsdeklarationen som PDF (belopp i hela kronor). Logga sedan
in på skatteverket.se med BankID, öppna Moms- och
arbetsgivardeklarationer och skriv in rutorna.
Enklast: ladda ner filen (XML), logga in på skatteverket.se med BankID,
öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil.
Ladda upp filen, granska och signera. Vill du hellre fylla i rutorna
för hand laddar du ner PDF:en och skriver av beloppen (i hela kronor).
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<a href={xmlHref} target="_blank" rel="noopener noreferrer">
<FileCode className="h-4 w-4" />
Ladda ner fil för uppladdning (XML)
</a>
</Button>
<Button variant="outline" size="sm" asChild className="gap-2">
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
<FileDown className="h-4 w-4" />
@@ -1293,6 +1300,7 @@ export function VatDeclarationView() {
<div className="space-y-4">
<ReportExportMenu
items={[
{ format: 'xml', href: `/api/reports/vat-declaration/eskd?${vatQueryString()}` },
{ format: 'pdf', href: `/api/reports/vat-declaration/pdf?${vatQueryString()}` },
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
]}
@@ -1594,6 +1602,7 @@ export function VatDeclarationView() {
</Card>
<VatManualFilingCard
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
/>
</div>
@@ -34,6 +34,7 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
{ key: 'salary_payment', labelKey: 'salary_payment' },
{ key: 'bank_transaction', labelKey: 'bank_transaction' },
{ key: 'reminder_fee', labelKey: 'reminder_fee' },
{ key: 'vat_settlement', labelKey: 'vat_settlement' },
{ key: 'opening_balance', labelKey: 'opening_balance' },
{ key: 'year_end', labelKey: 'year_end' },
]
@@ -53,6 +54,7 @@ const SV_LABELS: Record<string, string> = {
salary_payment: 'Lön',
bank_transaction: 'Banktransaktioner',
reminder_fee: 'Påminnelseavgifter',
vat_settlement: 'Momsredovisning',
opening_balance: 'Ingående balanser',
year_end: 'Bokslut',
}