diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 90ba36d8..f3f96914 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -23,6 +23,8 @@ import { BookOpen, } from 'lucide-react' import type { PendingOperation, PendingOperationStatus } from '@/types' +import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview' +import { MatchTransactionInvoicePreview } from '@/components/bookkeeping/MatchTransactionInvoicePreview' const operationLabels: Record = { categorize_transaction: { label: 'Kategorisering', icon: ArrowLeftRight, variant: 'default' }, @@ -375,6 +377,10 @@ function OperationPreview({ op }: { op: PendingOperation }) { return case 'correct_entry': return + case 'attach_document_to_transaction': + return + case 'match_transaction_invoice': + return default: return } diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 4ad9647e..ab5c7ca3 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -107,6 +107,17 @@ export default function NewSupplierInvoicePage() { const { canWrite } = useCanWrite() const { toast } = useToast() + // When opened from an invoice-inbox item, every redirect should land the + // user back in the inbox so they can pick the next document. Outside the + // inbox flow, preserve the original behavior (detail page when we have an + // invoice id, otherwise the list). + const afterCreate = (invoiceId?: string) => + inboxItemId + ? '/e/general/invoice-inbox' + : invoiceId + ? `/supplier-invoices/${invoiceId}` + : '/supplier-invoices' + const [suppliers, setSuppliers] = useState([]) const [suppliersLoaded, setSuppliersLoaded] = useState(false) const [accounts, setAccounts] = useState([]) @@ -624,7 +635,7 @@ export default function NewSupplierInvoicePage() { title: 'Utlägg registrerat', description: `Ankomstnummer: ${result.data.arrival_number}`, }) - router.push('/supplier-invoices') + router.push(afterCreate()) setIsSubmitting(false) return } @@ -637,10 +648,10 @@ export default function NewSupplierInvoicePage() { description: 'Fakturan skapades men kunde inte godkännas automatiskt', variant: 'destructive', }) - router.push(`/supplier-invoices/${result.data.id}`) + router.push(afterCreate(result.data.id)) } else { toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` }) - router.push('/supplier-invoices') + router.push(afterCreate()) } setIsSubmitting(false) } @@ -686,7 +697,7 @@ export default function NewSupplierInvoicePage() { toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${arrivalNumber}` }) } - router.push(`/supplier-invoices/${invoiceId}`) + router.push(afterCreate(invoiceId)) } else { // Treat duplicate-number as a recoverable conflict; everything else as a hard error. if (status === 409 && result.error === 'duplicate_supplier_invoice_number') { @@ -751,7 +762,7 @@ export default function NewSupplierInvoicePage() { description: `Ankomstnummer: ${result.data.arrival_number}`, }) reset(pendingData) - router.push(`/supplier-invoices/${result.data.id}`) + router.push(afterCreate(result.data.id)) return } @@ -828,13 +839,18 @@ export default function NewSupplierInvoicePage() { }) } reset(pendingData) - router.push(`/supplier-invoices/${invoiceId}`) + router.push(afterCreate(invoiceId)) } return (
-
@@ -1320,7 +1336,7 @@ export default function NewSupplierInvoicePage() { {/* Submit */}
- {!watchedPaidPrivately && ( diff --git a/components/bookkeeping/AttachDocumentPreview.tsx b/components/bookkeeping/AttachDocumentPreview.tsx new file mode 100644 index 00000000..fd7688bb --- /dev/null +++ b/components/bookkeeping/AttachDocumentPreview.tsx @@ -0,0 +1,114 @@ +'use client' + +import { ArrowDown, AlertTriangle } from 'lucide-react' +import { formatCurrency, formatDate } from '@/lib/utils' +import { DocumentViewButton } from './DocumentViewButton' + +interface AttachDocumentPreviewProps { + data: Record + params: Record +} + +/** + * Two-card preview for `attach_document_to_transaction` operations. Renders + * the transaction and the document side by side so the reviewer can confirm + * the pairing without cross-referencing IDs. + */ +export function AttachDocumentPreview({ data, params }: AttachDocumentPreviewProps) { + const txDescription = (data.transaction_description as string) || '—' + const txAmount = data.transaction_amount as number | undefined + const txCurrency = (data.transaction_currency as string) || 'SEK' + const txDate = data.transaction_date as string | undefined + + const docFileName = (data.document_file_name as string) || '—' + const docVendor = data.document_vendor_name as string | undefined + const docAmount = data.document_amount as number | undefined + const docCurrency = (data.document_currency as string) || txCurrency + const docInvoiceDate = data.document_invoice_date as string | undefined + + const willOverwrite = data.will_overwrite_existing === true + const existingDocName = data.existing_document_file_name as string | undefined + // Fail safe: if the staging tool didn't explicitly assert the existing doc + // is NOT räkenskapsinformation (i.e. `=== false`), treat overwrite as a + // BFL 7 kap event. A missing/undefined flag must not silently downgrade + // the destructive warning. + const existingIsAccounting = + willOverwrite && data.existing_document_is_rakenskapsinformation !== false + + const documentId = params.document_id as string | undefined + + return ( +
+
+ + + + + + + + + {docVendor && } + {docInvoiceDate && } + {typeof docAmount === 'number' && ( + + )} + {documentId && ( +
+ +
+ )} +
+
+ +
+ + kopplas till transaktionen +
+ + {willOverwrite && existingIsAccounting && ( +
+ +
+

Ersätter räkenskapsinformation

+

+ Befintligt dokument{existingDocName ? ` (${existingDocName})` : ''} är markerat som + räkenskapsinformation enligt BFL 7 kap. Att ersätta det här gör det tidigare + verifikationsunderlaget otillgängligt — bekräfta att du har originalet sparat innan + du godkänner. +

+
+
+ )} + {willOverwrite && !existingIsAccounting && ( +
+ Ersätter befintligt dokument{existingDocName ? `: ${existingDocName}` : ''}. +
+ )} +
+ ) +} + +function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

+ {label} +

+ {children} +
+ ) +} + +function Row({ label, value, tabular }: { label: string; value: string; tabular?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/components/bookkeeping/DocumentViewButton.tsx b/components/bookkeeping/DocumentViewButton.tsx new file mode 100644 index 00000000..f0a9f8c6 --- /dev/null +++ b/components/bookkeeping/DocumentViewButton.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useState } from 'react' +import { ExternalLink, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' + +interface DocumentViewButtonProps { + documentId: string + label?: string + className?: string +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Opens a document's signed download URL in a new tab. The signed URL is + * minted on demand via /api/documents/:id (60 min TTL), so we don't bake + * stale URLs into the preview payload. + */ +export function DocumentViewButton({ documentId, label = 'Visa dokument', className }: DocumentViewButtonProps) { + const { toast } = useToast() + const [loading, setLoading] = useState(false) + + const handleClick = async () => { + // documentId originates from staged preview_data (Record); + // validate the shape before interpolating into the request URL so a malformed + // payload can't redirect the fetch at another internal endpoint. + if (!UUID_RE.test(documentId)) { + toast({ + title: 'Ogiltigt dokument-ID', + description: 'Försök ladda om sidan eller kontakta support.', + variant: 'destructive', + }) + return + } + setLoading(true) + try { + const res = await fetch(`/api/documents/${documentId}`) + const json = await res.json().catch(() => ({})) + if (!res.ok || !json?.data?.download_url) { + toast({ + title: 'Kunde inte öppna dokumentet', + description: json?.error || 'Försök igen om en stund.', + variant: 'destructive', + }) + return + } + window.open(json.data.download_url as string, '_blank', 'noopener,noreferrer') + } finally { + setLoading(false) + } + } + + return ( + + ) +} diff --git a/components/bookkeeping/MatchTransactionInvoicePreview.tsx b/components/bookkeeping/MatchTransactionInvoicePreview.tsx new file mode 100644 index 00000000..35a15d9e --- /dev/null +++ b/components/bookkeeping/MatchTransactionInvoicePreview.tsx @@ -0,0 +1,92 @@ +'use client' + +import { ArrowDown } from 'lucide-react' +import { formatCurrency, formatDate } from '@/lib/utils' + +interface MatchTransactionInvoicePreviewProps { + data: Record +} + +/** + * Two-card preview for `match_transaction_invoice` operations. Mirrors + * AttachDocumentPreview's layout so reviewers learn one matching idiom. + */ +export function MatchTransactionInvoicePreview({ data }: MatchTransactionInvoicePreviewProps) { + const txDescription = (data.transaction_description as string) || '—' + const txAmount = data.transaction_amount as number | undefined + const txCurrency = (data.transaction_currency as string) || 'SEK' + const txDate = data.transaction_date as string | undefined + + const invoiceNumber = (data.invoice_number as string) || '—' + const invoiceTotal = data.invoice_total as number | undefined + const invoiceCurrency = (data.invoice_currency as string) || txCurrency + const invoiceDate = data.invoice_date as string | undefined + const customerName = data.customer_name as string | undefined + + // BFL 5 kap 4§ requires bookings to be made "so soon as possible" relative + // to the affärshändelse, so a transaction and invoice that diverge by more + // than a calendar month deserve a second look before the reviewer approves + // the match. The threshold is editorial, not legislated — it just nudges + // the reviewer; it doesn't block. + const showDateDriftHint = + txDate && + invoiceDate && + Math.abs(new Date(txDate).getTime() - new Date(invoiceDate).getTime()) > 31 * 24 * 60 * 60 * 1000 + + return ( +
+
+ + {txDate && } + + + + + + + {invoiceDate && } + {customerName && } + {typeof invoiceTotal === 'number' && ( + + )} + +
+ +
+ + matchas mot fakturan +
+ + {showDateDriftHint && ( +

+ Transaktionsdatum och fakturadatum skiljer sig med mer än en månad — kontrollera att + matchningen avser rätt affärshändelse. +

+ )} +
+ ) +} + +function PreviewCard({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

+ {label} +

+ {children} +
+ ) +} + +function Row({ label, value, tabular }: { label: string; value: string; tabular?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 63d18279..b476d92d 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -1260,81 +1260,16 @@ function FieldsRail({ !isProcessed && !isBookedDirectly && !!item.matched_transaction_id const isResolved = isProcessed || isBookedDirectly || isLinkedToTransaction const [isRetrying, setIsRetrying] = useState(false) - const [isCreatingSupplier, setIsCreatingSupplier] = useState(false) - // "Skapa leverantör" — surface when extraction caught a supplier name - // but no existing supplier matched. Frees the user from navigating to - // /suppliers/new manually for the very common "first invoice from this - // vendor" case. + // Surface a quiet hint when extraction caught a supplier name but no existing + // supplier matched. The actual creation flow lives on the leverantörsfaktura + // form (Skapa & välj), so we don't render a separate button here. const extractedSupplierName = data?.supplier?.name?.trim() || null - const showCreateSupplierCta = + const showNoMatchHint = !isResolved && !item.matched_supplier_id && !!extractedSupplierName - const handleCreateSupplier = async () => { - if (!extractedSupplierName) return - setIsCreatingSupplier(true) - try { - // Heuristic supplier_type: any extracted VAT number starting with "SE" - // (or a 10-digit org_number) → Swedish; otherwise default to - // non_eu_business. The user can correct on the supplier detail page. - const vat = data?.supplier?.vatNumber?.trim() || '' - const org = data?.supplier?.orgNumber?.trim() || '' - const supplierType = - vat.toUpperCase().startsWith('SE') || /^\d{6}-?\d{4}$/.test(org) - ? 'swedish_business' - : 'non_eu_business' - - const createRes = await fetch('/api/suppliers', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: extractedSupplierName, - supplier_type: supplierType, - org_number: org || undefined, - vat_number: vat || undefined, - address_line1: data?.supplier?.address || undefined, - bankgiro: data?.supplier?.bankgiro || undefined, - plusgiro: data?.supplier?.plusgiro || undefined, - }), - }) - const createJson = await createRes.json().catch(() => ({})) - if (!createRes.ok || !createJson?.data?.id) { - toast({ - title: 'Kunde inte skapa leverantör', - description: createJson?.error || 'Försök igen.', - variant: 'destructive', - }) - return - } - - // Link the new supplier back to the inbox item so the next action - // (Skapa leverantörsfaktura) prefills correctly. - const matchRes = await fetch( - `/api/extensions/ext/invoice-inbox/items/${item.id}/match-supplier`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ supplier_id: createJson.data.id }), - }, - ) - if (!matchRes.ok) { - const matchErr = await matchRes.json().catch(() => ({})) - toast({ - title: 'Leverantör skapad, men inte kopplad', - description: matchErr?.error || 'Välj leverantören manuellt på leverantörsfakturan.', - variant: 'destructive', - }) - } else { - toast({ title: 'Leverantör skapad', description: extractedSupplierName }) - } - await onRetryRequested() - } finally { - setIsCreatingSupplier(false) - } - } - const handleRetry = async () => { setIsRetrying(true) try { @@ -1412,26 +1347,12 @@ function FieldsRail({
)} - {/* Skapa leverantör hint — only when no existing supplier matched */} - {showCreateSupplierCta && ( -
- - Ingen leverantör matchade {extractedSupplierName} - - + {/* Hint only — creation happens on the leverantörsfaktura form via "Skapa & välj" */} + {showNoMatchHint && ( +
+ Ingen leverantör matchade{' '} + {extractedSupplierName} + {' — leverantören skapas när du klickar Skapa leverantörsfaktura.'}
)} @@ -1604,15 +1525,15 @@ const FIELD_DEFS: FieldDef[] = [ { key: 'supplier.name', label: 'Leverantör', type: 'text' }, { key: 'supplier.orgNumber', label: 'Org.nr', type: 'text' }, { key: 'supplier.vatNumber', label: 'VAT-nr', type: 'text' }, + { key: 'invoice.currency', label: 'Valuta', type: 'text' }, + { key: 'totals.total', label: 'Totalt', type: 'number', inputMode: 'decimal' }, + { key: 'totals.vatAmount', label: 'Moms', type: 'number', inputMode: 'decimal' }, { key: 'supplier.bankgiro', label: 'Bankgiro', type: 'text' }, { key: 'supplier.plusgiro', label: 'Plusgiro', type: 'text' }, { key: 'invoice.invoiceNumber', label: 'Fakturanr', type: 'text' }, { key: 'invoice.paymentReference', label: 'OCR/Referens', type: 'text' }, { key: 'invoice.invoiceDate', label: 'Fakturadatum', type: 'date' }, { key: 'invoice.dueDate', label: 'Förfallodatum', type: 'date' }, - { key: 'invoice.currency', label: 'Valuta', type: 'text' }, - { key: 'totals.total', label: 'Totalt', type: 'number', inputMode: 'decimal' }, - { key: 'totals.vatAmount', label: 'Moms', type: 'number', inputMode: 'decimal' }, ] function readField(data: InvoiceExtractionResult, key: FieldKey): string { diff --git a/components/import/OpeningBalanceEditStep.tsx b/components/import/OpeningBalanceEditStep.tsx index 9b7e4076..4603161a 100644 --- a/components/import/OpeningBalanceEditStep.tsx +++ b/components/import/OpeningBalanceEditStep.tsx @@ -67,17 +67,39 @@ export default function OpeningBalanceEditStep({ onContinue, onBack, }: OpeningBalanceEditStepProps) { - const [rows, setRows] = useState(() => - initialRows.map((r) => ({ - id: generateId(), - account_number: r.account_number, - account_name: r.account_name, - debit_amount: r.debit_amount, - credit_amount: r.credit_amount, - validation_errors: r.validation_errors, - bas_match: r.bas_match, - })), - ) + const [rows, setRows] = useState(() => { + // Defense-in-depth dedup: if the parser ever leaks duplicates by + // account_number, collapse them here before the user sees them. Union + // validation_errors so a warning surfaced only on the later row isn't + // silently dropped during the merge. + const byAccount = new Map() + for (const r of initialRows) { + const key = r.account_number.replace(/\D/g, '') + const existing = byAccount.get(key) + if (existing) { + existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100 + existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100 + if (!existing.account_name && r.account_name) existing.account_name = r.account_name + if (r.validation_errors?.length) { + const seen = new Set(existing.validation_errors) + for (const err of r.validation_errors) { + if (!seen.has(err)) existing.validation_errors.push(err) + } + } + continue + } + byAccount.set(key, { + id: generateId(), + account_number: r.account_number, + account_name: r.account_name, + debit_amount: r.debit_amount, + credit_amount: r.credit_amount, + validation_errors: [...r.validation_errors], + bas_match: r.bas_match, + }) + } + return Array.from(byAccount.values()) + }) const [activeAutocomplete, setActiveAutocomplete] = useState(null) const [autocompleteQuery, setAutocompleteQuery] = useState('') diff --git a/extensions/general/enable-banking/components/AccountPickerDialog.tsx b/extensions/general/enable-banking/components/AccountPickerDialog.tsx index dc7c130b..11ec90df 100644 --- a/extensions/general/enable-banking/components/AccountPickerDialog.tsx +++ b/extensions/general/enable-banking/components/AccountPickerDialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' import { Dialog, DialogContent, @@ -11,6 +12,7 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' import { Select, SelectContent, @@ -22,7 +24,17 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2 } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' +import { + getCurrentFiscalYearStart, + getPreviousFiscalYearStart, + daysBetween, +} from '@/lib/company/fiscal-year' +import type { CompanySettings } from '@/types' import type { StoredAccount } from '../types' +import { + BankSyncProgressDialog, + type SyncProgressState, +} from './BankSyncProgressDialog' interface AccountPickerDialogProps { open: boolean @@ -42,11 +54,8 @@ interface ChartAccount { account_name: string } -const LOOKBACK_OPTIONS = [ - { days: 90, label: 'Senaste 90 dagar (PSD2 standard, rekommenderas)' }, - { days: 180, label: 'Senaste 180 dagar' }, - { days: 365, label: 'Senaste 365 dagar' }, -] as const +type LookbackMode = 'fast' | 'fiscal-year' | 'custom' +type CustomSubMode = 'date' | 'previous-fiscal-year' // Suggested BAS account per currency. The mapping engine falls back to 1930 // when ledger_account is unset, so the SEK case is just an explicit hint. @@ -77,11 +86,17 @@ export function AccountPickerDialog({ const [selected, setSelected] = useState>(new Set()) const [isSaving, setIsSaving] = useState(false) - const [lookbackDays, setLookbackDays] = useState(90) const [sieLastDate, setSieLastDate] = useState(null) - const [showCustomLookback, setShowCustomLookback] = useState(false) const [chartAccounts, setChartAccounts] = useState([]) const [ledgerByUid, setLedgerByUid] = useState>({}) + const [companySettings, setCompanySettings] = useState | null>(null) + + const [lookbackMode, setLookbackMode] = useState('fiscal-year') + const [customSubMode, setCustomSubMode] = useState('date') + const [customDate, setCustomDate] = useState('') + + const [progressOpen, setProgressOpen] = useState(false) + const [progressState, setProgressState] = useState({ kind: 'syncing' }) useEffect(() => { if (open) { @@ -89,7 +104,9 @@ export function AccountPickerDialog({ accounts.filter(a => a.enabled !== false).map(a => a.uid) ) setSelected(initial) - setShowCustomLookback(false) + setLookbackMode('fiscal-year') + setCustomSubMode('date') + setCustomDate('') // Pre-populate ledger picks from existing StoredAccount values, falling // back to currency-based suggestions for accounts the user hasn't mapped yet. @@ -103,8 +120,25 @@ export function AccountPickerDialog({ } }, [open, accounts]) - // Fetch the latest SIE import end date so we can anchor the backfill to - // "day after last SIE entry" (SpeedLedger pattern). Only matters on the + // Load fiscal_year_start_month + entity_type so "Sedan räkenskapsårets början" + // resolves to the right date for non-calendar fiscal years. + useEffect(() => { + if (!open || !company?.id) return + let cancelled = false + ;(async () => { + const { data } = await supabase + .from('company_settings') + .select('fiscal_year_start_month, entity_type') + .eq('company_id', company.id) + .maybeSingle() + if (cancelled) return + setCompanySettings((data as { fiscal_year_start_month?: number; entity_type?: CompanySettings['entity_type'] } | null) as Pick | null) + })() + return () => { cancelled = true } + }, [open, company?.id, supabase]) + + // Fetch the latest SIE import end date so we can offer "day after last SIE entry" + // as a one-click escape from the default fiscal-year start. Only matters on the // initial activation flow — selection edits don't re-run sync. useEffect(() => { if (!open || !isInitialSelection || !company?.id) { @@ -122,16 +156,7 @@ export function AccountPickerDialog({ .limit(1) .maybeSingle() if (cancelled) return - const fye = (data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null - setSieLastDate(fye) - if (fye) { - const dayAfter = new Date(fye) - dayAfter.setDate(dayAfter.getDate() + 1) - const days = Math.ceil((Date.now() - dayAfter.getTime()) / (24 * 60 * 60 * 1000)) - setLookbackDays(Math.min(365, Math.max(30, days))) - } else { - setLookbackDays(90) - } + setSieLastDate((data as { fiscal_year_end?: string } | null)?.fiscal_year_end || null) })() return () => { cancelled = true } }, [open, isInitialSelection, company?.id, supabase]) @@ -217,7 +242,34 @@ export function AccountPickerDialog({ return } + // Block save when the user picked "Anpassat datum" but left the date blank. + // Without this guard, lookback.body is null and the PATCH would silently + // fall back to the backend's 120-day default — not what the user asked for. + if ( + isInitialSelection && + lookbackMode === 'custom' && + customSubMode === 'date' && + !lookback.body + ) { + toast({ + title: 'Ange startdatum', + description: 'Välj ett datum för att hämta historik, eller välj ett annat alternativ.', + variant: 'destructive', + }) + return + } + setIsSaving(true) + + // For the initial-selection path, open the progress modal up-front so the + // user has visible feedback during the 30–60s backfill. Selection edits + // (no backfill) keep the existing toast-only feedback. + if (isInitialSelection) { + setProgressState({ kind: 'syncing' }) + setProgressOpen(true) + onOpenChange(false) + } + try { // Send a mapping entry per selected account. Account_mappings doesn't // include disabled accounts — their existing ledger_account stays untouched. @@ -233,7 +285,7 @@ export function AccountPickerDialog({ connection_id: connectionId, enabled_uids: Array.from(selected), account_mappings, - ...(isInitialSelection ? { initial_lookback_days: lookbackDays } : {}), + ...(isInitialSelection && lookback.body ? lookback.body : {}), }), }) @@ -244,39 +296,32 @@ export function AccountPickerDialog({ } if (isInitialSelection && data.initial_sync) { - const { imported, returned_min_date, returned_max_date } = data.initial_sync as { - imported: number - returned_min_date: string | null - returned_max_date: string | null - } - const range = returned_min_date && returned_max_date - ? ` från ${returned_min_date} till ${returned_max_date}` - : '' - toast({ - title: 'Konton sparade', - description: `Importerade ${imported} transaktioner${range}.`, - }) + setProgressState({ kind: 'done', summary: data.initial_sync }) } else if (isInitialSelection && data.initial_sync_error) { - toast({ - title: 'Konton sparade — bakgrundssync misslyckades', - description: 'Vi försöker igen vid nästa körning. Bankanslutningen är aktiv.', - variant: 'destructive', + setProgressState({ + kind: 'failed', + error: { message: 'Vi sparade kontovalet men kunde inte hämta transaktioner just nu. Vi försöker igen vid nästa körning.' }, }) } else { toast({ title: 'Kontoval sparat', description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`, }) + onOpenChange(false) } - onOpenChange(false) onSaved() } catch (error) { - toast({ - title: 'Fel', - description: error instanceof Error ? error.message : 'Kunde inte spara kontoval', - variant: 'destructive', - }) + const message = error instanceof Error ? error.message : 'Kunde inte spara kontoval' + if (isInitialSelection) { + setProgressState({ kind: 'failed', error: { message } }) + } else { + toast({ + title: 'Fel', + description: message, + variant: 'destructive', + }) + } } finally { setIsSaving(false) } @@ -289,7 +334,49 @@ export function AccountPickerDialog({ return d.toISOString().split('T')[0] }, [sieLastDate]) + const fiscalYearStart = useMemo( + () => getCurrentFiscalYearStart(companySettings), + [companySettings], + ) + + const previousFiscalYearStart = useMemo( + () => getPreviousFiscalYearStart(companySettings), + [companySettings], + ) + + // Resolve mode → concrete request payload and a "resolved from-date" for display. + const lookback = useMemo(() => { + if (lookbackMode === 'fast') { + return { body: { initial_lookback_days: 90 }, fromDate: null as string | null, days: 90 } + } + if (lookbackMode === 'fiscal-year') { + return { body: { initial_lookback_from_date: fiscalYearStart }, fromDate: fiscalYearStart, days: daysBetween(fiscalYearStart) } + } + // custom + const date = customSubMode === 'previous-fiscal-year' ? previousFiscalYearStart : customDate + if (date && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + return { body: { initial_lookback_from_date: date }, fromDate: date, days: daysBetween(date) } + } + return { body: null as Record | null, fromDate: null as string | null, days: 0 } + }, [lookbackMode, customSubMode, customDate, fiscalYearStart, previousFiscalYearStart]) + + const showLongRangeHelper = lookback.days > 90 + return ( + <> + { + setProgressOpen(next) + // When the user closes the summary, propagate the saved/refresh + // signal to the parent (it would have been emitted on success earlier; + // this just guards the failure case where we still want a refresh). + if (!next) onSaved() + }} + bankName={bankName} + accounts={accounts.filter((a) => selected.has(a.uid))} + state={progressState} + /> @@ -302,70 +389,131 @@ export function AccountPickerDialog({ {isInitialSelection && ( -
- {sieLastDate && dayAfterSie ? ( -
-

- Vi hittade en SIE-import som täcker fram till{' '} - {sieLastDate}. - Vi hämtar bankhistorik från{' '} - {dayAfterSie}{' '} - så att inget överlappar din tidigare bokföring. +

+
+ +

+ Vi börjar hämta transaktioner från det datum du väljer. Du behöver inte tänka i dagar. +

+
+ + {sieLastDate && dayAfterSie && ( +
+

+ Senaste SIE-importen täcker till{' '} + {sieLastDate}. + Vi föreslår{' '} + {dayAfterSie}{' '} + som startdatum så inget överlappar din bokföring.

- {showCustomLookback && ( - - )}
- ) : ( -
- - setLookbackMode('fast')} disabled={isSaving} + className="mt-1" + /> + + Senaste 90 dagar (snabbt) + + + + + + +
+ + {showLongRangeHelper && ( +

+ Din bank returnerar oftast max 90 dagar. Behöver du äldre transaktioner kan du{' '} + - - - - - {LOOKBACK_OPTIONS.map(opt => ( - - {opt.label} - - ))} - - -

- PSD2-bankregler begränsar oftast historiken till 90 dagar bakåt. - Vi visar exakt vad banken returnerade efter sparat val. - För äldre data, använd SIE- eller CSV-import. -

-
+ importera via SIE eller bankfil + + . Vi visar exakt vad banken returnerade efter sparat val. +

)}
)} @@ -500,5 +648,6 @@ export function AccountPickerDialog({
+ ) } diff --git a/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx b/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx new file mode 100644 index 00000000..1b3ced7a --- /dev/null +++ b/extensions/general/enable-banking/components/BankSyncProgressDialog.tsx @@ -0,0 +1,176 @@ +'use client' + +import Link from 'next/link' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-react' +import { daysBetween } from '@/lib/company/fiscal-year' +import type { StoredAccount } from '../types' + +export interface SyncProgressSummary { + imported: number + duplicates: number + requested_from: string + returned_min_date: string | null + returned_max_date: string | null +} + +export interface SyncProgressError { + message: string +} + +export type SyncProgressState = + | { kind: 'syncing' } + | { kind: 'done'; summary: SyncProgressSummary } + | { kind: 'failed'; error: SyncProgressError } + +interface BankSyncProgressDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + bankName: string + accounts: StoredAccount[] + state: SyncProgressState +} + +export function BankSyncProgressDialog({ + open, + onOpenChange, + bankName, + accounts, + state, +}: BankSyncProgressDialogProps) { + // Close-prevention while sync is in flight is handled inline below via the + // onOpenChange guard + onPointerDownOutside + onEscapeKeyDown handlers. + + const enabledAccounts = accounts.filter((a) => a.enabled !== false) + + return ( + { + // Block manual close mid-sync + if (!next && state.kind === 'syncing') return + onOpenChange(next) + }} + > + { + if (state.kind === 'syncing') e.preventDefault() + }} + onEscapeKeyDown={(e) => { + if (state.kind === 'syncing') e.preventDefault() + }} + > + + + {state.kind === 'syncing' && `Hämtar transaktioner från ${bankName}`} + {state.kind === 'done' && 'Klart'} + {state.kind === 'failed' && 'Synkningen misslyckades'} + + + {state.kind === 'syncing' && ( + <> + Vi hämtar transaktioner från {enabledAccounts.length}{' '} + {enabledAccounts.length === 1 ? 'konto' : 'konton'}. Detta kan ta upp till en minut. Stäng inte fönstret. + + )} + {state.kind === 'done' && ( + <> + Vi hämtade {state.summary.imported}{' '} + {state.summary.imported === 1 ? 'transaktion' : 'transaktioner'}. + + )} + {state.kind === 'failed' && ( + <>Vi försöker igen automatiskt i bakgrunden. Du kan stänga den här rutan. + )} + + + + {state.kind === 'syncing' && ( +
+
+ +
+
    + {enabledAccounts.map((a) => ( +
  • + {a.name || a.iban || a.uid} + {a.currency} +
  • + ))} +
+
+ )} + + {state.kind === 'done' && ( + + )} + + {state.kind === 'failed' && ( +
+ {state.error.message} +
+ )} + + + + +
+
+ ) +} + +function DoneBody({ summary }: { summary: SyncProgressSummary }) { + const requestedDays = daysBetween(summary.requested_from) + const returnedDays = + summary.returned_min_date && summary.returned_max_date + ? daysBetween(summary.returned_min_date, new Date(summary.returned_max_date)) + : 0 + const wasTruncated = requestedDays - returnedDays > 7 + + return ( +
+
+ +
+

+ {summary.imported} nya transaktioner + importerade. +

+ {summary.returned_min_date && summary.returned_max_date && ( +

+ Datum: {summary.returned_min_date} → {summary.returned_max_date} +

+ )} +
+
+ + {wasTruncated && ( +
+ + + Banken returnerade kortare historik än begärt. För äldre data, använd{' '} + + SIE- eller bankfil-import + + . + +
+ )} +
+ ) +} diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 23520f23..5abf1929 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -458,6 +458,7 @@ export const enableBankingExtension: Extension = { const connection_id = body?.connection_id const enabled_uids = body?.enabled_uids const rawLookback = body?.initial_lookback_days + const rawLookbackFromDate = body?.initial_lookback_from_date const account_mappings = body?.account_mappings if (typeof connection_id !== 'string' || !connection_id) { @@ -519,7 +520,35 @@ export const enableBankingExtension: Extension = { // Default 120; clamp to [30, 365]. Ignored for selection edits. // PSD2 obliges ASPSPs to ~90 days without fresh SCA, but many Swedish banks // return more if asked — request 120 and accept whatever the bank gives back. + // + // If the client sent initial_lookback_from_date (preferred for fiscal-year-anchored + // backfills), derive days from that and reject future dates outright. Otherwise + // fall back to initial_lookback_days (or the 120-day default). + if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) { + const from = new Date(rawLookbackFromDate + 'T00:00:00Z') + if (!Number.isFinite(from.getTime())) { + return NextResponse.json( + { error: 'initial_lookback_from_date är inte ett giltigt datum.' }, + { status: 400 } + ) + } + const diffMs = Date.now() - from.getTime() + const daysFromDate = Math.ceil(diffMs / (24 * 60 * 60 * 1000)) + if (daysFromDate <= 0) { + return NextResponse.json( + { error: 'initial_lookback_from_date måste ligga i det förflutna.' }, + { status: 400 } + ) + } + } const initialLookbackDays = (() => { + if (typeof rawLookbackFromDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rawLookbackFromDate)) { + const from = new Date(rawLookbackFromDate + 'T00:00:00Z') + // Future/invalid dates already rejected above; days > 0 is guaranteed. + const diffMs = Date.now() - from.getTime() + const days = Math.ceil(diffMs / (24 * 60 * 60 * 1000)) + return Math.min(365, Math.max(1, days)) + } const n = typeof rawLookback === 'number' && Number.isFinite(rawLookback) ? rawLookback : 120 return Math.min(365, Math.max(30, Math.round(n))) })() diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index 8b6518a3..261028f8 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -24,6 +24,26 @@ import { linkToJournalEntry } from '@/lib/core/documents/document-service' import { CreateSupplierInvoiceSchema, BookInboxItemDirectlySchema } from '@/lib/api/schemas' import { appendProcessingHistory } from '@/lib/processing-history/append' import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox' +import { simpleParser } from 'mailparser' +import path from 'node:path' + +/** + * Defensive filename sanitisation for content arriving from .eml inner + * attachments and rejected-attachment metadata. The document-service already + * sanitises before storage paths are built (lib/core/documents/document-service.ts), + * so this is defense-in-depth: strip directory traversal sequences and exotic + * characters before they ever flow into DB columns or downstream consumers. + */ +function sanitiseFilename(raw: string | null | undefined, fallback: string): string { + const base = path.basename(String(raw ?? '').trim()) + const cleaned = base.replace(/[^\w.-]/g, '_').slice(0, 200) + return cleaned || fallback +} + +function sanitiseMime(raw: string | null | undefined): string { + const value = String(raw ?? '').trim().slice(0, 120) + return /^[\w./+-]+$/.test(value) ? value : 'application/octet-stream' +} import type { InvoiceExtractionResult, InvoiceInboxItem, SupplierInvoice, SupplierInvoiceItem } from '@/types' const MAX_FILE_SIZE = 10 * 1024 * 1024 @@ -1116,6 +1136,45 @@ export const invoiceInboxExtension: Extension = { } const results: Array<{ attachment_id: string; inbox_item_id?: string; error?: string; duplicate?: boolean }> = [] + + // Persist a "rejected" inbox row so the user has visibility into the drop. + // Without this, attachments that fail MIME validation vanish silently — + // a common Gmail "forward as attachment" foot-gun until we added .eml + // handling below. + const logRejection = async ( + attachmentId: string, + attachmentName: string | null, + mime: string, + reason: string, + ) => { + // attachment_name and mime are attacker-controlled (they come from the + // forwarded email headers); sanitise before they land in the JSONB + // raw_email_payload column so they can't surface as injection or + // oversized values when read back into the UI / audit trails. + try { + await serviceSupabase.from('invoice_inbox_items').insert({ + company_id: inbox.company_id, + user_id: userId, + status: 'error', + source: 'email', + email_from: from, + email_subject: subject, + email_received_at: created_at, + email_body_text: bodyText, + resend_email_id: email_id, + resend_attachment_id: attachmentId, + error_message: reason.slice(0, 500), + raw_email_payload: { + messageId: message_id, + attachment_name: sanitiseFilename(attachmentName, 'unknown'), + mime: sanitiseMime(mime), + }, + }) + } catch (insertErr) { + console.error('[invoice-inbox/inbound] Failed to log rejected attachment:', insertErr) + } + } + for (const att of attachments) { try { const { data: existing } = await serviceSupabase @@ -1130,11 +1189,66 @@ export const invoiceInboxExtension: Extension = { } const download = await fetchInboundAttachment(email_id, att.id) + + // Gmail "Forward as attachment" wraps the original email as message/rfc822. + // Unwrap it and process the inner attachments as if they had arrived + // directly, carrying the inner email's subject/from into our metadata. + if (download.contentType === 'message/rfc822') { + const parsed = await simpleParser(Buffer.from(download.buffer)) + const innerAttachments = parsed.attachments || [] + if (innerAttachments.length === 0) { + await logRejection(att.id, download.filename, download.contentType, 'Det vidarebefordrade meddelandet innehöll inga bilagor') + results.push({ attachment_id: att.id, error: 'eml_no_inner_attachments' }) + continue + } + const innerFrom = parsed.from?.text || from + const innerSubject = parsed.subject || subject + for (let i = 0; i < innerAttachments.length; i++) { + const inner = innerAttachments[i] + const innerType = sanitiseMime(inner.contentType) + const innerName = sanitiseFilename(inner.filename, `attachment-${i}`) + const innerBuffer = inner.content + if (!innerBuffer) continue + const innerId = `${att.id}#${i}` + if (!UPLOAD_ALLOWED_MIME_TYPES.has(innerType)) { + await logRejection(innerId, innerName, innerType, `Avvisad bilaga från vidarebefordrat mejl: filtypen ${innerType} stöds inte`) + results.push({ attachment_id: innerId, error: `Unsupported type ${innerType}` }) + continue + } + if (innerBuffer.byteLength > MAX_FILE_SIZE) { + await logRejection(innerId, innerName, innerType, 'Bilagan i det vidarebefordrade mejlet är för stor') + results.push({ attachment_id: innerId, error: 'Inner attachment too large' }) + continue + } + const innerArrayBuffer = new Uint8Array(innerBuffer).buffer + const innerResult = await uploadAndExtract( + serviceSupabase, + userId, + inbox.company_id, + { name: innerName, buffer: innerArrayBuffer, type: innerType }, + 'email', + { + from: innerFrom, + subject: innerSubject, + receivedAt: created_at, + messageId: message_id, + bodyText, + resendEmailId: email_id, + resendAttachmentId: innerId, + } + ) + results.push({ attachment_id: innerId, inbox_item_id: innerResult.inbox_item_id }) + } + continue + } + if (!UPLOAD_ALLOWED_MIME_TYPES.has(download.contentType)) { + await logRejection(att.id, download.filename, download.contentType, `Avvisad: filtypen ${download.contentType} stöds inte`) results.push({ attachment_id: att.id, error: `Unsupported type ${download.contentType}` }) continue } if (download.buffer.byteLength > MAX_FILE_SIZE) { + await logRejection(att.id, download.filename, download.contentType, 'Bilagan är för stor') results.push({ attachment_id: att.id, error: 'Attachment too large' }) continue } diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 72e63784..05083a1b 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -3407,7 +3407,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_match_transaction_to_invoice', - description: 'Match a bank transaction (income, amount>0) to a customer invoice. Stages for approval. Supports partial payments and auto-storno of prior categorization.', + description: 'Match a bank transaction (income, amount>0) to a customer invoice. Confirm tx date/amount and invoice number/customer match before staging — preview mirrors what you pass. Supports partial payments and auto-storno of prior categorization.', inputSchema: { type: 'object', additionalProperties: false, @@ -3432,7 +3432,7 @@ export const tools: McpTool[] = [ // Validate both exist and are matchable const { data: transaction, error: txError } = await supabase .from('transactions') - .select('id, description, merchant_name, amount, currency, invoice_id') + .select('id, description, merchant_name, amount, currency, date, invoice_id') .eq('id', transactionId) .eq('company_id', companyId) .single() @@ -3462,9 +3462,14 @@ export const tools: McpTool[] = [ transaction_description: txDesc, transaction_amount: transaction.amount, transaction_currency: transaction.currency, + // Surface both dates so the reviewer can spot a material mismatch + // between the payment and the invoice it's being matched against + // before approving. + transaction_date: transaction.date, invoice_number: invoice.invoice_number, invoice_total: invoice.total, invoice_currency: invoice.currency, + invoice_date: invoice.invoice_date, customer_name: (invoice.customer as Record)?.name as string, }, actor @@ -4369,7 +4374,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_attach_document_to_transaction', - description: 'Stage attaching a document to a bank transaction. The document is pinned to the tx; when the tx is later categorized the link propagates to the journal entry. Stages for approval.', + description: 'Stage attaching a document to a bank transaction. Verify tx (date, amount, counterparty) and document (filename, vendor, amount) match first — the preview shown to the human reviewer mirrors what you pass here. Stages for approval.', inputSchema: { type: 'object', additionalProperties: false, diff --git a/lib/company/__tests__/fiscal-year.test.ts b/lib/company/__tests__/fiscal-year.test.ts new file mode 100644 index 00000000..eb437077 --- /dev/null +++ b/lib/company/__tests__/fiscal-year.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { getCurrentFiscalYearStart, getPreviousFiscalYearStart, daysBetween } from '../fiscal-year' + +describe('getCurrentFiscalYearStart', () => { + it('returns Jan 1 of current year for calendar-year fiscals', () => { + expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 1, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2026-01-01') + }) + + it('returns previous-year July 1 when today is before July with a July fiscal start', () => { + expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2025-07-01') + }) + + it('returns current-year July 1 when today is after July with a July fiscal start', () => { + expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'aktiebolag' }, new Date('2026-09-15'))).toBe('2026-07-01') + }) + + it('locks enskild firma to calendar year even with a non-Jan setting', () => { + expect(getCurrentFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'enskild_firma' }, new Date('2026-05-21'))).toBe('2026-01-01') + }) + + it('defaults to Jan 1 when settings are missing', () => { + expect(getCurrentFiscalYearStart(null, new Date('2026-05-21'))).toBe('2026-01-01') + }) +}) + +describe('getPreviousFiscalYearStart', () => { + it('returns prior calendar year for calendar fiscals', () => { + expect(getPreviousFiscalYearStart({ fiscal_year_start_month: 1, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2025-01-01') + }) + + it('handles July fiscal start correctly', () => { + expect(getPreviousFiscalYearStart({ fiscal_year_start_month: 7, entity_type: 'aktiebolag' }, new Date('2026-05-21'))).toBe('2024-07-01') + }) +}) + +describe('daysBetween', () => { + it('counts whole days between two dates', () => { + expect(daysBetween('2026-01-01', '2026-01-11')).toBe(10) + }) + + it('returns 0 when from > to (no negative)', () => { + expect(daysBetween('2026-05-21', '2026-05-01')).toBe(0) + }) +}) diff --git a/lib/company/fiscal-year.ts b/lib/company/fiscal-year.ts new file mode 100644 index 00000000..c0830fc0 --- /dev/null +++ b/lib/company/fiscal-year.ts @@ -0,0 +1,51 @@ +import type { CompanySettings } from '@/types' + +/** + * Return the ISO date (YYYY-MM-DD) for the start of the fiscal year that + * contains `today`, given the company's fiscal_year_start_month setting. + * + * Enskild firma is locked to calendar year per BFL. We assume `entity_type` + * reflects the company's *current* tax-year status — if an enskild firma is + * mid-conversion to an AB, callers should re-resolve after the conversion + * lands rather than backfilling from a stale anchor. + */ +export function getCurrentFiscalYearStart( + settings: Pick | null | undefined, + today: Date = new Date(), +): string { + let startMonth = settings?.fiscal_year_start_month || 1 + if (settings?.entity_type === 'enskild_firma') startMonth = 1 + + const year = today.getMonth() + 1 >= startMonth ? today.getFullYear() : today.getFullYear() - 1 + return `${year}-${String(startMonth).padStart(2, '0')}-01` +} + +/** + * Return the ISO date for the start of the PREVIOUS fiscal year — useful when + * the user wants to backfill the year that just closed. + */ +export function getPreviousFiscalYearStart( + settings: Pick | null | undefined, + today: Date = new Date(), +): string { + let startMonth = settings?.fiscal_year_start_month || 1 + if (settings?.entity_type === 'enskild_firma') startMonth = 1 + + const currentYearStart = today.getMonth() + 1 >= startMonth + ? today.getFullYear() + : today.getFullYear() - 1 + return `${currentYearStart - 1}-${String(startMonth).padStart(2, '0')}-01` +} + +export function daysBetween(from: string | Date, to: string | Date = new Date()): number { + // Bare ISO date strings ("2026-01-01") are parsed as UTC midnight, but + // `new Date()` is local wall-clock time. Mixing the two means timezones east + // of UTC can be one day past UTC midnight while still on the prior local + // date, producing off-by-one drift. Pin both string operands to UTC so the + // math is timezone-independent. Date operands (rare; tests + future callers) + // are trusted as-is. + const parse = (v: string | Date) => + typeof v === 'string' ? new Date(v + 'T00:00:00Z') : v + const diff = parse(to).getTime() - parse(from).getTime() + return Math.max(0, Math.ceil(diff / (24 * 60 * 60 * 1000))) +} diff --git a/lib/import/opening-balance/__tests__/parser.test.ts b/lib/import/opening-balance/__tests__/parser.test.ts index 1dfdc6c3..2798fac4 100644 --- a/lib/import/opening-balance/__tests__/parser.test.ts +++ b/lib/import/opening-balance/__tests__/parser.test.ts @@ -161,6 +161,59 @@ describe('parseOpeningBalanceFile', () => { expect(result.warnings.some((w) => w.includes('1930'))).toBe(true) }) + it('preserves validation_errors from every duplicated row when merging', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + const wb = XLSX.utils.book_new() + // Two rows for account 3001 (a P&L class warning fires on each) plus a + // balance-sheet row to keep totals reachable. Both copies of 3001 should + // contribute their warning into the merged row's validation_errors so it + // isn't silently lost. + const data = [ + ['Kontonr', 'Debet', 'Kredit'], + ['3001', 100, 0], + ['3001', 200, 0], + ['2099', 0, 300], + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + const merged = result.rows.find((r) => r.account_number === '3001') + expect(merged).toBeDefined() + expect(merged!.debit_amount).toBe(300) + expect(merged!.validation_errors.length).toBeGreaterThan(0) + expect(merged!.validation_errors.some((e) => e.includes('resultatkonto'))).toBe(true) + }) + + it('merges duplicates that differ only in whitespace / formatting', async () => { + const XLSX = await import('xlsx') + const { parseOpeningBalanceFile } = await import('../parser') + + const wb = XLSX.utils.book_new() + // Same account written three ways: plain, padded with NBSP, with a dot + const data = [ + ['Kontonr', 'Debet', 'Kredit'], + ['1930', 10000, 0], + [' 1930 ', 20000, 0], + ['1.930', 30000, 0], + ['2099', 0, 60000], + ] + const ws = XLSX.utils.aoa_to_sheet(data) + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) + const result = parseOpeningBalanceFile(buffer, 'test.xlsx') + + const matches = result.rows.filter((r) => r.account_number === '1930') + expect(matches.length).toBe(1) + expect(matches[0].debit_amount).toBe(60000) + expect(result.rows.length).toBe(2) + }) + it('skips zero-amount rows', async () => { const XLSX = await import('xlsx') const { parseOpeningBalanceFile } = await import('../parser') diff --git a/lib/import/opening-balance/parser.ts b/lib/import/opening-balance/parser.ts index a4340c6b..e24c2ec5 100644 --- a/lib/import/opening-balance/parser.ts +++ b/lib/import/opening-balance/parser.ts @@ -102,13 +102,15 @@ export function parseOpeningBalanceFile( for (let i = 0; i < dataRows.length; i++) { const row = dataRows[i] - const rawAccountNumber = String(row[columns.account_number_col] || '').trim() + const rawAccountNumber = String(row[columns.account_number_col] || '') + .replace(/[ ​‌‍]/g, '') // strip NBSP and zero-width chars + .trim() // Skip empty rows if (!rawAccountNumber) continue - // Clean account number (remove leading zeros, spaces, dashes) - const accountNumber = rawAccountNumber.replace(/[^0-9]/g, '') + // Clean account number — strip every non-digit (whitespace, dots, dashes, letters) + const accountNumber = rawAccountNumber.replace(/\D/g, '') // Skip non-4-digit account numbers (likely header/total rows) if (!/^\d{4}$/.test(accountNumber)) { @@ -186,15 +188,29 @@ export function parseOpeningBalanceFile( }) } - // Merge duplicate accounts + // Merge duplicate accounts — keyed on the already-normalized account_number. + // Union validation_errors across rows so a warning that fires on row 5 (e.g. + // BAS-class mismatch) isn't silently dropped because row 2 of the same + // account had no error. Suppressed validation issues on IB-feeding data + // would risk a misclassification propagating into the ledger. const mergedMap = new Map() for (const row of rows) { const existing = mergedMap.get(row.account_number) if (existing) { existing.debit_amount = Math.round((existing.debit_amount + row.debit_amount) * 100) / 100 existing.credit_amount = Math.round((existing.credit_amount + row.credit_amount) * 100) / 100 + if (!existing.account_name && row.account_name) { + existing.account_name = row.account_name + } + if (row.validation_errors?.length) { + const seen = new Set(existing.validation_errors) + for (const err of row.validation_errors) { + if (!seen.has(err)) existing.validation_errors.push(err) + } + existing.is_valid = existing.is_valid && row.is_valid + } } else { - mergedMap.set(row.account_number, { ...row }) + mergedMap.set(row.account_number, { ...row, validation_errors: [...row.validation_errors] }) } } const mergedRows = Array.from(mergedMap.values()) diff --git a/package-lock.json b/package-lock.json index 00a5de08..704ae03f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "ics": "^3.8.1", "jszip": "^3.10.1", "lucide-react": "^0.563.0", + "mailparser": "^3.9.8", "next": "16.1.5", "next-themes": "^0.4.6", "qrcode": "^1.5.4", @@ -57,6 +58,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/mailparser": "^3.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", "@types/react": "^19", @@ -7584,6 +7586,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -11231,9 +11257,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -12479,23 +12505,35 @@ } }, "node_modules/mailparser": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz", - "integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.8.tgz", + "integrity": "sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==", "license": "MIT", "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", - "iconv-lite": "0.7.0", - "libmime": "5.3.7", + "iconv-lite": "0.7.2", + "libmime": "5.3.8", "linkify-it": "5.0.0", - "nodemailer": "7.0.11", + "nodemailer": "8.0.5", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, + "node_modules/mailparser/node_modules/libmime": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz", + "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -13320,9 +13358,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", - "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -14373,6 +14411,49 @@ } } }, + "node_modules/resend/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/resend/node_modules/mailparser": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.1.tgz", + "integrity": "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.8", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "9.0.5", + "iconv-lite": "0.7.0", + "libmime": "5.3.7", + "linkify-it": "5.0.0", + "nodemailer": "7.0.11", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, + "node_modules/resend/node_modules/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/resend/node_modules/svix": { "version": "1.84.1", "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", diff --git a/package.json b/package.json index dc7b1bbf..73c27671 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "ics": "^3.8.1", "jszip": "^3.10.1", "lucide-react": "^0.563.0", + "mailparser": "^3.9.8", "next": "16.1.5", "next-themes": "^0.4.6", "qrcode": "^1.5.4", @@ -63,6 +64,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/mailparser": "^3.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", "@types/react": "^19",