From 81e9dd224e7511a87604f9953146e7db9f8d6495 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Fri, 8 May 2026 15:42:06 +0200 Subject: [PATCH] Add/csv import options (#420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(import): add customer and supplier parsing functionality - Implemented customer file parsing in `lib/import/customers/parser.ts` with support for Excel and CSV formats. - Created types for detected customer columns and parsed customer rows in `lib/import/customers/types.ts`. - Added tests for customer classification logic in `lib/import/shared/__tests__/classify.test.ts`. - Developed classification functions for customers and suppliers in `lib/import/shared/classify.ts`. - Introduced shared column utility functions in `lib/import/shared/column-utils.ts`. - Implemented supplier file parsing in `lib/import/suppliers/parser.ts` with validation for various fields. - Created types for detected supplier columns and parsed supplier rows in `lib/import/suppliers/types.ts`. - Added tests for supplier column detection and parsing in `lib/import/suppliers/__tests__/column-detector.test.ts` and `lib/import/suppliers/__tests__/parser.test.ts`. * fix(labels): update 'Svenskt företag' to 'Svenskt företag eller organisation' for clarity * feat(import): refactor encoding handling for Swedish files and add tests for character preservation * feat(recapt): implement clearRecaptIdentity function and integrate into logout flow * feat(bookkeeping): implement copy functionality and next voucher sequence retrieval * feat(import): enhance customer and supplier import functionality with normalization and event handling --- app/(dashboard)/bookkeeping/page.tsx | 83 ++- app/(dashboard)/customers/[id]/page.tsx | 2 +- app/(dashboard)/customers/page.tsx | 2 +- app/(dashboard)/import/page.tsx | 636 +++++++++++++++++- app/(dashboard)/settings/account/page.tsx | 2 + app/(dashboard)/suppliers/[id]/page.tsx | 2 +- app/(dashboard)/suppliers/page.tsx | 95 +-- .../next/__tests__/route.test.ts | 145 ++++ .../voucher-sequences/next/route.ts | 62 ++ app/api/import/bank-file/parse/route.ts | 2 +- app/api/import/customers/execute/route.ts | 208 ++++++ app/api/import/customers/parse/route.ts | 120 ++++ app/api/import/suppliers/execute/route.ts | 193 ++++++ app/api/import/suppliers/parse/route.ts | 113 ++++ app/api/suppliers/route.ts | 10 + components/RecaptIdentify.tsx | 32 +- components/bookkeeping/JournalEntryList.tsx | 13 +- components/customers/CustomerForm.tsx | 2 +- components/dashboard/DashboardNav.tsx | 2 + components/import/CustomersEditStep.tsx | 253 +++++++ .../import/RegisterColumnMappingStep.tsx | 125 ++++ components/import/RegisterResultStep.tsx | 135 ++++ components/import/RegisterUploadStep.tsx | 123 ++++ components/import/SuppliersEditStep.tsx | 241 +++++++ components/invoices/InvoiceReviewContent.tsx | 2 +- components/suppliers/SupplierForm.tsx | 2 +- lib/api/schemas.ts | 54 ++ lib/errors/structured-errors.ts | 39 ++ lib/events/handlers/event-log-handler.ts | 3 +- lib/events/types.ts | 3 + lib/import/bank-file/formats/camt053.ts | 2 +- lib/import/bank-file/formats/generic-csv.ts | 2 +- lib/import/bank-file/formats/handelsbanken.ts | 2 +- lib/import/bank-file/formats/ica-banken.ts | 2 +- .../bank-file/formats/lansforsakringar.ts | 2 +- lib/import/bank-file/formats/lunar.ts | 2 +- .../bank-file/formats/nordea-business.ts | 2 +- lib/import/bank-file/formats/nordea.ts | 2 +- lib/import/bank-file/formats/seb.ts | 2 +- lib/import/bank-file/formats/skandia.ts | 2 +- lib/import/bank-file/formats/swedbank.ts | 2 +- .../__tests__/column-detector.test.ts | 47 ++ lib/import/customers/__tests__/parser.test.ts | 134 ++++ lib/import/customers/column-detector.ts | 106 +++ lib/import/customers/parser.ts | 199 ++++++ lib/import/customers/types.ts | 78 +++ lib/import/opening-balance/parser.ts | 3 +- lib/import/shared/__tests__/classify.test.ts | 107 +++ lib/import/shared/__tests__/encoding.test.ts | 79 +++ .../shared/__tests__/workbook-reader.test.ts | 77 +++ lib/import/shared/classify.ts | 106 +++ lib/import/shared/column-utils.ts | 68 ++ lib/import/{bank-file => shared}/encoding.ts | 26 +- lib/import/shared/workbook-reader.ts | 58 ++ .../__tests__/column-detector.test.ts | 38 ++ lib/import/suppliers/__tests__/parser.test.ts | 101 +++ lib/import/suppliers/column-detector.ts | 130 ++++ lib/import/suppliers/parser.ts | 206 ++++++ lib/import/suppliers/types.ts | 90 +++ lib/recapt.ts | 13 + scripts/fix-import-mojibake.ts | 186 +++++ types/recapt.d.ts | 14 +- 62 files changed, 4468 insertions(+), 124 deletions(-) create mode 100644 app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts create mode 100644 app/api/bookkeeping/voucher-sequences/next/route.ts create mode 100644 app/api/import/customers/execute/route.ts create mode 100644 app/api/import/customers/parse/route.ts create mode 100644 app/api/import/suppliers/execute/route.ts create mode 100644 app/api/import/suppliers/parse/route.ts create mode 100644 components/import/CustomersEditStep.tsx create mode 100644 components/import/RegisterColumnMappingStep.tsx create mode 100644 components/import/RegisterResultStep.tsx create mode 100644 components/import/RegisterUploadStep.tsx create mode 100644 components/import/SuppliersEditStep.tsx create mode 100644 lib/import/customers/__tests__/column-detector.test.ts create mode 100644 lib/import/customers/__tests__/parser.test.ts create mode 100644 lib/import/customers/column-detector.ts create mode 100644 lib/import/customers/parser.ts create mode 100644 lib/import/customers/types.ts create mode 100644 lib/import/shared/__tests__/classify.test.ts create mode 100644 lib/import/shared/__tests__/encoding.test.ts create mode 100644 lib/import/shared/__tests__/workbook-reader.test.ts create mode 100644 lib/import/shared/classify.ts create mode 100644 lib/import/shared/column-utils.ts rename lib/import/{bank-file => shared}/encoding.ts (71%) create mode 100644 lib/import/shared/workbook-reader.ts create mode 100644 lib/import/suppliers/__tests__/column-detector.test.ts create mode 100644 lib/import/suppliers/__tests__/parser.test.ts create mode 100644 lib/import/suppliers/column-detector.ts create mode 100644 lib/import/suppliers/parser.ts create mode 100644 lib/import/suppliers/types.ts create mode 100644 lib/recapt.ts create mode 100644 scripts/fix-import-mojibake.ts diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index d9612ec8..6f1736f8 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -1,7 +1,8 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import Link from 'next/link' +import { useRouter, useSearchParams } from 'next/navigation' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' @@ -20,30 +21,43 @@ interface CopyPrefill { notes: 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 - -function readCopyFromParam(): string | null { - if (typeof window === 'undefined') return null - const raw = new URLSearchParams(window.location.search).get('copy_from') - if (!raw) return null - // Guard against path-traversal or other malformed input in the fetch URL. - return UUID_RE.test(raw) ? raw : null +interface NextVoucher { + next: number + series: string } +type TabValue = 'journal' | 'new-entry' | 'accounts' + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + export default function BookkeepingPage() { const { toast } = useToast() + const router = useRouter() + const searchParams = useSearchParams() + const copyFromId = useMemo(() => { + const raw = searchParams.get('copy_from') + return raw && UUID_RE.test(raw) ? raw : null + }, [searchParams]) + const [refreshKey, setRefreshKey] = useState(0) - const [copyFromId] = useState(readCopyFromParam) - const [activeTab, setActiveTab] = useState(() => - copyFromId ? 'new-entry' : 'journal', - ) + const [activeTab, setActiveTab] = useState('journal') const [periodId, setPeriodId] = useState(null) const [copyPrefill, setCopyPrefill] = useState(null) - const [isLoadingCopy, setIsLoadingCopy] = useState(() => copyFromId !== null) + const [isLoadingCopy, setIsLoadingCopy] = useState(false) + const [nextVoucher, setNextVoucher] = useState(null) + // React to copy_from in URL: switch tab, fetch source entry, then clean URL. + // useSearchParams keeps this reactive even when navigation happens within the + // same route (e.g. clicking the Kopiera button in the expanded list row), + // which a one-shot useState initializer wouldn't notice. + /* eslint-disable react-hooks/set-state-in-effect -- URL→state sync requires sync setState */ useEffect(() => { if (!copyFromId) return + setActiveTab('new-entry') + setCopyPrefill(null) + setIsLoadingCopy(true) + fetch(`/api/bookkeeping/journal-entries/${copyFromId}`) .then((res) => res.json()) .then(({ data, error }: { data?: JournalEntry; error?: string }) => { @@ -85,10 +99,34 @@ export default function BookkeepingPage() { }) .finally(() => { setIsLoadingCopy(false) - // Clean the URL so a page refresh doesn't re-trigger the copy prefill. - window.history.replaceState({}, '', '/bookkeeping') + // Clear copy_from so a refresh doesn't re-trigger and so clicking the + // same entry's Kopiera button again re-fires this effect. + router.replace('/bookkeeping') }) - }, [copyFromId, toast]) + }, [copyFromId, toast, router]) + /* eslint-enable react-hooks/set-state-in-effect */ + + // Fetch the next voucher number for today's fiscal period + default series. + // Re-runs after each commit (refreshKey++) so the tab label stays current. + useEffect(() => { + let cancelled = false + fetch('/api/bookkeeping/voucher-sequences/next') + .then((r) => r.json()) + .then(({ data }) => { + if (cancelled) return + if (data?.next != null) { + setNextVoucher({ next: data.next, series: data.series }) + } else { + setNextVoucher(null) + } + }) + .catch(() => { + if (!cancelled) setNextVoucher(null) + }) + return () => { + cancelled = true + } + }, [refreshKey]) return (
@@ -111,10 +149,17 @@ export default function BookkeepingPage() { )} - + setActiveTab(v as TabValue)}> Verifikationer - Ny verifikation + + Ny verifikation + {nextVoucher && ( + + ({nextVoucher.series}{nextVoucher.next}) + + )} + Kontoplan diff --git a/app/(dashboard)/customers/[id]/page.tsx b/app/(dashboard)/customers/[id]/page.tsx index 970c76ae..79141e38 100644 --- a/app/(dashboard)/customers/[id]/page.tsx +++ b/app/(dashboard)/customers/[id]/page.tsx @@ -32,7 +32,7 @@ import type { Customer, CustomerType, CreateCustomerInput } from '@/types' const customerTypeLabels: Record = { individual: 'Privatperson', - swedish_business: 'Svenskt företag', + swedish_business: 'Svenskt företag eller organisation', eu_business: 'EU-företag', non_eu_business: 'Utanför EU', } diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index 643e9eca..bb9ad14a 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -19,7 +19,7 @@ import type { Customer, CustomerType, CreateCustomerInput } from '@/types' const customerTypeLabels: Record = { individual: 'Privatperson', - swedish_business: 'Svenskt företag', + swedish_business: 'Svenskt företag eller organisation', eu_business: 'EU-företag', non_eu_business: 'Utanför EU', } diff --git a/app/(dashboard)/import/page.tsx b/app/(dashboard)/import/page.tsx index 1226d36e..e57eeecb 100644 --- a/app/(dashboard)/import/page.tsx +++ b/app/(dashboard)/import/page.tsx @@ -7,7 +7,8 @@ import { Progress } from '@/components/ui/progress' import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, Scale } from 'lucide-react' +import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet } from 'lucide-react' +import { motion } from 'framer-motion' import { cn } from '@/lib/utils' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' @@ -31,6 +32,23 @@ import OpeningBalancePeriodStep from '@/components/import/OpeningBalancePeriodSt import OpeningBalanceResultStep from '@/components/import/OpeningBalanceResultStep' import type { OpeningBalanceParseResult, OpeningBalanceExecuteResult, DetectedColumns } from '@/lib/import/opening-balance/types' +// Register import (customers/suppliers) components +import RegisterUploadStep from '@/components/import/RegisterUploadStep' +import RegisterColumnMappingStep, { type RegisterColumnSpec } from '@/components/import/RegisterColumnMappingStep' +import CustomersEditStep from '@/components/import/CustomersEditStep' +import SuppliersEditStep from '@/components/import/SuppliersEditStep' +import RegisterResultStep, { type RegisterResult } from '@/components/import/RegisterResultStep' +import type { + CustomerImportParseResult, + AnnotatedCustomerRow, + DetectedCustomerColumns, +} from '@/lib/import/customers/types' +import type { + SupplierImportParseResult, + AnnotatedSupplierRow, + DetectedSupplierColumns, +} from '@/lib/import/suppliers/types' + // SIE import components import SIEUploadStep from '@/components/import/SIEUploadStep' import SIEPreviewStep from '@/components/import/SIEPreviewStep' @@ -667,7 +685,7 @@ function SIEImportWizard() { } // ============================================================ -// Opening Balance Import Wizard +// Opening Balance Flow (entity = "opening_balance" inside CSVDataImportWizard) // ============================================================ type OpeningBalanceStep = 'upload' | 'column_mapping' | 'edit' | 'period' | 'result' @@ -680,7 +698,7 @@ const OB_STEP_LABELS: Record = { result: 'Resultat', } -function OpeningBalanceImportWizard() { +function OpeningBalanceFlow() { const { toast } = useToast() const [obStep, setObStep] = useState('upload') @@ -920,6 +938,598 @@ function OpeningBalanceImportWizard() { ) } +// ============================================================ +// Customers Flow (entity = "customers" inside CSVDataImportWizard) +// ============================================================ + +type RegisterStep = 'upload' | 'column_mapping' | 'edit' | 'result' + +const REGISTER_STEP_LABELS: Record = { + upload: 'Ladda upp', + column_mapping: 'Kolumnmappning', + edit: 'Granska', + result: 'Resultat', +} + +const CUSTOMER_COLUMN_SPECS: RegisterColumnSpec[] = [ + { key: 'name_col', label: 'Namn', required: true }, + { key: 'org_number_col', label: 'Org-/personnummer', required: false }, + { key: 'customer_type_col', label: 'Kundtyp', required: false }, + { key: 'email_col', label: 'E-post', required: false }, + { key: 'phone_col', label: 'Telefon', required: false }, + { key: 'address_line1_col', label: 'Adress', required: false }, + { key: 'address_line2_col', label: 'Adress rad 2', required: false }, + { key: 'postal_code_col', label: 'Postnummer', required: false }, + { key: 'city_col', label: 'Ort', required: false }, + { key: 'country_col', label: 'Land', required: false }, + { key: 'vat_number_col', label: 'VAT-nummer', required: false }, + { key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false }, + { key: 'notes_col', label: 'Anteckning', required: false }, +] + +function columnsToMapping( + cols: { readonly [key: string]: unknown }, + specs: RegisterColumnSpec[], +): Record { + const out = {} as Record + for (const spec of specs) { + const v = cols[spec.key as string] + out[spec.key] = typeof v === 'number' ? v : null + } + return out +} + +function CustomersFlow() { + const { toast } = useToast() + + const [step, setStep] = useState('upload') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [file, setFile] = useState(null) + const [parseResult, setParseResult] = useState(null) + const [executeResult, setExecuteResult] = useState(null) + + const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8 + const steps: RegisterStep[] = needsMapping + ? ['upload', 'column_mapping', 'edit', 'result'] + : ['upload', 'edit', 'result'] + const currentStepIndex = steps.indexOf(step) + const progress = ((currentStepIndex + 1) / steps.length) * 100 + + const handleFileSelect = useCallback(async (selectedFile: File) => { + setError(null) + setIsLoading(true) + setFile(selectedFile) + + try { + const formData = new FormData() + formData.append('file', selectedFile) + + const res = await fetch('/api/import/customers/parse', { + method: 'POST', + body: formData, + }) + const data = await res.json() + + if (!res.ok) { + setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen') + return + } + + const result = data.data as CustomerImportParseResult + setParseResult(result) + + if (result.rows.length === 0) { + setError('Inga giltiga kundrader hittades. Kontrollera att filen innehåller en namnkolumn.') + return + } + + toast({ + title: 'Fil analyserad', + description: `${result.rows.length} kunder hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`, + }) + + setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit') + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte läsa filen') + } finally { + setIsLoading(false) + } + }, [toast]) + + const handleColumnMappingConfirm = useCallback(async ( + mapping: Record, + ) => { + if (!file) return + setIsLoading(true) + setError(null) + + try { + const overrides: DetectedCustomerColumns = { + name_col: mapping.name_col ?? 0, + org_number_col: mapping.org_number_col, + customer_type_col: mapping.customer_type_col, + email_col: mapping.email_col, + phone_col: mapping.phone_col, + address_line1_col: mapping.address_line1_col, + address_line2_col: mapping.address_line2_col, + postal_code_col: mapping.postal_code_col, + city_col: mapping.city_col, + country_col: mapping.country_col, + vat_number_col: mapping.vat_number_col, + payment_terms_col: mapping.payment_terms_col, + notes_col: mapping.notes_col, + confidence: 1, + } + + const formData = new FormData() + formData.append('file', file) + formData.append('column_overrides', JSON.stringify(overrides)) + + const res = await fetch('/api/import/customers/parse', { + method: 'POST', + body: formData, + }) + const data = await res.json() + + if (!res.ok) { + setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen med de valda kolumnerna') + return + } + + setParseResult(data.data) + setStep('edit') + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte läsa filen') + } finally { + setIsLoading(false) + } + }, [file]) + + const handleExecute = useCallback(async ( + rows: AnnotatedCustomerRow[], + updateDuplicates: boolean, + ) => { + setIsLoading(true) + setError(null) + + try { + const res = await fetch('/api/import/customers/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest), + update_duplicates: updateDuplicates, + }), + }) + const data = await res.json() + + if (!res.ok) { + setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades') + return + } + + setExecuteResult(data.data as RegisterResult) + setStep('result') + + const r = data.data as RegisterResult + toast({ + title: r.success ? 'Kunder importerade' : 'Importen slutfördes med fel', + description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`, + variant: r.success ? 'default' : 'destructive', + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Importen misslyckades') + } finally { + setIsLoading(false) + } + }, [toast]) + + const handleNewImport = () => { + setStep('upload') + setFile(null) + setParseResult(null) + setExecuteResult(null) + setError(null) + } + + const initialMapping = parseResult + ? columnsToMapping(parseResult.detected_columns as unknown as { [key: string]: unknown }, CUSTOMER_COLUMN_SPECS) + : null + + return ( +
+ + +
+
+ + Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]} + + {steps.map((s, i) => ( + + {REGISTER_STEP_LABELS[s]} + + ))} +
+ +
+
+
+ + {step === 'upload' && ( + + )} + + {step === 'column_mapping' && parseResult && initialMapping && ( + + headers={parseResult.headers} + previewRows={parseResult.preview_rows} + specs={CUSTOMER_COLUMN_SPECS} + initial={initialMapping} + onConfirm={handleColumnMappingConfirm} + onBack={() => setStep('upload')} + /> + )} + + {step === 'edit' && parseResult && ( + setStep(needsMapping ? 'column_mapping' : 'upload')} + isLoading={isLoading} + error={error} + /> + )} + + {step === 'result' && executeResult && ( + + )} +
+ ) +} + +// ============================================================ +// Suppliers Flow (entity = "suppliers" inside CSVDataImportWizard) +// ============================================================ + +const SUPPLIER_COLUMN_SPECS: RegisterColumnSpec[] = [ + { key: 'name_col', label: 'Namn', required: true }, + { key: 'org_number_col', label: 'Org-/personnummer', required: false }, + { key: 'supplier_type_col', label: 'Leverantörstyp', required: false }, + { key: 'email_col', label: 'E-post', required: false }, + { key: 'phone_col', label: 'Telefon', required: false }, + { key: 'address_line1_col', label: 'Adress', required: false }, + { key: 'address_line2_col', label: 'Adress rad 2', required: false }, + { key: 'postal_code_col', label: 'Postnummer', required: false }, + { key: 'city_col', label: 'Ort', required: false }, + { key: 'country_col', label: 'Land', required: false }, + { key: 'vat_number_col', label: 'VAT-nummer', required: false }, + { key: 'bankgiro_col', label: 'Bankgiro', required: false }, + { key: 'plusgiro_col', label: 'Plusgiro', required: false }, + { key: 'bank_account_col', label: 'Bankkonto', required: false }, + { key: 'iban_col', label: 'IBAN', required: false }, + { key: 'bic_col', label: 'BIC/SWIFT', required: false }, + { key: 'payment_terms_col', label: 'Betalningsvillkor (dagar)', required: false }, + { key: 'default_currency_col', label: 'Valuta', required: false }, + { key: 'notes_col', label: 'Anteckning', required: false }, +] + +function SuppliersFlow() { + const { toast } = useToast() + + const [step, setStep] = useState('upload') + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [file, setFile] = useState(null) + const [parseResult, setParseResult] = useState(null) + const [executeResult, setExecuteResult] = useState(null) + + const needsMapping = parseResult && parseResult.detected_columns.confidence < 0.8 + const steps: RegisterStep[] = needsMapping + ? ['upload', 'column_mapping', 'edit', 'result'] + : ['upload', 'edit', 'result'] + const currentStepIndex = steps.indexOf(step) + const progress = ((currentStepIndex + 1) / steps.length) * 100 + + const handleFileSelect = useCallback(async (selectedFile: File) => { + setError(null) + setIsLoading(true) + setFile(selectedFile) + + try { + const formData = new FormData() + formData.append('file', selectedFile) + + const res = await fetch('/api/import/suppliers/parse', { + method: 'POST', + body: formData, + }) + const data = await res.json() + + if (!res.ok) { + setError(data.error?.message_sv || data.error?.message || data.error || 'Kunde inte läsa filen') + return + } + + const result = data.data as SupplierImportParseResult + setParseResult(result) + + if (result.rows.length === 0) { + setError('Inga giltiga leverantörsrader hittades. Kontrollera att filen innehåller en namnkolumn.') + return + } + + toast({ + title: 'Fil analyserad', + description: `${result.rows.length} leverantörer hittades${result.duplicate_count > 0 ? ` (${result.duplicate_count} matchar befintliga)` : ''}`, + }) + + setStep(result.detected_columns.confidence < 0.8 ? 'column_mapping' : 'edit') + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte läsa filen') + } finally { + setIsLoading(false) + } + }, [toast]) + + const handleColumnMappingConfirm = useCallback(async ( + mapping: Record, + ) => { + if (!file) return + setIsLoading(true) + setError(null) + + try { + const overrides: DetectedSupplierColumns = { + name_col: mapping.name_col ?? 0, + org_number_col: mapping.org_number_col, + supplier_type_col: mapping.supplier_type_col, + email_col: mapping.email_col, + phone_col: mapping.phone_col, + address_line1_col: mapping.address_line1_col, + address_line2_col: mapping.address_line2_col, + postal_code_col: mapping.postal_code_col, + city_col: mapping.city_col, + country_col: mapping.country_col, + vat_number_col: mapping.vat_number_col, + bankgiro_col: mapping.bankgiro_col, + plusgiro_col: mapping.plusgiro_col, + bank_account_col: mapping.bank_account_col, + iban_col: mapping.iban_col, + bic_col: mapping.bic_col, + payment_terms_col: mapping.payment_terms_col, + default_currency_col: mapping.default_currency_col, + notes_col: mapping.notes_col, + confidence: 1, + } + + const formData = new FormData() + formData.append('file', file) + formData.append('column_overrides', JSON.stringify(overrides)) + + const res = await fetch('/api/import/suppliers/parse', { + method: 'POST', + body: formData, + }) + const data = await res.json() + + if (!res.ok) { + setError(data.error?.message_sv || data.error?.message || 'Kunde inte tolka filen') + return + } + + setParseResult(data.data) + setStep('edit') + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte läsa filen') + } finally { + setIsLoading(false) + } + }, [file]) + + const handleExecute = useCallback(async ( + rows: AnnotatedSupplierRow[], + updateDuplicates: boolean, + ) => { + setIsLoading(true) + setError(null) + + try { + const res = await fetch('/api/import/suppliers/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rows: rows.map(({ duplicate_match: _dup, is_valid: _v, validation_errors: _ve, ...rest }) => rest), + update_duplicates: updateDuplicates, + }), + }) + const data = await res.json() + + if (!res.ok) { + setError(data.error?.message_sv || data.error?.message || 'Importen misslyckades') + return + } + + setExecuteResult(data.data as RegisterResult) + setStep('result') + + const r = data.data as RegisterResult + toast({ + title: r.success ? 'Leverantörer importerade' : 'Importen slutfördes med fel', + description: `${r.created} skapade, ${r.updated} uppdaterade, ${r.skipped} hoppade över${r.failed > 0 ? `, ${r.failed} misslyckades` : ''}`, + variant: r.success ? 'default' : 'destructive', + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Importen misslyckades') + } finally { + setIsLoading(false) + } + }, [toast]) + + const handleNewImport = () => { + setStep('upload') + setFile(null) + setParseResult(null) + setExecuteResult(null) + setError(null) + } + + const initialMapping = parseResult + ? columnsToMapping(parseResult.detected_columns as unknown as { [key: string]: unknown }, SUPPLIER_COLUMN_SPECS) + : null + + return ( +
+ + +
+
+ + Steg {currentStepIndex + 1}/{steps.length}: {REGISTER_STEP_LABELS[step]} + + {steps.map((s, i) => ( + + {REGISTER_STEP_LABELS[s]} + + ))} +
+ +
+
+
+ + {step === 'upload' && ( + + )} + + {step === 'column_mapping' && parseResult && initialMapping && ( + + headers={parseResult.headers} + previewRows={parseResult.preview_rows} + specs={SUPPLIER_COLUMN_SPECS} + initial={initialMapping} + onConfirm={handleColumnMappingConfirm} + onBack={() => setStep('upload')} + /> + )} + + {step === 'edit' && parseResult && ( + setStep(needsMapping ? 'column_mapping' : 'upload')} + isLoading={isLoading} + error={error} + /> + )} + + {step === 'result' && executeResult && ( + + )} +
+ ) +} + +// ============================================================ +// CSV/Excel Data Import Wizard — entity selector + sub-flow +// ============================================================ + +type CSVDataEntity = 'opening_balance' | 'customers' | 'suppliers' + +const ENTITY_OPTIONS: { value: CSVDataEntity; label: string }[] = [ + { value: 'opening_balance', label: 'Ingående balanser' }, + { value: 'customers', label: 'Kunder' }, + { value: 'suppliers', label: 'Leverantörer' }, +] + +function CSVDataImportWizard() { + const [entity, setEntity] = useState('opening_balance') + + return ( +
+
+ {ENTITY_OPTIONS.map((opt) => { + const selected = entity === opt.value + return ( +
+ {selected && ( + + + + )} + +
+ ) + })} +
+ + {entity === 'opening_balance' && } + {entity === 'customers' && } + {entity === 'suppliers' && } +
+ ) +} + // ============================================================ // PSD2 Bank Connection (inline, from Enable Banking extension) // ============================================================ @@ -1112,7 +1722,7 @@ function PSD2ConnectWizard() { // Import Page with Selection Cards // ============================================================ -type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'opening_balance' | 'migration' +type ImportMode = null | 'psd2' | 'bank' | 'sie' | 'csv_data' | 'migration' export default function ImportPage() { const { company } = useCompany() @@ -1146,7 +1756,7 @@ export default function ImportPage() { setMode('migration') } else { const modeParam = searchParams.get('mode') - if (modeParam && ['psd2', 'bank', 'sie', 'opening_balance', 'migration'].includes(modeParam)) { + if (modeParam && ['psd2', 'bank', 'sie', 'csv_data', 'migration'].includes(modeParam)) { setMode(modeParam as ImportMode) } } @@ -1287,7 +1897,7 @@ export default function ImportPage() {
- {/* 4. Ingående balanser */} + {/* 4. CSV/Excel-data (ingående balanser, kunder, leverantörer) */}
{ if (!isSandbox) setMode('opening_balance') }} - onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('opening_balance') } }} + onClick={() => { if (!isSandbox) setMode('csv_data') }} + onKeyDown={(e) => { if (!isSandbox && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); setMode('csv_data') } }} >
- +
-

Ingående balanser

+

Importera CSV/Excel-data

- Importera ingående balanser från en Excel- eller CSV-fil. + Importera ingående balanser, kunder eller leverantörer.

- {['XLSX', 'CSV'].map(fmt => ( + {['XLSX', 'CSV', 'Ingående balanser', 'Kunder', 'Leverantörer'].map(fmt => ( {fmt} @@ -1366,7 +1976,7 @@ export default function ImportPage() { {mode === 'psd2' && } {mode === 'bank' && } {mode === 'sie' && } - {mode === 'opening_balance' && } + {mode === 'csv_data' && } {mode === 'migration' && }
) diff --git a/app/(dashboard)/settings/account/page.tsx b/app/(dashboard)/settings/account/page.tsx index 5904e019..36fa7c63 100644 --- a/app/(dashboard)/settings/account/page.tsx +++ b/app/(dashboard)/settings/account/page.tsx @@ -12,6 +12,7 @@ import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings import { AccountDangerZone } from '@/components/settings/AccountDangerZone' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { useSettings } from '@/components/settings/useSettings' +import { clearRecaptIdentity } from '@/lib/recapt' export default function AccountSettingsPage() { const router = useRouter() @@ -24,6 +25,7 @@ export default function AccountSettingsPage() { useEffect(() => { setMounted(true) }, []) async function handleLogout() { + clearRecaptIdentity() await supabase.auth.signOut() router.push('/login') } diff --git a/app/(dashboard)/suppliers/[id]/page.tsx b/app/(dashboard)/suppliers/[id]/page.tsx index 5aa99e35..f8f4b219 100644 --- a/app/(dashboard)/suppliers/[id]/page.tsx +++ b/app/(dashboard)/suppliers/[id]/page.tsx @@ -16,7 +16,7 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types' const supplierTypeLabels: Record = { - swedish_business: 'Svenskt företag', + swedish_business: 'Svenskt företag eller organisation', eu_business: 'EU-företag', non_eu_business: 'Utanför EU', } diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx index b7ef0efa..f3459573 100644 --- a/app/(dashboard)/suppliers/page.tsx +++ b/app/(dashboard)/suppliers/page.tsx @@ -3,12 +3,11 @@ import { useState, useEffect } from 'react' import { createClient } from '@/lib/supabase/client' import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' +import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { useToast } from '@/components/ui/use-toast' -import { Plus, Search, Building2, Globe, Lock } from 'lucide-react' +import { Plus, Search, Building2, Lock } from 'lucide-react' import SupplierForm from '@/components/suppliers/SupplierForm' import Link from 'next/link' import { useCompany } from '@/contexts/CompanyContext' @@ -16,15 +15,23 @@ import { useCanWrite } from '@/lib/hooks/use-can-write' import type { Supplier, SupplierType, CreateSupplierInput } from '@/types' const supplierTypeLabels: Record = { - swedish_business: 'Svenskt företag', + swedish_business: 'Svenskt företag eller organisation', eu_business: 'EU-företag', non_eu_business: 'Utanför EU', } -const supplierTypeIcons: Record = { - swedish_business: Building2, - eu_business: Globe, - non_eu_business: Globe, +function getPaymentInfo(supplier: Supplier): { label: string; value: string } | null { + if (supplier.bankgiro) return { label: 'BG', value: supplier.bankgiro } + if (supplier.plusgiro) return { label: 'PG', value: supplier.plusgiro } + if (supplier.iban) return { label: 'IBAN', value: supplier.iban } + if (supplier.bank_account) return { label: 'Bankkonto', value: supplier.bank_account } + return null +} + +function formatLocation(supplier: Supplier): string | null { + if (supplier.city && supplier.country) return `${supplier.city}, ${supplier.country}` + if (supplier.city) return supplier.city + return null } export default function SuppliersPage() { @@ -150,12 +157,12 @@ export default function SuppliersPage() {
{[1, 2, 3].map((i) => ( - +
-
- - -
+
+
+
+
))} @@ -198,38 +205,50 @@ export default function SuppliersPage() { ) : (
{filteredSuppliers.map((supplier) => { - const Icon = supplierTypeIcons[supplier.supplier_type] + const payment = getPaymentInfo(supplier) + const location = formatLocation(supplier) return ( - - - -
-
-
- -
-
- {supplier.name} - {supplier.email || 'Ingen e-post'} -
-
- + + + +
+

+ {supplier.name} +

+

{supplierTypeLabels[supplier.supplier_type]} - +

- - -
+ +
{supplier.org_number && ( -

Org.nr: {supplier.org_number}

+
+
Org.nr
+
{supplier.org_number}
+
)} - {supplier.bankgiro && ( -

Bankgiro: {supplier.bankgiro}

+ {payment && ( +
+
{payment.label}
+
{payment.value}
+
)} - {supplier.city && ( -

{supplier.city}, {supplier.country}

+ {supplier.email && ( +
+
E-post
+
{supplier.email}
+
)} -
+ {location && ( +
+
Plats
+
{location}
+
+ )} + {!supplier.org_number && !payment && !supplier.email && !location && ( +

Inga kontaktuppgifter

+ )} +
diff --git a/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts b/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts new file mode 100644 index 00000000..5a2fac97 --- /dev/null +++ b/app/api/bookkeeping/voucher-sequences/next/__tests__/route.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockFrom = vi.fn() +const mockAuth = vi.fn() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockResolvedValue({ + from: (...args: unknown[]) => mockFrom(...args), + auth: { getUser: () => mockAuth() }, + }), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +import { GET } from '../route' + +interface ChainResult { + data?: unknown + error?: unknown +} + +function mockChain(result: ChainResult) { + const chain: Record = {} + for (const m of ['select', 'eq', 'lte', 'gte']) { + chain[m] = vi.fn().mockReturnValue(chain) + } + chain.maybeSingle = vi.fn().mockResolvedValue(result) + return chain +} + +function mkReq() { + return new Request('http://localhost/api/bookkeeping/voucher-sequences/next') +} + +function mkParams() { + return { params: Promise.resolve({}) } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/bookkeeping/voucher-sequences/next', () => { + it('returns 401 when not authenticated', async () => { + mockAuth.mockResolvedValue({ data: { user: null } }) + + const response = await GET(mkReq(), mkParams()) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.error).toBe('Unauthorized') + }) + + it('returns last_number + 1 when sequence exists', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return mockChain({ data: { id: 'period-1' }, error: null }) + } + if (table === 'company_settings') { + return mockChain({ data: { default_voucher_series: 'A' }, error: null }) + } + if (table === 'voucher_sequences') { + return mockChain({ data: { last_number: 57 }, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }) + + const response = await GET(mkReq(), mkParams()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ next: 58, series: 'A', fiscal_period_id: 'period-1' }) + }) + + it('returns 1 when no sequence row exists yet', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return mockChain({ data: { id: 'period-1' }, error: null }) + } + if (table === 'company_settings') { + return mockChain({ data: null, error: null }) + } + if (table === 'voucher_sequences') { + return mockChain({ data: null, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }) + + const response = await GET(mkReq(), mkParams()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ next: 1, series: 'A', fiscal_period_id: 'period-1' }) + }) + + it('returns next: null when no fiscal period covers today', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return mockChain({ data: null, error: null }) + } + if (table === 'company_settings') { + return mockChain({ data: { default_voucher_series: 'V' }, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }) + + const response = await GET(mkReq(), mkParams()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ next: null, series: 'V', fiscal_period_id: null }) + }) + + it('honors a non-default voucher series from company settings', async () => { + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + + mockFrom.mockImplementation((table: string) => { + if (table === 'fiscal_periods') { + return mockChain({ data: { id: 'period-2' }, error: null }) + } + if (table === 'company_settings') { + return mockChain({ data: { default_voucher_series: 'V' }, error: null }) + } + if (table === 'voucher_sequences') { + return mockChain({ data: { last_number: 12 }, error: null }) + } + throw new Error(`Unexpected table: ${table}`) + }) + + const response = await GET(mkReq(), mkParams()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toEqual({ next: 13, series: 'V', fiscal_period_id: 'period-2' }) + }) +}) diff --git a/app/api/bookkeeping/voucher-sequences/next/route.ts b/app/api/bookkeeping/voucher-sequences/next/route.ts new file mode 100644 index 00000000..76d174ab --- /dev/null +++ b/app/api/bookkeeping/voucher-sequences/next/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse } from '@/lib/errors/get-structured-error' + +export const GET = withRouteContext( + 'voucher_sequence.next', + async (_request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const today = new Date().toISOString().split('T')[0] + + const [{ data: period, error: periodError }, { data: settings, error: settingsError }] = + await Promise.all([ + supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', companyId) + .lte('period_start', today) + .gte('period_end', today) + .maybeSingle(), + supabase + .from('company_settings') + .select('default_voucher_series') + .eq('company_id', companyId) + .maybeSingle(), + ]) + + if (periodError) { + log.error('fiscal_periods lookup failed', periodError) + return errorResponse(periodError, log, { requestId }) + } + if (settingsError) { + log.error('company_settings lookup failed', settingsError) + return errorResponse(settingsError, log, { requestId }) + } + + const series = settings?.default_voucher_series || 'A' + + if (!period) { + return NextResponse.json({ data: { next: null, series, fiscal_period_id: null } }) + } + + const { data: sequence, error: sequenceError } = await supabase + .from('voucher_sequences') + .select('last_number') + .eq('company_id', companyId) + .eq('fiscal_period_id', period.id) + .eq('voucher_series', series) + .maybeSingle() + + if (sequenceError) { + log.error('voucher_sequences lookup failed', sequenceError) + return errorResponse(sequenceError, log, { requestId }) + } + + const next = (sequence?.last_number ?? 0) + 1 + + return NextResponse.json({ + data: { next, series, fiscal_period_id: period.id }, + }) + }, +) diff --git a/app/api/import/bank-file/parse/route.ts b/app/api/import/bank-file/parse/route.ts index e32161ef..3fe4a7ef 100644 --- a/app/api/import/bank-file/parse/route.ts +++ b/app/api/import/bank-file/parse/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { parseBankFile, generateFileHash, detectFileFormat } from '@/lib/import/bank-file/parser' -import { decodeFileContent } from '@/lib/import/bank-file/encoding' +import { decodeFileContent } from '@/lib/import/shared/encoding' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import type { BankFileFormatId } from '@/lib/import/bank-file/types' diff --git a/app/api/import/customers/execute/route.ts b/app/api/import/customers/execute/route.ts new file mode 100644 index 00000000..8e73d476 --- /dev/null +++ b/app/api/import/customers/execute/route.ts @@ -0,0 +1,208 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events' +import { validateBody } from '@/lib/api/validate' +import { CustomerImportExecuteSchema } from '@/lib/api/schemas' +import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { Customer } from '@/types' +import type { CustomerImportExecuteResult } from '@/lib/import/customers/types' + +ensureInitialized() + +interface ExistingCustomer { + id: string + name: string + org_number: string | null + email: string | null + phone: string | null + address_line1: string | null + address_line2: string | null + postal_code: string | null + city: string | null + country: string + vat_number: string | null + default_payment_terms: number + notes: string | null + customer_type: string +} + +/** + * POST /api/import/customers/execute + * + * Imports validated customer rows. Duplicates (matched by org_number or email) + * are either updated (merge — only non-empty file fields overwrite) or skipped + * based on `update_duplicates`. + */ +export const POST = withRouteContext( + 'register_import.customers.execute', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const result = await validateBody(request, CustomerImportExecuteSchema, { + log, + operation: 'register_import.customers.execute', + }) + if (!result.success) return result.response + + const { rows, update_duplicates } = result.data + const opLog = log.child({ rowCount: rows.length, updateDuplicates: update_duplicates }) + + if (rows.length === 0) { + return errorResponseFromCode('REG_IMPORT_NO_ROWS', opLog, { requestId }) + } + + try { + const existingRaw = await fetchAllRows(({ from, to }) => + supabase + .from('customers') + .select( + 'id, name, org_number, email, phone, address_line1, address_line2, ' + + 'postal_code, city, country, vat_number, default_payment_terms, notes, ' + + 'customer_type', + ) + .eq('company_id', companyId) + .range(from, to), + ) + const existing = existingRaw as unknown as ExistingCustomer[] + + const byOrg = new Map() + const byEmail = new Map() + for (const c of existing) { + const org = normalizeOrgNumber(c.org_number) + if (org) byOrg.set(org, c) + const email = normalizeEmail(c.email) + if (email) byEmail.set(email, c) + } + + const created: Customer[] = [] + const updated: Customer[] = [] + let skipped = 0 + const errors: { row_index: number; name: string; reason: string }[] = [] + + for (const row of rows) { + const orgKey = normalizeOrgNumber(row.org_number) + const emailKey = normalizeEmail(row.email) + const match = + (orgKey && byOrg.get(orgKey)) || + (emailKey && byEmail.get(emailKey)) || + null + + if (match) { + if (!update_duplicates) { + skipped++ + continue + } + + // Merge mode: only overwrite fields where the file has a non-empty value. + const merged: Record = {} + if (row.name) merged.name = row.name + if (row.customer_type) merged.customer_type = row.customer_type + if (row.org_number) merged.org_number = row.org_number + if (row.email) merged.email = row.email + if (row.phone) merged.phone = row.phone + if (row.address_line1) merged.address_line1 = row.address_line1 + if (row.address_line2) merged.address_line2 = row.address_line2 + if (row.postal_code) merged.postal_code = row.postal_code + if (row.city) merged.city = row.city + if (row.country) merged.country = row.country + if (row.vat_number) merged.vat_number = row.vat_number + if (row.default_payment_terms) merged.default_payment_terms = row.default_payment_terms + if (row.notes) merged.notes = row.notes + + if (Object.keys(merged).length === 0) { + skipped++ + continue + } + + const { data, error } = await supabase + .from('customers') + .update(merged) + .eq('id', match.id) + .eq('company_id', companyId) + .select() + .single() + + if (error) { + errors.push({ row_index: row.row_index, name: row.name, reason: error.message }) + continue + } + if (data) updated.push(data as Customer) + continue + } + + // No match — create. + const { data, error } = await supabase + .from('customers') + .insert({ + user_id: user.id, + company_id: companyId, + name: row.name, + customer_type: row.customer_type, + email: row.email, + phone: row.phone, + address_line1: row.address_line1, + address_line2: row.address_line2, + postal_code: row.postal_code, + city: row.city, + country: row.country || 'Sweden', + org_number: row.org_number, + vat_number: row.vat_number, + default_payment_terms: row.default_payment_terms || 30, + notes: row.notes, + }) + .select() + .single() + + if (error) { + // Treat unique-violation as a soft skip (race with concurrent import). + if (error.code === '23505') { + skipped++ + continue + } + errors.push({ row_index: row.row_index, name: row.name, reason: error.message }) + continue + } + if (data) { + created.push(data as Customer) + // Track newly inserted org/email so subsequent rows in the same batch + // dedup against them too. + const newOrg = normalizeOrgNumber(data.org_number) + if (newOrg) byOrg.set(newOrg, data as ExistingCustomer) + const newEmail = normalizeEmail(data.email) + if (newEmail) byEmail.set(newEmail, data as ExistingCustomer) + } + } + + // Emit events for downstream listeners (non-blocking). + for (const c of created) { + await eventBus.emit({ + type: 'customer.created', + payload: { customer: c, companyId: companyId!, userId: user.id }, + }) + } + + const response: CustomerImportExecuteResult = { + success: errors.length === 0, + created: created.length, + updated: updated.length, + skipped, + failed: errors.length, + errors, + } + + opLog.info('customer import complete', response) + + return NextResponse.json({ data: response }) + } catch (err) { + opLog.error('customer import execute failed', err as Error) + return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/import/customers/parse/route.ts b/app/api/import/customers/parse/route.ts new file mode 100644 index 00000000..8ee58651 --- /dev/null +++ b/app/api/import/customers/parse/route.ts @@ -0,0 +1,120 @@ +import { NextResponse } from 'next/server' +import { parseCustomersFile } from '@/lib/import/customers/parser' +import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { + AnnotatedCustomerRow, + CustomerImportParseResult, + DetectedCustomerColumns, +} from '@/lib/import/customers/types' + +const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods'] +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB + +/** + * POST /api/import/customers/parse + * + * Accepts an Excel/CSV file via FormData, auto-detects columns, parses rows, + * and annotates each row with any duplicate-match against existing customers. + */ +export const POST = withRouteContext( + 'register_import.customers.parse', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const formData = await request.formData() + const file = formData.get('file') as File | null + const columnOverridesRaw = formData.get('column_overrides') as string | null + + if (!file) { + return errorResponseFromCode('REG_IMPORT_NO_FILE', log, { requestId }) + } + + if (file.size > MAX_FILE_SIZE) { + return errorResponseFromCode('REG_IMPORT_FILE_TOO_LARGE', log, { + requestId, + details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) }, + }) + } + + const ext = '.' + file.name.split('.').pop()?.toLowerCase() + if (!ALLOWED_EXTENSIONS.includes(ext)) { + return errorResponseFromCode('REG_IMPORT_INVALID_FORMAT', log, { + requestId, + details: { extension: ext, allowed: ALLOWED_EXTENSIONS }, + }) + } + + const opLog = log.child({ filename: file.name, sizeBytes: file.size }) + + let columnOverrides: DetectedCustomerColumns | undefined + if (columnOverridesRaw) { + try { + columnOverrides = JSON.parse(columnOverridesRaw) + } catch { + return errorResponseFromCode('REG_IMPORT_INVALID_COLUMN_OVERRIDES', opLog, { requestId }) + } + } + + try { + const buffer = await file.arrayBuffer() + const parsed = parseCustomersFile(buffer, file.name, columnOverrides) + + // Fetch existing customers for duplicate detection. + const existing = await fetchAllRows(({ from, to }) => + supabase + .from('customers') + .select('id, name, org_number, email') + .eq('company_id', companyId) + .range(from, to), + ) + + const byOrg = new Map() + const byEmail = new Map() + for (const c of existing) { + const org = normalizeOrgNumber(c.org_number) + if (org) byOrg.set(org, { id: c.id, name: c.name }) + const email = normalizeEmail(c.email) + if (email) byEmail.set(email, { id: c.id, name: c.name }) + } + + let duplicateCount = 0 + const annotated: AnnotatedCustomerRow[] = parsed.rows.map((r) => { + const orgKey = normalizeOrgNumber(r.org_number) + const emailKey = normalizeEmail(r.email) + let match: AnnotatedCustomerRow['duplicate_match'] = null + if (orgKey && byOrg.has(orgKey)) { + const e = byOrg.get(orgKey)! + match = { customer_id: e.id, matched_by: 'org_number', existing_name: e.name } + } else if (emailKey && byEmail.has(emailKey)) { + const e = byEmail.get(emailKey)! + match = { customer_id: e.id, matched_by: 'email', existing_name: e.name } + } + if (match) duplicateCount++ + return { ...r, duplicate_match: match } + }) + + const result: CustomerImportParseResult = { + filename: parsed.filename, + sheet_name: parsed.sheet_name, + total_rows: annotated.length, + detected_columns: parsed.detected_columns, + headers: parsed.headers, + preview_rows: parsed.preview_rows, + rows: annotated, + duplicate_count: duplicateCount, + warnings: parsed.warnings, + } + + return NextResponse.json({ data: result }) + } catch (err) { + opLog.error('customer import parse failed', err as Error) + return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/import/suppliers/execute/route.ts b/app/api/import/suppliers/execute/route.ts new file mode 100644 index 00000000..f659e914 --- /dev/null +++ b/app/api/import/suppliers/execute/route.ts @@ -0,0 +1,193 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { eventBus } from '@/lib/events' +import { validateBody } from '@/lib/api/validate' +import { SupplierImportExecuteSchema } from '@/lib/api/schemas' +import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { Supplier } from '@/types' +import type { SupplierImportExecuteResult } from '@/lib/import/suppliers/types' + +ensureInitialized() + +interface ExistingSupplier { + id: string + name: string + org_number: string | null + email: string | null +} + +export const POST = withRouteContext( + 'register_import.suppliers.execute', + async (request, ctx) => { + const { user, supabase, companyId, log, requestId } = ctx + + const result = await validateBody(request, SupplierImportExecuteSchema, { + log, + operation: 'register_import.suppliers.execute', + }) + if (!result.success) return result.response + + const { rows, update_duplicates } = result.data + const opLog = log.child({ rowCount: rows.length, updateDuplicates: update_duplicates }) + + if (rows.length === 0) { + return errorResponseFromCode('REG_IMPORT_NO_ROWS', opLog, { requestId }) + } + + try { + const existingRaw = await fetchAllRows(({ from, to }) => + supabase + .from('suppliers') + .select('id, name, org_number, email') + .eq('company_id', companyId) + .range(from, to), + ) + const existing = existingRaw as unknown as ExistingSupplier[] + + const byOrg = new Map() + const byEmail = new Map() + for (const s of existing) { + const org = normalizeOrgNumber(s.org_number) + if (org) byOrg.set(org, s) + const email = normalizeEmail(s.email) + if (email) byEmail.set(email, s) + } + + const created: Supplier[] = [] + const updated: Supplier[] = [] + let skipped = 0 + const errors: { row_index: number; name: string; reason: string }[] = [] + + for (const row of rows) { + const orgKey = normalizeOrgNumber(row.org_number) + const emailKey = normalizeEmail(row.email) + const match = + (orgKey && byOrg.get(orgKey)) || + (emailKey && byEmail.get(emailKey)) || + null + + if (match) { + if (!update_duplicates) { + skipped++ + continue + } + + const merged: Record = {} + if (row.name) merged.name = row.name + if (row.supplier_type) merged.supplier_type = row.supplier_type + if (row.org_number) merged.org_number = row.org_number + if (row.email) merged.email = row.email + if (row.phone) merged.phone = row.phone + if (row.address_line1) merged.address_line1 = row.address_line1 + if (row.address_line2) merged.address_line2 = row.address_line2 + if (row.postal_code) merged.postal_code = row.postal_code + if (row.city) merged.city = row.city + if (row.country) merged.country = row.country + if (row.vat_number) merged.vat_number = row.vat_number + if (row.bankgiro) merged.bankgiro = row.bankgiro + if (row.plusgiro) merged.plusgiro = row.plusgiro + if (row.bank_account) merged.bank_account = row.bank_account + if (row.iban) merged.iban = row.iban + if (row.bic) merged.bic = row.bic + if (row.default_payment_terms) merged.default_payment_terms = row.default_payment_terms + if (row.default_currency) merged.default_currency = row.default_currency + if (row.notes) merged.notes = row.notes + + if (Object.keys(merged).length === 0) { + skipped++ + continue + } + + const { data, error } = await supabase + .from('suppliers') + .update(merged) + .eq('id', match.id) + .eq('company_id', companyId) + .select() + .single() + + if (error) { + errors.push({ row_index: row.row_index, name: row.name, reason: error.message }) + continue + } + if (data) updated.push(data as Supplier) + continue + } + + const { data, error } = await supabase + .from('suppliers') + .insert({ + user_id: user.id, + company_id: companyId, + name: row.name, + supplier_type: row.supplier_type, + email: row.email, + phone: row.phone, + address_line1: row.address_line1, + address_line2: row.address_line2, + postal_code: row.postal_code, + city: row.city, + country: row.country || 'SE', + org_number: row.org_number, + vat_number: row.vat_number, + bankgiro: row.bankgiro, + plusgiro: row.plusgiro, + bank_account: row.bank_account, + iban: row.iban, + bic: row.bic, + default_payment_terms: row.default_payment_terms || 30, + default_currency: row.default_currency || 'SEK', + notes: row.notes, + }) + .select() + .single() + + if (error) { + if (error.code === '23505') { + skipped++ + continue + } + errors.push({ row_index: row.row_index, name: row.name, reason: error.message }) + continue + } + if (data) { + created.push(data as Supplier) + const newOrg = normalizeOrgNumber(data.org_number) + if (newOrg) byOrg.set(newOrg, data as ExistingSupplier) + const newEmail = normalizeEmail(data.email) + if (newEmail) byEmail.set(newEmail, data as ExistingSupplier) + } + } + + for (const s of created) { + await eventBus.emit({ + type: 'supplier.created', + payload: { supplier: s, companyId, userId: user.id }, + }) + } + + const response: SupplierImportExecuteResult = { + success: errors.length === 0, + created: created.length, + updated: updated.length, + skipped, + failed: errors.length, + errors, + } + + opLog.info('supplier import complete', response) + + return NextResponse.json({ data: response }) + } catch (err) { + opLog.error('supplier import execute failed', err as Error) + return errorResponseFromCode('REG_IMPORT_EXECUTE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/import/suppliers/parse/route.ts b/app/api/import/suppliers/parse/route.ts new file mode 100644 index 00000000..c3c10576 --- /dev/null +++ b/app/api/import/suppliers/parse/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server' +import { parseSuppliersFile } from '@/lib/import/suppliers/parser' +import { normalizeOrgNumber, normalizeEmail } from '@/lib/import/shared/column-utils' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { + AnnotatedSupplierRow, + SupplierImportParseResult, + DetectedSupplierColumns, +} from '@/lib/import/suppliers/types' + +const ALLOWED_EXTENSIONS = ['.xlsx', '.xls', '.csv', '.ods'] +const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB + +export const POST = withRouteContext( + 'register_import.suppliers.parse', + async (request, ctx) => { + const { supabase, companyId, log, requestId } = ctx + + const formData = await request.formData() + const file = formData.get('file') as File | null + const columnOverridesRaw = formData.get('column_overrides') as string | null + + if (!file) { + return errorResponseFromCode('REG_IMPORT_NO_FILE', log, { requestId }) + } + + if (file.size > MAX_FILE_SIZE) { + return errorResponseFromCode('REG_IMPORT_FILE_TOO_LARGE', log, { + requestId, + details: { sizeMb: +(file.size / 1024 / 1024).toFixed(1) }, + }) + } + + const ext = '.' + file.name.split('.').pop()?.toLowerCase() + if (!ALLOWED_EXTENSIONS.includes(ext)) { + return errorResponseFromCode('REG_IMPORT_INVALID_FORMAT', log, { + requestId, + details: { extension: ext, allowed: ALLOWED_EXTENSIONS }, + }) + } + + const opLog = log.child({ filename: file.name, sizeBytes: file.size }) + + let columnOverrides: DetectedSupplierColumns | undefined + if (columnOverridesRaw) { + try { + columnOverrides = JSON.parse(columnOverridesRaw) + } catch { + return errorResponseFromCode('REG_IMPORT_INVALID_COLUMN_OVERRIDES', opLog, { requestId }) + } + } + + try { + const buffer = await file.arrayBuffer() + const parsed = parseSuppliersFile(buffer, file.name, columnOverrides) + + const existing = await fetchAllRows(({ from, to }) => + supabase + .from('suppliers') + .select('id, name, org_number, email') + .eq('company_id', companyId) + .range(from, to), + ) + + const byOrg = new Map() + const byEmail = new Map() + for (const s of existing) { + const org = normalizeOrgNumber(s.org_number) + if (org) byOrg.set(org, { id: s.id, name: s.name }) + const email = normalizeEmail(s.email) + if (email) byEmail.set(email, { id: s.id, name: s.name }) + } + + let duplicateCount = 0 + const annotated: AnnotatedSupplierRow[] = parsed.rows.map((r) => { + const orgKey = normalizeOrgNumber(r.org_number) + const emailKey = normalizeEmail(r.email) + let match: AnnotatedSupplierRow['duplicate_match'] = null + if (orgKey && byOrg.has(orgKey)) { + const e = byOrg.get(orgKey)! + match = { supplier_id: e.id, matched_by: 'org_number', existing_name: e.name } + } else if (emailKey && byEmail.has(emailKey)) { + const e = byEmail.get(emailKey)! + match = { supplier_id: e.id, matched_by: 'email', existing_name: e.name } + } + if (match) duplicateCount++ + return { ...r, duplicate_match: match } + }) + + const result: SupplierImportParseResult = { + filename: parsed.filename, + sheet_name: parsed.sheet_name, + total_rows: annotated.length, + detected_columns: parsed.detected_columns, + headers: parsed.headers, + preview_rows: parsed.preview_rows, + rows: annotated, + duplicate_count: duplicateCount, + warnings: parsed.warnings, + } + + return NextResponse.json({ data: result }) + } catch (err) { + opLog.error('supplier import parse failed', err as Error) + return errorResponseFromCode('REG_IMPORT_PARSE_FAILED', opLog, { + requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + }, +) diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts index 114501a8..6db9eb9d 100644 --- a/app/api/suppliers/route.ts +++ b/app/api/suppliers/route.ts @@ -1,8 +1,13 @@ import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' import { validateBody } from '@/lib/api/validate' import { CreateSupplierSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import type { Supplier } from '@/types' + +ensureInitialized() export const GET = withRouteContext( 'supplier.list', @@ -79,6 +84,11 @@ export const POST = withRouteContext( }) } + await eventBus.emit({ + type: 'supplier.created', + payload: { supplier: data as Supplier, companyId, userId: user.id }, + }) + return NextResponse.json({ data }) }, { requireWrite: true }, diff --git a/components/RecaptIdentify.tsx b/components/RecaptIdentify.tsx index 17d187e6..90d98103 100644 --- a/components/RecaptIdentify.tsx +++ b/components/RecaptIdentify.tsx @@ -2,21 +2,6 @@ import { useEffect } from 'react' -declare global { - interface Window { - Recapt?: { - session: { - setIdentity: (identity: { - uid: string - email?: string - nickname?: string - fullName?: string - }) => void - } - } - } -} - export function RecaptIdentify({ userId, email, @@ -27,16 +12,23 @@ export function RecaptIdentify({ displayName?: string }) { useEffect(() => { + let attempts = 0 + const maxAttempts = 50 const interval = setInterval(() => { - if (typeof window.Recapt?.session?.setIdentity === 'function') { - window.Recapt.session.setIdentity({ + if (typeof window.recapt === 'function') { + window.recapt('identify', { uid: userId, - email: email, - fullName: displayName, + email, + nickname: displayName, }) clearInterval(interval) + return } - }, 500) + attempts++ + if (attempts >= maxAttempts) { + clearInterval(interval) + } + }, 100) return () => clearInterval(interval) }, [userId, email, displayName]) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index b37691f2..dca9cdb0 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -2,12 +2,13 @@ import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' +import { useRouter } from 'next/navigation' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' -import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X } from 'lucide-react' +import { ArrowDownNarrowWide, ArrowUpNarrowWide, ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy } from 'lucide-react' import { Input } from '@/components/ui/input' import { AccountNumber } from '@/components/ui/account-number' import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' @@ -30,6 +31,7 @@ interface Props { } export default function JournalEntryList({ periodId }: Props) { + const router = useRouter() const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [expandedId, setExpandedId] = useState(null) @@ -463,6 +465,15 @@ export default function JournalEntryList({ periodId }: Props) { Skapa ändringsverifikation )} +
)} diff --git a/components/customers/CustomerForm.tsx b/components/customers/CustomerForm.tsx index 7c771626..687ca3fe 100644 --- a/components/customers/CustomerForm.tsx +++ b/components/customers/CustomerForm.tsx @@ -144,7 +144,7 @@ export default function CustomerForm({ Privatperson (Sverige) - Svenskt företag + Svenskt företag eller organisation EU-företag Företag utanför EU diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 9ce28a7a..f2f71fa4 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -33,6 +33,7 @@ import { resolveIcon } from '@/lib/extensions/icon-resolver' import { SupportLink } from '@/components/ui/support-link' import CompanySwitcher from '@/components/dashboard/CompanySwitcher' import { useCompany } from '@/contexts/CompanyContext' +import { clearRecaptIdentity } from '@/lib/recapt' import type { EntityType } from '@/types' interface ExtensionNavItem { @@ -127,6 +128,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un } const handleLogout = async () => { + clearRecaptIdentity() await supabase.auth.signOut() router.push(isSandbox ? '/sandbox' : '/login') } diff --git a/components/import/CustomersEditStep.tsx b/components/import/CustomersEditStep.tsx new file mode 100644 index 00000000..84b06977 --- /dev/null +++ b/components/import/CustomersEditStep.tsx @@ -0,0 +1,253 @@ +'use client' + +import { useMemo, useState, useCallback } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { Trash2, AlertTriangle, Loader2, RefreshCw } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { CustomerType } from '@/types' +import type { AnnotatedCustomerRow } from '@/lib/import/customers/types' + +let idCounter = 0 +const newId = () => `cust_row_${++idCounter}_${Date.now()}` + +interface EditableCustomerRow extends AnnotatedCustomerRow { + id: string +} + +interface CustomersEditStepProps { + rows: AnnotatedCustomerRow[] + onExecute: (rows: AnnotatedCustomerRow[], updateDuplicates: boolean) => void + onBack: () => void + isLoading: boolean + error: string | null +} + +const TYPE_LABELS: Record = { + individual: 'Privatperson', + swedish_business: 'Svenskt företag eller organisation', + eu_business: 'EU-företag', + non_eu_business: 'Utomeuropeiskt företag', +} + +export default function CustomersEditStep({ + rows: initialRows, + onExecute, + onBack, + isLoading, + error, +}: CustomersEditStepProps) { + const [rows, setRows] = useState(() => + initialRows.map((r) => ({ ...r, id: newId() })), + ) + const [updateDuplicates, setUpdateDuplicates] = useState(false) + + const liveDuplicateCount = useMemo( + () => rows.filter((r) => r.duplicate_match !== null).length, + [rows], + ) + + const newCount = rows.length - liveDuplicateCount + + const hasErrors = useMemo( + () => rows.some((r) => !r.is_valid), + [rows], + ) + + const canContinue = rows.length > 0 && !hasErrors && !isLoading + + const updateRow = useCallback((id: string, updates: Partial) => { + setRows((prev) => + prev.map((r) => (r.id === id ? { ...r, ...updates } : r)), + ) + }, []) + + const deleteRow = useCallback((id: string) => { + setRows((prev) => prev.filter((r) => r.id !== id)) + }, []) + + const handleExecute = () => { + if (!canContinue) return + const stripped: AnnotatedCustomerRow[] = rows.map(({ id: _id, ...rest }) => rest) + onExecute(stripped, updateDuplicates) + } + + return ( + + + Granska kunder + + Kontrollera att uppgifterna stämmer. Du kan justera namn och kundtyp inline, + eller ta bort rader. {newCount} ny{newCount === 1 ? '' : 'a'} kund{newCount === 1 ? '' : 'er'} skapas + {liveDuplicateCount > 0 ? ` och ${liveDuplicateCount} matchar befintliga.` : '.'} + + + + {/* Duplicate handling banner */} + {liveDuplicateCount > 0 && ( +
+ +
+

+ {liveDuplicateCount} rader matchar befintliga + kunder (på orgnummer eller e-post). +

+
+ + +
+ {updateDuplicates && ( +

+ Endast fält med värden i filen skrivs över. Tomma fält i filen lämnar + befintliga värden orörda. +

+ )} +
+
+ )} + + {/* Table */} +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
NamnKundtypOrgnrE-postStatus +
+ updateRow(row.id, { name: e.target.value })} + className="h-8" + /> + + + + {row.org_number || '—'} + + {row.email || '—'} + +
+ {!row.is_valid && ( + + + + )} + {row.duplicate_match ? ( + + {updateDuplicates ? 'Uppdateras' : 'Hoppas över'} + + ) : ( + + Ny + + )} +
+
+ +
+
+ + {hasErrors && ( +
+ +

+ Vissa rader har valideringsfel (markerade i rött). Åtgärda eller ta bort dem + innan du fortsätter. +

+
+ )} + + {error && ( +
+ +

{error}

+
+ )} + +
+ + +
+
+
+ ) +} diff --git a/components/import/RegisterColumnMappingStep.tsx b/components/import/RegisterColumnMappingStep.tsx new file mode 100644 index 00000000..f5581868 --- /dev/null +++ b/components/import/RegisterColumnMappingStep.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Label } from '@/components/ui/label' + +/** A field that can be mapped to a column in the uploaded file. */ +export interface RegisterColumnSpec { + key: K + label: string + required: boolean +} + +interface RegisterColumnMappingStepProps { + headers: string[] + previewRows: string[][] + specs: RegisterColumnSpec[] + initial: Record + onConfirm: (mapping: Record) => void + onBack: () => void +} + +export default function RegisterColumnMappingStep({ + headers, + previewRows, + specs, + initial, + onConfirm, + onBack, +}: RegisterColumnMappingStepProps) { + const [mapping, setMapping] = useState>(initial) + + const columnOptions = headers.map((h, i) => ({ + value: String(i), + label: `${i + 1}: ${h || '(tom)'}`, + })) + + const canContinue = specs + .filter((s) => s.required) + .every((s) => mapping[s.key] !== null && mapping[s.key]! >= 0) + + return ( + + + Kolumnmappning + + Vi kunde inte automatiskt identifiera alla kolumner. Ange vilka kolumner i din fil + som motsvarar respektive fält. Lämna tomt för fält som inte finns. + + + +
+ {specs.map((spec) => ( +
+ + +
+ ))} +
+ + {previewRows.length > 0 && ( +
+ +
+ + + + {headers.map((h, i) => ( + + ))} + + + + {previewRows.slice(0, 5).map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {h || `Kolumn ${i + 1}`} +
+ {cell} +
+
+
+ )} + +
+ + +
+
+
+ ) +} diff --git a/components/import/RegisterResultStep.tsx b/components/import/RegisterResultStep.tsx new file mode 100644 index 00000000..b9f8cbfa --- /dev/null +++ b/components/import/RegisterResultStep.tsx @@ -0,0 +1,135 @@ +'use client' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { CheckCircle2, XCircle, ArrowRight, AlertTriangle } from 'lucide-react' +import Link from 'next/link' + +export type RegisterResult = { + success: boolean + created: number + updated: number + skipped: number + failed: number + errors: { row_index: number; name: string; reason: string }[] +} + +interface RegisterResultStepProps { + entity: 'customers' | 'suppliers' + result: RegisterResult + onNewImport: () => void +} + +const ENTITY_COPY = { + customers: { + successTitle: 'Kunder importerade', + failTitle: 'Importen misslyckades', + listLabel: 'Visa alla kunder', + listHref: '/customers', + }, + suppliers: { + successTitle: 'Leverantörer importerade', + failTitle: 'Importen misslyckades', + listLabel: 'Visa alla leverantörer', + listHref: '/suppliers', + }, +} as const + +export default function RegisterResultStep({ + entity, + result, + onNewImport, +}: RegisterResultStepProps) { + const copy = ENTITY_COPY[entity] + const totalProcessed = result.created + result.updated + result.skipped + result.failed + const isPartial = result.failed > 0 && result.created + result.updated > 0 + + return ( + + +
+ {result.success ? ( + + ) : isPartial ? ( + + ) : ( + + )} + + {result.success + ? copy.successTitle + : isPartial + ? 'Import slutförd med fel' + : copy.failTitle} + +
+
+ + {/* Stats */} +
+ + + + 0 ? 'destructive' : 'muted'} /> +
+ + {totalProcessed === 0 && ( +

Inga rader bearbetades.

+ )} + + {/* Errors */} + {result.errors.length > 0 && ( +
+

Rader som inte kunde importeras

+
+
    + {result.errors.map((e, i) => ( +
  • + Rad {e.row_index}: {e.name} + — {e.reason} +
  • + ))} +
+
+
+ )} + +
+ + +
+
+
+ ) +} + +function Stat({ + label, + value, + accent, +}: { + label: string + value: number + accent?: 'success' | 'warning' | 'destructive' | 'muted' +}) { + return ( +
+

+ {value} +

+

{label}

+
+ ) +} diff --git a/components/import/RegisterUploadStep.tsx b/components/import/RegisterUploadStep.tsx new file mode 100644 index 00000000..b2bba7e8 --- /dev/null +++ b/components/import/RegisterUploadStep.tsx @@ -0,0 +1,123 @@ +'use client' + +import { useCallback, useState } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Upload, FileSpreadsheet, AlertCircle, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +export type RegisterEntity = 'customers' | 'suppliers' + +interface RegisterUploadStepProps { + entity: RegisterEntity + onFileSelect: (file: File) => void + isLoading: boolean + error: string | null +} + +const COPY: Record = { + customers: { + title: 'Ladda upp fil med kunder', + description: + 'Ladda upp en Excel- eller CSV-fil med ditt kundregister. Filen bör innehålla minst en kolumn med kundnamn.', + hint: + 'Vanliga kolumner identifieras automatiskt — t.ex. "Namn", "Orgnr", "E-post", "Telefon", "Adress", "Postort".', + }, + suppliers: { + title: 'Ladda upp fil med leverantörer', + description: + 'Ladda upp en Excel- eller CSV-fil med ditt leverantörsregister. Filen bör innehålla minst en kolumn med leverantörsnamn.', + hint: + 'Vanliga kolumner identifieras automatiskt — t.ex. "Namn", "Orgnr", "Bankgiro", "Plusgiro", "IBAN", "E-post".', + }, +} + +export default function RegisterUploadStep({ + entity, + onFileSelect, + isLoading, + error, +}: RegisterUploadStepProps) { + const [isDragging, setIsDragging] = useState(false) + const copy = COPY[entity] + + const handleFile = useCallback((file: File) => { + const ext = file.name.split('.').pop()?.toLowerCase() + if (!ext || !['xlsx', 'xls', 'csv', 'ods'].includes(ext)) return + onFileSelect(file) + }, [onFileSelect]) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleFile(file) + }, [handleFile]) + + return ( + + + {copy.title} + {copy.description} + + +
{ e.preventDefault(); setIsDragging(true) }} + onDragLeave={() => setIsDragging(false)} + > + {isLoading ? ( +
+ +

Läser fil och identifierar kolumner...

+
+ ) : ( + <> + +

Dra och släpp din fil här

+

eller

+ +

XLSX, XLS, CSV, ODS — max 10 MB

+ + )} +
+ + {error && ( +
+ +

{error}

+
+ )} + +
+ +
+

Filformat

+

{copy.hint}

+
+
+
+
+ ) +} diff --git a/components/import/SuppliersEditStep.tsx b/components/import/SuppliersEditStep.tsx new file mode 100644 index 00000000..b5700dbc --- /dev/null +++ b/components/import/SuppliersEditStep.tsx @@ -0,0 +1,241 @@ +'use client' + +import { useMemo, useState, useCallback } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { Trash2, AlertTriangle, Loader2, RefreshCw } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { SupplierType } from '@/types' +import type { AnnotatedSupplierRow } from '@/lib/import/suppliers/types' + +let idCounter = 0 +const newId = () => `supp_row_${++idCounter}_${Date.now()}` + +interface EditableSupplierRow extends AnnotatedSupplierRow { + id: string +} + +interface SuppliersEditStepProps { + rows: AnnotatedSupplierRow[] + onExecute: (rows: AnnotatedSupplierRow[], updateDuplicates: boolean) => void + onBack: () => void + isLoading: boolean + error: string | null +} + +const TYPE_LABELS: Record = { + swedish_business: 'Svenskt företag eller organisation', + eu_business: 'EU-företag', + non_eu_business: 'Utomeuropeiskt företag', +} + +export default function SuppliersEditStep({ + rows: initialRows, + onExecute, + onBack, + isLoading, + error, +}: SuppliersEditStepProps) { + const [rows, setRows] = useState(() => + initialRows.map((r) => ({ ...r, id: newId() })), + ) + const [updateDuplicates, setUpdateDuplicates] = useState(false) + + const liveDuplicateCount = useMemo( + () => rows.filter((r) => r.duplicate_match !== null).length, + [rows], + ) + const newCount = rows.length - liveDuplicateCount + + const hasErrors = useMemo(() => rows.some((r) => !r.is_valid), [rows]) + + const canContinue = rows.length > 0 && !hasErrors && !isLoading + + const updateRow = useCallback((id: string, updates: Partial) => { + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...updates } : r))) + }, []) + + const deleteRow = useCallback((id: string) => { + setRows((prev) => prev.filter((r) => r.id !== id)) + }, []) + + const handleExecute = () => { + if (!canContinue) return + const stripped: AnnotatedSupplierRow[] = rows.map(({ id: _id, ...rest }) => rest) + onExecute(stripped, updateDuplicates) + } + + return ( + + + Granska leverantörer + + Kontrollera att uppgifterna stämmer. {newCount} ny{newCount === 1 ? '' : 'a'} leverantör{newCount === 1 ? '' : 'er'} skapas + {liveDuplicateCount > 0 ? ` och ${liveDuplicateCount} matchar befintliga.` : '.'} + + + + {liveDuplicateCount > 0 && ( +
+ +
+

+ {liveDuplicateCount} rader matchar befintliga + leverantörer (på orgnummer eller e-post). +

+
+ + +
+ {updateDuplicates && ( +

+ Endast fält med värden i filen skrivs över. Tomma fält i filen lämnar + befintliga värden orörda. +

+ )} +
+
+ )} + +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
NamnTypOrgnrBankgiro/IBANStatus +
+ updateRow(row.id, { name: e.target.value })} + className="h-8" + /> + + + + {row.org_number || '—'} + + {row.bankgiro || row.plusgiro || row.iban || '—'} + +
+ {!row.is_valid && ( + + + + )} + {row.duplicate_match ? ( + + {updateDuplicates ? 'Uppdateras' : 'Hoppas över'} + + ) : ( + + Ny + + )} +
+
+ +
+
+ + {hasErrors && ( +
+ +

+ Vissa rader har valideringsfel (markerade i rött). Åtgärda eller ta bort dem + innan du fortsätter. +

+
+ )} + + {error && ( +
+ +

{error}

+
+ )} + +
+ + +
+
+
+ ) +} diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index cb399e10..a49fc066 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -42,7 +42,7 @@ export function InvoiceReviewContent({ }: InvoiceReviewContentProps) { const customerTypeLabel: Record = { individual: 'Privatperson', - swedish_business: 'Svenskt företag', + swedish_business: 'Svenskt företag eller organisation', eu_business: 'EU-företag', non_eu_business: 'Utanför EU', } diff --git a/components/suppliers/SupplierForm.tsx b/components/suppliers/SupplierForm.tsx index eae1977f..0cc04ee8 100644 --- a/components/suppliers/SupplierForm.tsx +++ b/components/suppliers/SupplierForm.tsx @@ -98,7 +98,7 @@ export default function SupplierForm({ - Svenskt företag + Svenskt företag eller organisation EU-företag Företag utanför EU diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 23158f70..b86d8020 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -607,6 +607,60 @@ export const OpeningBalanceExecuteSchema = z.object({ })).min(2, 'At least two lines are required for double-entry'), }) +// ============================================================ +// Register import schemas (customers, suppliers) +// ============================================================ + +const ImportedCustomerRowSchema = z.object({ + row_index: z.number().int(), + name: z.string().min(1), + customer_type: CustomerTypeSchema, + org_number: z.string().nullable(), + email: z.string().nullable(), + phone: z.string().nullable(), + address_line1: z.string().nullable(), + address_line2: z.string().nullable(), + postal_code: z.string().nullable(), + city: z.string().nullable(), + country: z.string(), + vat_number: z.string().nullable(), + default_payment_terms: z.number().int().min(0).max(365), + notes: z.string().nullable(), +}) + +export const CustomerImportExecuteSchema = z.object({ + rows: z.array(ImportedCustomerRowSchema).min(1, 'At least one row is required'), + update_duplicates: z.boolean(), +}) + +const ImportedSupplierRowSchema = z.object({ + row_index: z.number().int(), + name: z.string().min(1), + supplier_type: SupplierTypeSchema, + org_number: z.string().nullable(), + email: z.string().nullable(), + phone: z.string().nullable(), + address_line1: z.string().nullable(), + address_line2: z.string().nullable(), + postal_code: z.string().nullable(), + city: z.string().nullable(), + country: z.string(), + vat_number: z.string().nullable(), + bankgiro: z.string().nullable(), + plusgiro: z.string().nullable(), + bank_account: z.string().nullable(), + iban: z.string().nullable(), + bic: z.string().nullable(), + default_payment_terms: z.number().int().min(0).max(365), + default_currency: z.string(), + notes: z.string().nullable(), +}) + +export const SupplierImportExecuteSchema = z.object({ + rows: z.array(ImportedSupplierRowSchema).min(1, 'At least one row is required'), + update_duplicates: z.boolean(), +}) + // ============================================================ // Salary schemas // ============================================================ diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index fcbe2250..90f393da 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -845,6 +845,44 @@ const OPENING_BALANCE_IMPORT: Record = { }, } +const REGISTER_IMPORT: Record = { + REG_IMPORT_NO_FILE: { + httpStatus: 400, + message_sv: 'Ingen fil bifogad.', + message_en: 'No file attached.', + }, + REG_IMPORT_FILE_TOO_LARGE: { + httpStatus: 400, + message_sv: 'Filen är för stor. Maxstorlek är 10 MB.', + message_en: 'File exceeds the 10 MB size limit.', + }, + REG_IMPORT_INVALID_FORMAT: { + httpStatus: 400, + message_sv: 'Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.', + message_en: 'Unsupported file format.', + }, + REG_IMPORT_INVALID_COLUMN_OVERRIDES: { + httpStatus: 400, + message_sv: 'Ogiltig kolumnmappning.', + message_en: 'Invalid column overrides JSON.', + }, + REG_IMPORT_PARSE_FAILED: { + httpStatus: 500, + message_sv: 'Kunde inte tolka filen.', + message_en: 'Failed to parse the register file.', + }, + REG_IMPORT_NO_ROWS: { + httpStatus: 400, + message_sv: 'Inga giltiga rader hittades i filen.', + message_en: 'No valid rows found in the file.', + }, + REG_IMPORT_EXECUTE_FAILED: { + httpStatus: 500, + message_sv: 'Importen misslyckades.', + message_en: 'Register import failed.', + }, +} + // ───────────────────────────────────────────────────────────────── // Wave 3 tail: provider migration extension codes // ───────────────────────────────────────────────────────────────── @@ -1238,6 +1276,7 @@ const REGISTRY: Record = { ...SIE_IMPORT, ...BANK_FILE, ...OPENING_BALANCE_IMPORT, + ...REGISTER_IMPORT, ...PROVIDER_MIGRATION, ...DOCUMENT, ...CUSTOMER, diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts index 0cce36ab..b7cfb834 100644 --- a/lib/events/handlers/event-log-handler.ts +++ b/lib/events/handlers/event-log-handler.ts @@ -24,6 +24,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [ 'period.locked', 'period.year_closed', 'customer.created', + 'supplier.created', 'receipt.matched', 'receipt.confirmed', 'supplier_invoice.registered', @@ -52,7 +53,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [ function extractEntityId(payload: Record): string | null { // Try common entity shapes in priority order const entityKeys = [ - 'entry', 'invoice', 'transaction', 'customer', 'receipt', + 'entry', 'invoice', 'transaction', 'customer', 'supplier', 'receipt', 'supplierInvoice', 'creditNote', 'period', 'document', 'inboxItem', ] as const diff --git a/lib/events/types.ts b/lib/events/types.ts index e0d44f36..f94e8d11 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -3,6 +3,7 @@ import type { Invoice, Transaction, Customer, + Supplier, FiscalPeriod, DocumentAttachment, Receipt, @@ -40,6 +41,8 @@ export type CoreEvent = | { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } } // Customers | { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } } + // Suppliers + | { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } } // Receipts | { type: 'receipt.extracted'; payload: { receipt: Receipt; diff --git a/lib/import/bank-file/formats/camt053.ts b/lib/import/bank-file/formats/camt053.ts index a6390c22..5fe8cab9 100644 --- a/lib/import/bank-file/formats/camt053.ts +++ b/lib/import/bank-file/formats/camt053.ts @@ -14,7 +14,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' export const camt053Format: BankFileFormat = { id: 'camt053', diff --git a/lib/import/bank-file/formats/generic-csv.ts b/lib/import/bank-file/formats/generic-csv.ts index b1d8e5a7..dcaed87d 100644 --- a/lib/import/bank-file/formats/generic-csv.ts +++ b/lib/import/bank-file/formats/generic-csv.ts @@ -6,7 +6,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { parseCSVLine } from './nordea' import { normalizeDate } from '../date-utils' diff --git a/lib/import/bank-file/formats/handelsbanken.ts b/lib/import/bank-file/formats/handelsbanken.ts index 7568bb2b..1a599d92 100644 --- a/lib/import/bank-file/formats/handelsbanken.ts +++ b/lib/import/bank-file/formats/handelsbanken.ts @@ -11,7 +11,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' function parseCommaDecimal(value: string): number { diff --git a/lib/import/bank-file/formats/ica-banken.ts b/lib/import/bank-file/formats/ica-banken.ts index 02f9f076..4106583a 100644 --- a/lib/import/bank-file/formats/ica-banken.ts +++ b/lib/import/bank-file/formats/ica-banken.ts @@ -13,7 +13,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' function parseCommaDecimal(value: string): number { diff --git a/lib/import/bank-file/formats/lansforsakringar.ts b/lib/import/bank-file/formats/lansforsakringar.ts index 9c9a24ea..c5214d5b 100644 --- a/lib/import/bank-file/formats/lansforsakringar.ts +++ b/lib/import/bank-file/formats/lansforsakringar.ts @@ -13,7 +13,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' import { parseCSVLine } from './nordea' diff --git a/lib/import/bank-file/formats/lunar.ts b/lib/import/bank-file/formats/lunar.ts index 48868360..25ac16f7 100644 --- a/lib/import/bank-file/formats/lunar.ts +++ b/lib/import/bank-file/formats/lunar.ts @@ -14,7 +14,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' import { parseCSVLine } from './nordea' diff --git a/lib/import/bank-file/formats/nordea-business.ts b/lib/import/bank-file/formats/nordea-business.ts index f43cd78c..5818d531 100644 --- a/lib/import/bank-file/formats/nordea-business.ts +++ b/lib/import/bank-file/formats/nordea-business.ts @@ -21,7 +21,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' function parseCommaDecimal(value: string): number { diff --git a/lib/import/bank-file/formats/nordea.ts b/lib/import/bank-file/formats/nordea.ts index 5672d6c8..200021d7 100644 --- a/lib/import/bank-file/formats/nordea.ts +++ b/lib/import/bank-file/formats/nordea.ts @@ -12,7 +12,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' function parseCommaDecimal(value: string): number { diff --git a/lib/import/bank-file/formats/seb.ts b/lib/import/bank-file/formats/seb.ts index 2027b20c..ccd4600f 100644 --- a/lib/import/bank-file/formats/seb.ts +++ b/lib/import/bank-file/formats/seb.ts @@ -9,7 +9,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' function parseCommaDecimal(value: string): number { diff --git a/lib/import/bank-file/formats/skandia.ts b/lib/import/bank-file/formats/skandia.ts index 6eabc614..35b10616 100644 --- a/lib/import/bank-file/formats/skandia.ts +++ b/lib/import/bank-file/formats/skandia.ts @@ -12,7 +12,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' function parseCommaDecimal(value: string): number { diff --git a/lib/import/bank-file/formats/swedbank.ts b/lib/import/bank-file/formats/swedbank.ts index 6755a616..b519c38e 100644 --- a/lib/import/bank-file/formats/swedbank.ts +++ b/lib/import/bank-file/formats/swedbank.ts @@ -17,7 +17,7 @@ */ import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types' -import { prepareContent } from '../encoding' +import { prepareContent } from '../../shared/encoding' import { normalizeDate } from '../date-utils' import { parseCSVLine } from './nordea' diff --git a/lib/import/customers/__tests__/column-detector.test.ts b/lib/import/customers/__tests__/column-detector.test.ts new file mode 100644 index 00000000..1d8a409c --- /dev/null +++ b/lib/import/customers/__tests__/column-detector.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { detectCustomerColumns } from '../column-detector' + +describe('detectCustomerColumns', () => { + it('detects Swedish customer register headers', () => { + const headers = ['Namn', 'Orgnr', 'E-post', 'Telefon', 'Adress', 'Postnr', 'Ort'] + const result = detectCustomerColumns(headers) + expect(result.name_col).toBe(0) + expect(result.org_number_col).toBe(1) + expect(result.email_col).toBe(2) + expect(result.phone_col).toBe(3) + expect(result.address_line1_col).toBe(4) + expect(result.postal_code_col).toBe(5) + expect(result.city_col).toBe(6) + expect(result.confidence).toBeGreaterThanOrEqual(0.8) + }) + + it('detects English headers', () => { + const headers = ['Customer Name', 'Organization Number', 'Email', 'Phone'] + const result = detectCustomerColumns(headers) + expect(result.name_col).toBe(0) + expect(result.org_number_col).toBe(1) + expect(result.email_col).toBe(2) + expect(result.phone_col).toBe(3) + }) + + it('handles missing optional columns', () => { + const headers = ['Kundnamn'] + const result = detectCustomerColumns(headers) + expect(result.name_col).toBe(0) + expect(result.email_col).toBeNull() + expect(result.org_number_col).toBeNull() + }) + + it('does not match the same column twice', () => { + const headers = ['Namn', 'Adress', 'C/O'] + const result = detectCustomerColumns(headers) + expect(result.address_line1_col).toBe(1) + expect(result.address_line2_col).toBe(2) + }) + + it('returns low confidence when name not matched', () => { + const headers = ['ColA', 'ColB'] + const result = detectCustomerColumns(headers) + expect(result.confidence).toBe(0) + }) +}) diff --git a/lib/import/customers/__tests__/parser.test.ts b/lib/import/customers/__tests__/parser.test.ts new file mode 100644 index 00000000..25c0e0e9 --- /dev/null +++ b/lib/import/customers/__tests__/parser.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest' +import * as XLSX from 'xlsx' +import { parseCustomersFile } from '../parser' + +function buildXlsx(rows: (string | number)[][]): ArrayBuffer { + const ws = XLSX.utils.aoa_to_sheet(rows) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Kunder') + // Returning a Node Buffer; ArrayBuffer view is interchangeable with XLSX.read + const out = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer + return out +} + +describe('parseCustomersFile', () => { + it('parses a basic Swedish customer register', () => { + const buffer = buildXlsx([ + ['Namn', 'Orgnr', 'E-post', 'Telefon', 'Adress', 'Postnr', 'Ort'], + ['Acme AB', '5560217780', 'kontakt@acme.se', '0701234567', 'Storgatan 1', '11122', 'Stockholm'], + ['Beta AB', '5562345678', 'info@beta.se', '0709876543', 'Vasagatan 2', '11329', 'Stockholm'], + ]) + + const result = parseCustomersFile(buffer, 'kunder.xlsx') + + expect(result.total_rows).toBe(2) + expect(result.rows[0].name).toBe('Acme AB') + expect(result.rows[0].org_number).toBe('5560217780') + expect(result.rows[0].email).toBe('kontakt@acme.se') + expect(result.rows[0].postal_code).toBe('11122') + expect(result.rows[0].city).toBe('Stockholm') + expect(result.rows[0].is_valid).toBe(true) + }) + + it('auto-classifies customer_type by org_number length', () => { + const buffer = buildXlsx([ + ['Namn', 'Orgnr'], + ['Acme AB', '5560217780'], // 10-digit company + ['Sven Svensson', '198001011234'], // 12-digit personnummer + ]) + + const result = parseCustomersFile(buffer, 'mixed.xlsx') + expect(result.rows[0].customer_type).toBe('swedish_business') + expect(result.rows[1].customer_type).toBe('individual') + }) + + it('classifies eu_business from VAT number prefix', () => { + const buffer = buildXlsx([ + ['Namn', 'VAT'], + ['Müller GmbH', 'DE123456789'], + ]) + + const result = parseCustomersFile(buffer, 'eu.xlsx') + expect(result.rows[0].customer_type).toBe('eu_business') + expect(result.rows[0].vat_number).toBe('DE123456789') + }) + + it('skips rows with empty name', () => { + const buffer = buildXlsx([ + ['Namn', 'E-post'], + ['Acme AB', 'a@a.se'], + ['', 'b@b.se'], + ['Beta AB', 'c@c.se'], + ]) + + const result = parseCustomersFile(buffer, 'sparse.xlsx') + expect(result.total_rows).toBe(2) + expect(result.rows.map((r) => r.name)).toEqual(['Acme AB', 'Beta AB']) + }) + + it('flags invalid email format', () => { + const buffer = buildXlsx([ + ['Namn', 'E-post'], + ['Acme AB', 'not-an-email'], + ]) + + const result = parseCustomersFile(buffer, 'bad-email.xlsx') + expect(result.rows[0].is_valid).toBe(false) + expect(result.rows[0].validation_errors).toContain('Ogiltig e-postadress') + }) + + it('parses payment terms with day suffix', () => { + const buffer = buildXlsx([ + ['Namn', 'Betalningsvillkor'], + ['Acme AB', '45 dagar'], + ['Beta AB', ''], + ]) + + const result = parseCustomersFile(buffer, 'terms.xlsx') + expect(result.rows[0].default_payment_terms).toBe(45) + expect(result.rows[1].default_payment_terms).toBe(30) // default fallback + }) + + it('returns warning when zero rows match', () => { + const buffer = buildXlsx([ + ['Namn', 'Orgnr'], + ]) + + const result = parseCustomersFile(buffer, 'empty.xlsx') + expect(result.total_rows).toBe(0) + expect(result.warnings.length).toBeGreaterThan(0) + }) + + it('honors explicit customer_type column', () => { + const buffer = buildXlsx([ + ['Namn', 'Kundtyp'], + ['Acme AB', 'aktiebolag'], + ['Sven', 'privatperson'], + ]) + + const result = parseCustomersFile(buffer, 'types.xlsx') + expect(result.rows[0].customer_type).toBe('swedish_business') + expect(result.rows[1].customer_type).toBe('individual') + }) + + it('preserves row_index pointing to spreadsheet row', () => { + const buffer = buildXlsx([ + ['Namn'], + ['Acme AB'], + ['Beta AB'], + ]) + + const result = parseCustomersFile(buffer, 'rows.xlsx') + expect(result.rows[0].row_index).toBe(2) // header is row 1 + expect(result.rows[1].row_index).toBe(3) + }) + + it('preserves Swedish characters when reading a UTF-8 CSV', () => { + const csv = new TextEncoder().encode( + 'Namn,Ort\nAcme AB,GÖTEBORG\nBeta AB,HISINGS KÄRRA\n', + ).buffer + const result = parseCustomersFile(csv, 'kunder.csv') + expect(result.rows[0].city).toBe('GÖTEBORG') + expect(result.rows[1].city).toBe('HISINGS KÄRRA') + }) +}) diff --git a/lib/import/customers/column-detector.ts b/lib/import/customers/column-detector.ts new file mode 100644 index 00000000..00980056 --- /dev/null +++ b/lib/import/customers/column-detector.ts @@ -0,0 +1,106 @@ +import { findColumn } from '../shared/column-utils' +import type { DetectedCustomerColumns } from './types' + +const NAME_KEYWORDS = [ + 'kundnamn', 'kund namn', 'namn', 'name', 'kund', 'customer', 'customer name', + 'företag', 'foretag', 'company', 'företagsnamn', 'foretagsnamn', +] + +const ORG_NUMBER_KEYWORDS = [ + 'orgnr', 'org nr', 'organisationsnummer', 'organisationsnr', 'org', + 'personnr', 'personnummer', 'org number', 'organization number', +] + +const CUSTOMER_TYPE_KEYWORDS = [ + 'kundtyp', 'kund typ', 'typ', 'type', 'customer type', 'customer_type', +] + +const EMAIL_KEYWORDS = [ + 'epost', 'e post', 'email', 'mail', 'e mail', 'e-post', +] + +const PHONE_KEYWORDS = [ + 'telefon', 'tel', 'phone', 'mobil', 'mobile', 'telefonnummer', +] + +const ADDRESS_LINE1_KEYWORDS = [ + 'adress', 'address', 'gatuadress', 'street', 'gata', + 'address line 1', 'address1', 'adressrad 1', +] + +const ADDRESS_LINE2_KEYWORDS = [ + 'address line 2', 'address2', 'adressrad 2', 'c o', 'co', +] + +const POSTAL_CODE_KEYWORDS = [ + 'postnr', 'postnummer', 'postal code', 'postal_code', 'zip', 'zip code', +] + +const CITY_KEYWORDS = ['ort', 'stad', 'city', 'postort'] + +const COUNTRY_KEYWORDS = ['land', 'country'] + +const VAT_NUMBER_KEYWORDS = [ + 'vat', 'vatnr', 'vat nr', 'vat number', 'momsnummer', 'momsregistreringsnummer', + 'momsregnr', 'moms nr', +] + +const PAYMENT_TERMS_KEYWORDS = [ + 'betalningsvillkor', 'betalvillkor', 'payment terms', 'kredittid', 'kreditdagar', + 'dagar', 'förfallodagar', 'forfallodagar', +] + +const NOTES_KEYWORDS = [ + 'anteckning', 'anteckningar', 'notes', 'kommentar', 'kommentarer', 'comment', + 'note', 'beskrivning', +] + +/** + * Detect customer-register columns from headers. + * Header-only matching: register imports always have headers, and the column + * structure varies too much to do data-driven fallbacks reliably. + */ +export function detectCustomerColumns(headers: string[]): DetectedCustomerColumns { + const taken = new Set() + + const name_col = findColumn(headers, NAME_KEYWORDS, taken) ?? -1 + const org_number_col = findColumn(headers, ORG_NUMBER_KEYWORDS, taken) + const customer_type_col = findColumn(headers, CUSTOMER_TYPE_KEYWORDS, taken) + const email_col = findColumn(headers, EMAIL_KEYWORDS, taken) + const phone_col = findColumn(headers, PHONE_KEYWORDS, taken) + const address_line1_col = findColumn(headers, ADDRESS_LINE1_KEYWORDS, taken) + const address_line2_col = findColumn(headers, ADDRESS_LINE2_KEYWORDS, taken) + const postal_code_col = findColumn(headers, POSTAL_CODE_KEYWORDS, taken) + const city_col = findColumn(headers, CITY_KEYWORDS, taken) + const country_col = findColumn(headers, COUNTRY_KEYWORDS, taken) + const vat_number_col = findColumn(headers, VAT_NUMBER_KEYWORDS, taken) + const payment_terms_col = findColumn(headers, PAYMENT_TERMS_KEYWORDS, taken) + const notes_col = findColumn(headers, NOTES_KEYWORDS, taken) + + // Confidence: name is required; bonus from how many other columns matched. + let confidence = 0 + if (name_col >= 0) { + const matched = [ + org_number_col, email_col, phone_col, address_line1_col, + postal_code_col, city_col, vat_number_col, payment_terms_col, + ].filter((c) => c !== null).length + confidence = 0.55 + Math.min(matched, 6) * 0.075 + } + + return { + name_col: name_col >= 0 ? name_col : 0, + org_number_col, + customer_type_col, + email_col, + phone_col, + address_line1_col, + address_line2_col, + postal_code_col, + city_col, + country_col, + vat_number_col, + payment_terms_col, + notes_col, + confidence: Math.min(Math.round(confidence * 100) / 100, 1), + } +} diff --git a/lib/import/customers/parser.ts b/lib/import/customers/parser.ts new file mode 100644 index 00000000..17981871 --- /dev/null +++ b/lib/import/customers/parser.ts @@ -0,0 +1,199 @@ +import type { CustomerType } from '@/types' +import { detectCustomerColumns } from './column-detector' +import { cellOrNull, parsePaymentTerms } from '../shared/column-utils' +import { classifyCustomer } from '../shared/classify' +import { readBestSheet } from '../shared/workbook-reader' +import type { + DetectedCustomerColumns, + ParsedCustomerRow, +} from './types' + +const VALID_CUSTOMER_TYPES: CustomerType[] = [ + 'individual', + 'swedish_business', + 'eu_business', + 'non_eu_business', +] + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function normalizeCustomerType(value: string | null): CustomerType | null { + if (!value) return null + const lower = value.toLowerCase().trim() + if (lower === 'individual' || lower === 'privat' || lower === 'privatperson' || lower === 'person') { + return 'individual' + } + if (lower === 'swedish_business' || lower === 'swedish' || lower === 'företag' || lower === 'foretag' || lower === 'business' || lower === 'ab' || lower === 'aktiebolag') { + return 'swedish_business' + } + if (lower === 'eu_business' || lower === 'eu') { + return 'eu_business' + } + if (lower === 'non_eu_business' || lower === 'non-eu' || lower === 'utomeu' || lower === 'utländsk' || lower === 'utlandsk') { + return 'non_eu_business' + } + return VALID_CUSTOMER_TYPES.includes(lower as CustomerType) + ? (lower as CustomerType) + : null +} + +function normalizeCountry(value: string | null): string { + if (!value) return 'Sweden' + const trimmed = value.trim() + const lower = trimmed.toLowerCase() + if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'Sweden' + return trimmed +} + +/** + * Parse a customer-register file (Excel or CSV) and return structured rows. + * + * @param buffer - Raw file buffer + * @param filename - Original filename + * @param columnOverrides - Optional manual column mapping + */ +export function parseCustomersFile( + buffer: ArrayBuffer, + filename: string, + columnOverrides?: DetectedCustomerColumns, +): { + filename: string + sheet_name: string + total_rows: number + detected_columns: DetectedCustomerColumns + headers: string[] + preview_rows: string[][] + rows: ParsedCustomerRow[] + warnings: string[] +} { + const { sheetName, rawData } = readBestSheet(buffer, filename) + + if (rawData.length < 2) { + const fallbackColumns: DetectedCustomerColumns = columnOverrides ?? { + name_col: 0, + org_number_col: null, + customer_type_col: null, + email_col: null, + phone_col: null, + address_line1_col: null, + address_line2_col: null, + postal_code_col: null, + city_col: null, + country_col: null, + vat_number_col: null, + payment_terms_col: null, + notes_col: null, + confidence: 0, + } + return { + filename, + sheet_name: sheetName, + total_rows: 0, + detected_columns: fallbackColumns, + headers: rawData[0]?.map((h) => String(h)) || [], + preview_rows: [], + rows: [], + warnings: ['Filen innehåller för få rader.'], + } + } + + const headers = rawData[0].map((h) => String(h)) + const dataRows = rawData.slice(1) + const columns = columnOverrides || detectCustomerColumns(headers) + + const rows: ParsedCustomerRow[] = [] + const warnings: string[] = [] + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i] + const name = cellOrNull(row[columns.name_col]) + if (!name) continue // skip empty rows silently + + const orgNumber = columns.org_number_col !== null + ? cellOrNull(row[columns.org_number_col]) + : null + const email = columns.email_col !== null + ? cellOrNull(row[columns.email_col]) + : null + const phone = columns.phone_col !== null + ? cellOrNull(row[columns.phone_col]) + : null + const addressLine1 = columns.address_line1_col !== null + ? cellOrNull(row[columns.address_line1_col]) + : null + const addressLine2 = columns.address_line2_col !== null + ? cellOrNull(row[columns.address_line2_col]) + : null + const postalCode = columns.postal_code_col !== null + ? cellOrNull(row[columns.postal_code_col]) + : null + const city = columns.city_col !== null + ? cellOrNull(row[columns.city_col]) + : null + const countryRaw = columns.country_col !== null + ? cellOrNull(row[columns.country_col]) + : null + const country = normalizeCountry(countryRaw) + const vatNumber = columns.vat_number_col !== null + ? cellOrNull(row[columns.vat_number_col]) + : null + const paymentTermsRaw = columns.payment_terms_col !== null + ? row[columns.payment_terms_col] + : null + const notes = columns.notes_col !== null + ? cellOrNull(row[columns.notes_col]) + : null + + const explicitType = columns.customer_type_col !== null + ? normalizeCustomerType(cellOrNull(row[columns.customer_type_col])) + : null + const customerType: CustomerType = + explicitType ?? classifyCustomer({ + org_number: orgNumber, + vat_number: vatNumber, + country: countryRaw, + }) + + const validationErrors: string[] = [] + if (email && !EMAIL_RE.test(email)) { + validationErrors.push('Ogiltig e-postadress') + } + if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) { + validationErrors.push('Ogiltigt org-/personnummer') + } + + rows.push({ + row_index: i + 2, // 1-based + header + name, + customer_type: customerType, + org_number: orgNumber, + email, + phone, + address_line1: addressLine1, + address_line2: addressLine2, + postal_code: postalCode, + city, + country, + vat_number: vatNumber, + default_payment_terms: parsePaymentTerms(paymentTermsRaw, 30), + notes, + is_valid: validationErrors.length === 0, + validation_errors: validationErrors, + }) + } + + if (rows.length === 0) { + warnings.push('Inga giltiga kundrader hittades. Kontrollera att namnkolumnen är korrekt mappad.') + } + + return { + filename, + sheet_name: sheetName, + total_rows: rows.length, + detected_columns: columns, + headers, + preview_rows: dataRows.slice(0, 5), + rows, + warnings, + } +} diff --git a/lib/import/customers/types.ts b/lib/import/customers/types.ts new file mode 100644 index 00000000..ad4144cd --- /dev/null +++ b/lib/import/customers/types.ts @@ -0,0 +1,78 @@ +import type { CustomerType } from '@/types' + +/** Result of auto-detecting columns in a customer register file. */ +export interface DetectedCustomerColumns { + name_col: number + org_number_col: number | null + customer_type_col: number | null + email_col: number | null + phone_col: number | null + address_line1_col: number | null + address_line2_col: number | null + postal_code_col: number | null + city_col: number | null + country_col: number | null + vat_number_col: number | null + payment_terms_col: number | null + notes_col: number | null + /** 0-1 confidence score for the detection */ + confidence: number +} + +/** A single parsed row from the customer register file. */ +export interface ParsedCustomerRow { + row_index: number + name: string + customer_type: CustomerType + org_number: string | null + email: string | null + phone: string | null + address_line1: string | null + address_line2: string | null + postal_code: string | null + city: string | null + country: string + vat_number: string | null + default_payment_terms: number + notes: string | null + is_valid: boolean + validation_errors: string[] +} + +/** Customer-row + dedup annotation produced by the API route. */ +export interface AnnotatedCustomerRow extends ParsedCustomerRow { + duplicate_match: { + customer_id: string + matched_by: 'org_number' | 'email' + existing_name: string + } | null +} + +/** Full result from parsing a customer register file. */ +export interface CustomerImportParseResult { + filename: string + sheet_name: string + total_rows: number + detected_columns: DetectedCustomerColumns + headers: string[] + preview_rows: string[][] + rows: AnnotatedCustomerRow[] + duplicate_count: number + warnings: string[] +} + +/** Input for executing the customer import. */ +export interface CustomerImportExecuteInput { + rows: ParsedCustomerRow[] + update_duplicates: boolean +} + +/** Result of executing the customer import. */ +export interface CustomerImportExecuteResult { + success: boolean + created: number + updated: number + skipped: number + failed: number + errors: { row_index: number; name: string; reason: string }[] +} diff --git a/lib/import/opening-balance/parser.ts b/lib/import/opening-balance/parser.ts index d38342f2..a4340c6b 100644 --- a/lib/import/opening-balance/parser.ts +++ b/lib/import/opening-balance/parser.ts @@ -1,6 +1,7 @@ import * as XLSX from 'xlsx' import { detectColumns } from './column-detector' import { getBASReference } from '@/lib/bookkeeping/bas-reference' +import { readWorkbookFromBuffer } from '../shared/workbook-reader' import type { DetectedColumns, ParsedOpeningBalanceRow, @@ -43,7 +44,7 @@ export function parseOpeningBalanceFile( filename: string, columnOverrides?: DetectedColumns, ): OpeningBalanceParseResult { - const workbook = XLSX.read(buffer, { type: 'array' }) + const workbook = readWorkbookFromBuffer(buffer, filename) // Pick the sheet with the most rows (heuristic for multi-sheet workbooks) let bestSheet = workbook.SheetNames[0] diff --git a/lib/import/shared/__tests__/classify.test.ts b/lib/import/shared/__tests__/classify.test.ts new file mode 100644 index 00000000..c627e282 --- /dev/null +++ b/lib/import/shared/__tests__/classify.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest' +import { classifyCustomer, classifySupplier } from '../classify' + +describe('classifyCustomer', () => { + it('classifies 12-digit personnummer as individual', () => { + expect(classifyCustomer({ + org_number: '198001011234', + vat_number: null, + })).toBe('individual') + }) + + it('classifies 10-digit Swedish org as swedish_business', () => { + expect(classifyCustomer({ + org_number: '5560217780', + vat_number: null, + })).toBe('swedish_business') + }) + + it('classifies non-SE EU VAT prefix as eu_business', () => { + expect(classifyCustomer({ + org_number: null, + vat_number: 'DE123456789', + })).toBe('eu_business') + }) + + it('classifies non-EU VAT prefix as non_eu_business', () => { + expect(classifyCustomer({ + org_number: null, + vat_number: 'NO12345678', + })).toBe('non_eu_business') + }) + + it('keeps SE VAT prefix as swedish_business', () => { + expect(classifyCustomer({ + org_number: '5560217780', + vat_number: 'SE556021778001', + })).toBe('swedish_business') + }) + + it('classifies 10-digit personnummer-month-pattern as individual', () => { + // Third digit is 0 (month 01), classic personnummer pattern + expect(classifyCustomer({ + org_number: '8001011234', + vat_number: null, + })).toBe('individual') + }) + + it('falls back to swedish_business when no signals', () => { + expect(classifyCustomer({ + org_number: null, + vat_number: null, + })).toBe('swedish_business') + }) + + it('uses country code when VAT missing', () => { + expect(classifyCustomer({ + org_number: null, + vat_number: null, + country: 'DE', + })).toBe('eu_business') + }) + + it('detects Norway as non-EU', () => { + expect(classifyCustomer({ + org_number: null, + vat_number: null, + country: 'Norge', + })).toBe('non_eu_business') + }) +}) + +describe('classifySupplier', () => { + it('never returns individual', () => { + expect(classifySupplier({ + org_number: '198001011234', + vat_number: null, + })).toBe('swedish_business') + }) + + it('classifies non-SE EU VAT prefix as eu_business', () => { + expect(classifySupplier({ + org_number: null, + vat_number: 'FR12345678901', + })).toBe('eu_business') + }) + + it('classifies post-Brexit GB VAT as non_eu_business', () => { + expect(classifySupplier({ + org_number: null, + vat_number: 'GB123456789', + })).toBe('non_eu_business') + }) + + it('classifies XI (Northern Ireland) VAT as eu_business', () => { + expect(classifySupplier({ + org_number: null, + vat_number: 'XI123456789', + })).toBe('eu_business') + }) + + it('falls back to swedish_business by default', () => { + expect(classifySupplier({ + org_number: null, + vat_number: null, + })).toBe('swedish_business') + }) +}) diff --git a/lib/import/shared/__tests__/encoding.test.ts b/lib/import/shared/__tests__/encoding.test.ts new file mode 100644 index 00000000..13e5a479 --- /dev/null +++ b/lib/import/shared/__tests__/encoding.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { + decodeFileContent, + decodeStringContent, + hasEncodingIssues, +} from '../encoding' + +describe('decodeStringContent', () => { + it('recovers UTF-8-as-Latin-1 mojibake for lowercase Swedish chars', () => { + expect(decodeStringContent('Malmö')).toBe('Malmö') + expect(decodeStringContent('Ã¥re')).toBe('Åre'.toLowerCase()) + expect(decodeStringContent('Linköping')).toBe('Linköping') + }) + + it('recovers UTF-8-as-Latin-1 mojibake for uppercase Swedish chars', () => { + // The middle char is U+0096 (control), invisible in most renderings → "GÃTEBORG" + expect(decodeStringContent('GÃ\u0096TEBORG')).toBe('GÖTEBORG') + expect(decodeStringContent('HISINGS KÃ\u0084RRA')).toBe('HISINGS KÄRRA') + expect(decodeStringContent('Ã\u0085NGE')).toBe('ÅNGE') + }) + + it('is a no-op on already-correct Swedish strings', () => { + expect(decodeStringContent('GÖTEBORG')).toBe('GÖTEBORG') + expect(decodeStringContent('Malmö')).toBe('Malmö') + expect(decodeStringContent('STOCKHOLM')).toBe('STOCKHOLM') + expect(decodeStringContent('')).toBe('') + }) + + it('is idempotent (running twice equals running once)', () => { + const once = decodeStringContent('Malmö') + const twice = decodeStringContent(once) + expect(twice).toBe(once) + expect(twice).toBe('Malmö') + }) + + it('preserves non-Swedish strings unchanged', () => { + expect(decodeStringContent('Café')).toBe('Café') + expect(decodeStringContent('München')).toBe('München') + expect(decodeStringContent('123 Main St')).toBe('123 Main St') + }) +}) + +describe('hasEncodingIssues', () => { + it('detects U+FFFD replacement characters', () => { + expect(hasEncodingIssues('Foo\uFFFDbar')).toBe(true) + }) + + it('detects all six Swedish mojibake patterns', () => { + expect(hasEncodingIssues('Malmö')).toBe(true) // ö + expect(hasEncodingIssues('Ã¥re')).toBe(true) // å + expect(hasEncodingIssues('älg')).toBe(true) // ä + expect(hasEncodingIssues('GÃ\u0096TEBORG')).toBe(true) // Ö + expect(hasEncodingIssues('Ã\u0085NGE')).toBe(true) // Å + expect(hasEncodingIssues('Ã\u0084RRA')).toBe(true) // Ä + }) + + it('returns false for clean strings', () => { + expect(hasEncodingIssues('Stockholm')).toBe(false) + expect(hasEncodingIssues('Malmö')).toBe(false) + expect(hasEncodingIssues('Café')).toBe(false) + }) +}) + +describe('decodeFileContent', () => { + function buf(bytes: number[]): ArrayBuffer { + return new Uint8Array(bytes).buffer + } + + it('decodes UTF-8 bytes correctly', () => { + const utf8 = new TextEncoder().encode('GÖTEBORG').buffer + expect(decodeFileContent(utf8)).toBe('GÖTEBORG') + }) + + it('falls back to Windows-1252 when UTF-8 decode is invalid', () => { + // 0xD6 = Ö in Windows-1252; lone 0xD6 is not valid UTF-8 start byte + const cp1252 = buf([0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47]) + expect(decodeFileContent(cp1252)).toBe('GÖTEBORG') + }) +}) diff --git a/lib/import/shared/__tests__/workbook-reader.test.ts b/lib/import/shared/__tests__/workbook-reader.test.ts new file mode 100644 index 00000000..e3064ffe --- /dev/null +++ b/lib/import/shared/__tests__/workbook-reader.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import * as XLSX from 'xlsx' +import { readWorkbookFromBuffer } from '../workbook-reader' + +function bufFromBytes(bytes: number[]): ArrayBuffer { + return new Uint8Array(bytes).buffer +} + +function rowsOf(workbook: XLSX.WorkBook): string[][] { + const sheet = workbook.Sheets[workbook.SheetNames[0]] + return XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '', raw: false }) as string[][] +} + +describe('readWorkbookFromBuffer', () => { + it('decodes UTF-8 CSV with Swedish characters correctly', () => { + const csv = new TextEncoder().encode('Namn,Ort\nAcme,GÖTEBORG\nBeta,KÄRRA\n').buffer + const wb = readWorkbookFromBuffer(csv, 'lev.csv') + expect(rowsOf(wb)).toEqual([ + ['Namn', 'Ort'], + ['Acme', 'GÖTEBORG'], + ['Beta', 'KÄRRA'], + ]) + }) + + it('decodes UTF-8 CSV with BOM', () => { + const bom = [0xef, 0xbb, 0xbf] + const body = Array.from(new TextEncoder().encode('Namn,Ort\nAcme,GÖTEBORG\n')) + const wb = readWorkbookFromBuffer(bufFromBytes([...bom, ...body]), 'lev.csv') + expect(rowsOf(wb)).toEqual([ + ['Namn', 'Ort'], + ['Acme', 'GÖTEBORG'], + ]) + }) + + it('decodes Windows-1252 CSV with Swedish characters', () => { + // "Namn,Ort\nAcme,GÖTEBORG\nBeta,KÄRRA\n" in Windows-1252: + // Ö = 0xD6, Ä = 0xC4 + const bytes = [ + 0x4e, 0x61, 0x6d, 0x6e, 0x2c, 0x4f, 0x72, 0x74, 0x0a, + 0x41, 0x63, 0x6d, 0x65, 0x2c, 0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47, 0x0a, + 0x42, 0x65, 0x74, 0x61, 0x2c, 0x4b, 0xc4, 0x52, 0x52, 0x41, 0x0a, + ] + const wb = readWorkbookFromBuffer(bufFromBytes(bytes), 'lev.csv') + expect(rowsOf(wb)).toEqual([ + ['Namn', 'Ort'], + ['Acme', 'GÖTEBORG'], + ['Beta', 'KÄRRA'], + ]) + }) + + it('reads xlsx files via the binary path', () => { + const ws = XLSX.utils.aoa_to_sheet([ + ['Namn', 'Ort'], + ['Acme', 'GÖTEBORG'], + ]) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer + + const result = readWorkbookFromBuffer(buffer, 'data.xlsx') + expect(rowsOf(result)).toEqual([ + ['Namn', 'Ort'], + ['Acme', 'GÖTEBORG'], + ]) + }) + + it('treats non-csv extensions as binary spreadsheets', () => { + // .xls and .ods both go through the binary path; xlsx handles encoding internally + const ws = XLSX.utils.aoa_to_sheet([['A'], ['Ö']]) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Sheet1') + const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer + + const result = readWorkbookFromBuffer(buffer, 'data.xls') + expect(rowsOf(result)).toEqual([['A'], ['Ö']]) + }) +}) diff --git a/lib/import/shared/classify.ts b/lib/import/shared/classify.ts new file mode 100644 index 00000000..5f2c6225 --- /dev/null +++ b/lib/import/shared/classify.ts @@ -0,0 +1,106 @@ +import type { CustomerType, SupplierType } from '@/types' + +/** + * EU VAT number prefixes (excluding SE). + * Source: https://taxation-ec.europa.eu/online-services/check-vat-number-vies_en + */ +export const EU_VAT_PREFIXES = new Set([ + 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'EL', 'ES', + 'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', + 'MT', 'NL', 'PL', 'PT', 'RO', 'SI', 'SK', 'XI', // XI = Northern Ireland +]) + +/** + * Strip whitespace, dashes, and dots from an org/personnummer for length checks. + */ +function digits(value: string | null): string { + if (!value) return '' + return value.replace(/\D/g, '') +} + +/** + * Check the third digit of a Swedish org/personnummer. + * + * Personnummer: month digit (00-12) — third digit ≤ 1. + * Företag: third digit ≥ 2 (per Skatteverket's allocation rules). + */ +function looksLikePersonnummer(orgNumber: string | null): boolean { + const d = digits(orgNumber) + // 12 digits = full personnummer (YYYYMMDDXXXX) + if (d.length === 12) return true + // 10 digits — disambiguate by month (positions 3-4 are month, 01-12) + if (d.length === 10) { + const month = parseInt(d.substring(2, 4), 10) + if (month >= 1 && month <= 12 && d[2] <= '1') return true + } + return false +} + +function vatPrefix(vatNumber: string | null): string | null { + if (!vatNumber) return null + const cleaned = vatNumber.trim().toUpperCase() + const match = cleaned.match(/^([A-Z]{2})/) + return match ? match[1] : null +} + +/** + * Auto-classify a customer based on org_number + vat_number heuristics. + * + * Precedence: + * 1. Non-SE EU VAT prefix → 'eu_business' + * 2. Non-EU letter prefix → 'non_eu_business' + * 3. Personnummer-shaped org → 'individual' + * 4. Default → 'swedish_business' + */ +export function classifyCustomer(args: { + org_number: string | null + vat_number: string | null + country?: string | null +}): CustomerType { + const prefix = vatPrefix(args.vat_number) + if (prefix && prefix !== 'SE') { + if (EU_VAT_PREFIXES.has(prefix)) return 'eu_business' + return 'non_eu_business' + } + + const country = args.country?.trim().toUpperCase() + if (country && country !== 'SE' && country !== 'SVERIGE' && country !== 'SWEDEN') { + // Country-based fallback when VAT is missing. + if (country.length === 2 && EU_VAT_PREFIXES.has(country)) return 'eu_business' + if (country.length >= 3) { + // Common Swedish names for non-EU jurisdictions; heuristic is best-effort + if (/norge|norway|usa|kanada|canada|storbritannien|uk|united kingdom/i.test(country)) { + return 'non_eu_business' + } + } + } + + if (looksLikePersonnummer(args.org_number)) return 'individual' + return 'swedish_business' +} + +/** + * Auto-classify a supplier. Suppliers cannot be 'individual' — Swedish business + * with personnummer is still 'swedish_business' (a sole trader supplier). + */ +export function classifySupplier(args: { + org_number: string | null + vat_number: string | null + country?: string | null +}): SupplierType { + const prefix = vatPrefix(args.vat_number) + if (prefix && prefix !== 'SE') { + if (EU_VAT_PREFIXES.has(prefix)) return 'eu_business' + return 'non_eu_business' + } + + const country = args.country?.trim().toUpperCase() + if (country && country !== 'SE' && country !== 'SVERIGE' && country !== 'SWEDEN') { + if (country.length === 2 && EU_VAT_PREFIXES.has(country)) return 'eu_business' + if (/norge|norway|usa|kanada|canada|storbritannien|uk|united kingdom/i.test(country)) { + return 'non_eu_business' + } + } + + return 'swedish_business' +} diff --git a/lib/import/shared/column-utils.ts b/lib/import/shared/column-utils.ts new file mode 100644 index 00000000..b7108c21 --- /dev/null +++ b/lib/import/shared/column-utils.ts @@ -0,0 +1,68 @@ +/** + * Shared column-detection helpers for register imports + * (customers, suppliers, future: articles). + */ + +export function normalize(header: string): string { + return header.toLowerCase().trim().replace(/[_\-./]/g, ' ') +} + +export function matchesKeywords(header: string, keywords: string[]): boolean { + const normalized = normalize(header) + return keywords.some((kw) => normalized === kw || normalized.includes(kw)) +} + +/** + * Find the first column index whose header matches one of `keywords`, + * skipping any indices already taken by other columns. + */ +export function findColumn( + headers: string[], + keywords: string[], + taken: Set, +): number | null { + for (let i = 0; i < headers.length; i++) { + if (taken.has(i)) continue + if (matchesKeywords(headers[i], keywords)) { + taken.add(i) + return i + } + } + return null +} + +/** Trim a string-or-blank cell, returning null when empty. */ +export function cellOrNull(value: unknown): string | null { + if (value === null || value === undefined) return null + const str = String(value).trim() + return str === '' ? null : str +} + +/** Parse an integer payment term ("30 dagar" → 30) with a default fallback. */ +export function parsePaymentTerms(value: unknown, fallback: number): number { + const str = cellOrNull(value) + if (!str) return fallback + const match = str.match(/-?\d+/) + if (!match) return fallback + const n = parseInt(match[0], 10) + if (isNaN(n) || n < 0 || n > 365) return fallback + return n +} + +/** + * Normalize an org/personal number to its dedup key (digits only). + * Returns null for empty input or strings that contain no digits. + */ +export function normalizeOrgNumber(value: string | null): string | null { + if (!value) return null + return value.replace(/\D/g, '') || null +} + +/** + * Normalize an email to its dedup key (trimmed + lowercased). + * Returns null for empty/whitespace-only input. + */ +export function normalizeEmail(value: string | null): string | null { + if (!value) return null + return value.trim().toLowerCase() || null +} diff --git a/lib/import/bank-file/encoding.ts b/lib/import/shared/encoding.ts similarity index 71% rename from lib/import/bank-file/encoding.ts rename to lib/import/shared/encoding.ts index 875ecc7a..a05d6bae 100644 --- a/lib/import/bank-file/encoding.ts +++ b/lib/import/shared/encoding.ts @@ -1,7 +1,8 @@ /** - * Encoding detection and conversion for Swedish bank files. + * Encoding detection and conversion for Swedish import files. * - * Swedish bank exports use either UTF-8 or Windows-1252 (ISO-8859-1). + * Used by bank file, supplier, customer, and opening-balance parsers. + * Swedish data exports use either UTF-8 or Windows-1252 (ISO-8859-1). * We detect encoding by checking for valid Swedish characters. */ @@ -12,37 +13,39 @@ * (U+FFFD) or garbled Swedish chars, fall back to Windows-1252. */ export function decodeFileContent(buffer: ArrayBuffer): string { - // Try UTF-8 first const utf8Decoder = new TextDecoder('utf-8', { fatal: false }) const utf8Result = utf8Decoder.decode(buffer) - // Check if UTF-8 decode produced valid Swedish text if (!hasEncodingIssues(utf8Result)) { return utf8Result } - // Fall back to Windows-1252 (superset of ISO-8859-1) const latin1Decoder = new TextDecoder('windows-1252', { fatal: false }) return latin1Decoder.decode(buffer) } /** - * Decode a string that may have been incorrectly decoded as UTF-8 - * when the source was actually Windows-1252. + * Re-decode a string that suffered the canonical "UTF-8 bytes read as Latin-1" + * mojibake (e.g. "Malmö" → "Malmö", "GÖTEBORG" → "GÖTEBORG"). + * + * Mechanism: each char in the input is a codepoint that was originally a UTF-8 + * byte misinterpreted as a Latin-1/Windows-1252 character. We pack those chars + * back into a byte sequence and decode the bytes as UTF-8 to recover the + * original text. + * + * No-op when the string is already clean (no garbled patterns). */ export function decodeStringContent(content: string): string { - // If the string already contains valid Swedish chars, return as-is if (!hasEncodingIssues(content)) { return content } - // Try re-encoding as Latin-1 and decoding as Windows-1252 try { const bytes = new Uint8Array(content.length) for (let i = 0; i < content.length; i++) { bytes[i] = content.charCodeAt(i) & 0xff } - const decoder = new TextDecoder('windows-1252', { fatal: false }) + const decoder = new TextDecoder('utf-8', { fatal: false }) return decoder.decode(bytes) } catch { return content @@ -52,8 +55,7 @@ export function decodeStringContent(content: string): string { /** * Check if a string has encoding issues (garbled Swedish characters). */ -function hasEncodingIssues(text: string): boolean { - // U+FFFD = replacement character (means invalid UTF-8 byte sequences) +export function hasEncodingIssues(text: string): boolean { if (text.includes('\uFFFD')) return true // Common garbled patterns when Windows-1252 is read as UTF-8: diff --git a/lib/import/shared/workbook-reader.ts b/lib/import/shared/workbook-reader.ts new file mode 100644 index 00000000..4ac91075 --- /dev/null +++ b/lib/import/shared/workbook-reader.ts @@ -0,0 +1,58 @@ +import * as XLSX from 'xlsx' +import { decodeFileContent } from './encoding' + +/** + * Read a workbook from a raw file buffer, with correct encoding handling + * for CSV files. + * + * For binary spreadsheet formats (.xlsx, .xls, .ods), xlsx handles encoding + * via the embedded codepage and we pass the buffer through as `type: 'array'`. + * + * For CSV files, xlsx with `type: 'array'` decodes bytes as Latin-1, which + * mangles UTF-8 multi-byte sequences (e.g. Ö → Ö). We instead detect the + * source encoding (UTF-8 with optional BOM, or Windows-1252) and decode to + * a string before handing it to xlsx as `type: 'string'`. + */ +export function readWorkbookFromBuffer(buffer: ArrayBuffer, filename: string): XLSX.WorkBook { + const ext = filename.toLowerCase().split('.').pop() ?? '' + if (ext === 'csv') { + const content = decodeFileContent(buffer) + return XLSX.read(content, { type: 'string' }) + } + return XLSX.read(buffer, { type: 'array' }) +} + +/** + * Read the workbook from `buffer` and return raw rows from its largest sheet. + * + * Picks the sheet with the most rows (a heuristic that handles files where + * the header sheet isn't the first one). Returns rows as a 2D string array + * with the header row included; cells default to empty string. + */ +export function readBestSheet( + buffer: ArrayBuffer, + filename: string, +): { sheetName: string; rawData: string[][] } { + const workbook = readWorkbookFromBuffer(buffer, filename) + + let bestSheet = workbook.SheetNames[0] + let bestRowCount = 0 + for (const name of workbook.SheetNames) { + const sheet = workbook.Sheets[name] + const range = XLSX.utils.decode_range(sheet['!ref'] || 'A1') + const rowCount = range.e.r - range.s.r + 1 + if (rowCount > bestRowCount) { + bestRowCount = rowCount + bestSheet = name + } + } + + const sheet = workbook.Sheets[bestSheet] + const rawData: string[][] = XLSX.utils.sheet_to_json(sheet, { + header: 1, + defval: '', + raw: false, + }) + + return { sheetName: bestSheet, rawData } +} diff --git a/lib/import/suppliers/__tests__/column-detector.test.ts b/lib/import/suppliers/__tests__/column-detector.test.ts new file mode 100644 index 00000000..56ab1a3e --- /dev/null +++ b/lib/import/suppliers/__tests__/column-detector.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { detectSupplierColumns } from '../column-detector' + +describe('detectSupplierColumns', () => { + it('detects Swedish supplier register headers', () => { + const headers = ['Namn', 'Orgnr', 'Bankgiro', 'Plusgiro', 'IBAN', 'BIC', 'E-post'] + const result = detectSupplierColumns(headers) + expect(result.name_col).toBe(0) + expect(result.org_number_col).toBe(1) + expect(result.bankgiro_col).toBe(2) + expect(result.plusgiro_col).toBe(3) + expect(result.iban_col).toBe(4) + expect(result.bic_col).toBe(5) + expect(result.email_col).toBe(6) + expect(result.confidence).toBeGreaterThanOrEqual(0.8) + }) + + it('handles supplier-specific keyword "Leverantör"', () => { + const headers = ['Leverantör', 'Orgnummer', 'Bankgiro'] + const result = detectSupplierColumns(headers) + expect(result.name_col).toBe(0) + expect(result.org_number_col).toBe(1) + expect(result.bankgiro_col).toBe(2) + }) + + it('does not confuse plusgiro with bankgiro', () => { + const headers = ['Namn', 'Plusgiro', 'Bankgiro'] + const result = detectSupplierColumns(headers) + expect(result.plusgiro_col).toBe(1) + expect(result.bankgiro_col).toBe(2) + }) + + it('returns confidence 0 with no name column', () => { + const headers = ['ColA', 'ColB'] + const result = detectSupplierColumns(headers) + expect(result.confidence).toBe(0) + }) +}) diff --git a/lib/import/suppliers/__tests__/parser.test.ts b/lib/import/suppliers/__tests__/parser.test.ts new file mode 100644 index 00000000..970bfc86 --- /dev/null +++ b/lib/import/suppliers/__tests__/parser.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import * as XLSX from 'xlsx' +import { parseSuppliersFile } from '../parser' + +function buildXlsx(rows: (string | number)[][]): ArrayBuffer { + const ws = XLSX.utils.aoa_to_sheet(rows) + const wb = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(wb, ws, 'Leverantörer') + return XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) as ArrayBuffer +} + +describe('parseSuppliersFile', () => { + it('parses Swedish supplier register with bankgiro/iban', () => { + const buffer = buildXlsx([ + ['Namn', 'Orgnr', 'Bankgiro', 'Plusgiro', 'IBAN', 'BIC'], + ['Acme AB', '5560217780', '123-4567', '12 34 56-7', 'SE3550000000054910000003', 'ESSESESS'], + ]) + + const result = parseSuppliersFile(buffer, 'lev.xlsx') + + expect(result.rows[0].name).toBe('Acme AB') + expect(result.rows[0].bankgiro).toBe('123-4567') + expect(result.rows[0].plusgiro).toBe('123456-7') + expect(result.rows[0].iban).toBe('SE3550000000054910000003') + expect(result.rows[0].bic).toBe('ESSESESS') + expect(result.rows[0].is_valid).toBe(true) + }) + + it('classifies eu_business by VAT prefix', () => { + const buffer = buildXlsx([ + ['Namn', 'VAT'], + ['Müller GmbH', 'DE123456789'], + ]) + const result = parseSuppliersFile(buffer, 'eu.xlsx') + expect(result.rows[0].supplier_type).toBe('eu_business') + }) + + it('flags invalid IBAN format', () => { + const buffer = buildXlsx([ + ['Namn', 'IBAN'], + ['Acme AB', 'NOT-AN-IBAN'], + ]) + const result = parseSuppliersFile(buffer, 'bad-iban.xlsx') + expect(result.rows[0].is_valid).toBe(false) + expect(result.rows[0].validation_errors).toContain('Ogiltigt IBAN') + }) + + it('defaults currency to SEK when missing or invalid', () => { + const buffer = buildXlsx([ + ['Namn', 'Valuta'], + ['Acme AB', ''], + ['Beta AB', 'XYZ'], + ['Gamma AB', 'EUR'], + ]) + const result = parseSuppliersFile(buffer, 'curr.xlsx') + expect(result.rows[0].default_currency).toBe('SEK') + expect(result.rows[1].default_currency).toBe('SEK') + expect(result.rows[2].default_currency).toBe('EUR') + }) + + it('skips rows with empty name', () => { + const buffer = buildXlsx([ + ['Namn'], + ['Acme AB'], + [''], + ['Beta AB'], + ]) + const result = parseSuppliersFile(buffer, 'sparse.xlsx') + expect(result.total_rows).toBe(2) + }) + + it('cleans bankgiro number formatting', () => { + const buffer = buildXlsx([ + ['Namn', 'Bankgiro'], + ['Acme AB', '5402 9685'], + ]) + const result = parseSuppliersFile(buffer, 'bg.xlsx') + expect(result.rows[0].bankgiro).toBe('54029685') + }) + + it('preserves Swedish characters when reading a UTF-8 CSV', () => { + const csv = new TextEncoder().encode( + 'Namn,Ort\nDinel AB,GÖTEBORG\nHisings AB,HISINGS KÄRRA\n', + ).buffer + const result = parseSuppliersFile(csv, 'lev.csv') + expect(result.rows[0].city).toBe('GÖTEBORG') + expect(result.rows[1].city).toBe('HISINGS KÄRRA') + }) + + it('preserves Swedish characters when reading a Windows-1252 CSV', () => { + // Ö = 0xD6, Ä = 0xC4 in Windows-1252 + const bytes = [ + 0x4e, 0x61, 0x6d, 0x6e, 0x2c, 0x4f, 0x72, 0x74, 0x0a, // "Namn,Ort\n" + 0x41, 0x63, 0x6d, 0x65, 0x2c, 0x47, 0xd6, 0x54, 0x45, 0x42, 0x4f, 0x52, 0x47, 0x0a, // "Acme,GÖTEBORG\n" + 0x42, 0x65, 0x74, 0x61, 0x2c, 0x4b, 0xc4, 0x52, 0x52, 0x41, 0x0a, // "Beta,KÄRRA\n" + ] + const result = parseSuppliersFile(new Uint8Array(bytes).buffer, 'lev.csv') + expect(result.rows[0].city).toBe('GÖTEBORG') + expect(result.rows[1].city).toBe('KÄRRA') + }) +}) diff --git a/lib/import/suppliers/column-detector.ts b/lib/import/suppliers/column-detector.ts new file mode 100644 index 00000000..5a5bd973 --- /dev/null +++ b/lib/import/suppliers/column-detector.ts @@ -0,0 +1,130 @@ +import { findColumn } from '../shared/column-utils' +import type { DetectedSupplierColumns } from './types' + +const NAME_KEYWORDS = [ + 'leverantörsnamn', 'leverantorsnamn', 'leverantör', 'leverantor', 'namn', 'name', + 'supplier', 'supplier name', 'företag', 'foretag', 'company', 'vendor', +] + +const ORG_NUMBER_KEYWORDS = [ + 'orgnr', 'org nr', 'organisationsnummer', 'organisationsnr', 'org', + 'personnr', 'personnummer', 'org number', 'organization number', +] + +const SUPPLIER_TYPE_KEYWORDS = [ + 'leverantörstyp', 'leverantorstyp', 'typ', 'type', 'supplier type', + 'supplier_type', +] + +const EMAIL_KEYWORDS = ['epost', 'e post', 'email', 'mail', 'e mail', 'e-post'] + +const PHONE_KEYWORDS = [ + 'telefon', 'tel', 'phone', 'mobil', 'mobile', 'telefonnummer', +] + +const ADDRESS_LINE1_KEYWORDS = [ + 'adress', 'address', 'gatuadress', 'street', 'gata', + 'address line 1', 'address1', 'adressrad 1', +] + +const ADDRESS_LINE2_KEYWORDS = [ + 'address line 2', 'address2', 'adressrad 2', 'c o', 'co', +] + +const POSTAL_CODE_KEYWORDS = [ + 'postnr', 'postnummer', 'postal code', 'postal_code', 'zip', 'zip code', +] + +const CITY_KEYWORDS = ['ort', 'stad', 'city', 'postort'] + +const COUNTRY_KEYWORDS = ['land', 'country'] + +const VAT_NUMBER_KEYWORDS = [ + 'vat', 'vatnr', 'vat nr', 'vat number', 'momsnummer', 'momsregistreringsnummer', + 'momsregnr', 'moms nr', +] + +const BANKGIRO_KEYWORDS = [ + 'bankgiro', 'bg', 'bgnr', 'bg nr', 'bankgironr', 'bankgironummer', +] + +const PLUSGIRO_KEYWORDS = [ + 'plusgiro', 'pg', 'pgnr', 'pg nr', 'postgiro', 'plusgironummer', +] + +const BANK_ACCOUNT_KEYWORDS = [ + 'bankkonto', 'kontonummer', 'bank account', 'bank_account', 'clearing', +] + +const IBAN_KEYWORDS = ['iban', 'iban nr'] + +const BIC_KEYWORDS = ['bic', 'swift', 'swift code', 'bic code'] + +const PAYMENT_TERMS_KEYWORDS = [ + 'betalningsvillkor', 'betalvillkor', 'payment terms', 'kredittid', 'kreditdagar', + 'dagar', 'förfallodagar', 'forfallodagar', +] + +const CURRENCY_KEYWORDS = ['valuta', 'currency', 'curr'] + +const NOTES_KEYWORDS = [ + 'anteckning', 'anteckningar', 'notes', 'kommentar', 'kommentarer', 'comment', + 'note', 'beskrivning', +] + +export function detectSupplierColumns(headers: string[]): DetectedSupplierColumns { + const taken = new Set() + + const name_col = findColumn(headers, NAME_KEYWORDS, taken) ?? -1 + const org_number_col = findColumn(headers, ORG_NUMBER_KEYWORDS, taken) + const supplier_type_col = findColumn(headers, SUPPLIER_TYPE_KEYWORDS, taken) + const email_col = findColumn(headers, EMAIL_KEYWORDS, taken) + const phone_col = findColumn(headers, PHONE_KEYWORDS, taken) + const address_line1_col = findColumn(headers, ADDRESS_LINE1_KEYWORDS, taken) + const address_line2_col = findColumn(headers, ADDRESS_LINE2_KEYWORDS, taken) + const postal_code_col = findColumn(headers, POSTAL_CODE_KEYWORDS, taken) + const city_col = findColumn(headers, CITY_KEYWORDS, taken) + const country_col = findColumn(headers, COUNTRY_KEYWORDS, taken) + const vat_number_col = findColumn(headers, VAT_NUMBER_KEYWORDS, taken) + const bankgiro_col = findColumn(headers, BANKGIRO_KEYWORDS, taken) + const plusgiro_col = findColumn(headers, PLUSGIRO_KEYWORDS, taken) + const bank_account_col = findColumn(headers, BANK_ACCOUNT_KEYWORDS, taken) + const iban_col = findColumn(headers, IBAN_KEYWORDS, taken) + const bic_col = findColumn(headers, BIC_KEYWORDS, taken) + const payment_terms_col = findColumn(headers, PAYMENT_TERMS_KEYWORDS, taken) + const default_currency_col = findColumn(headers, CURRENCY_KEYWORDS, taken) + const notes_col = findColumn(headers, NOTES_KEYWORDS, taken) + + let confidence = 0 + if (name_col >= 0) { + const matched = [ + org_number_col, email_col, phone_col, address_line1_col, + postal_code_col, city_col, vat_number_col, bankgiro_col, iban_col, + payment_terms_col, + ].filter((c) => c !== null).length + confidence = 0.55 + Math.min(matched, 6) * 0.075 + } + + return { + name_col: name_col >= 0 ? name_col : 0, + org_number_col, + supplier_type_col, + email_col, + phone_col, + address_line1_col, + address_line2_col, + postal_code_col, + city_col, + country_col, + vat_number_col, + bankgiro_col, + plusgiro_col, + bank_account_col, + iban_col, + bic_col, + payment_terms_col, + default_currency_col, + notes_col, + confidence: Math.min(Math.round(confidence * 100) / 100, 1), + } +} diff --git a/lib/import/suppliers/parser.ts b/lib/import/suppliers/parser.ts new file mode 100644 index 00000000..e51ac631 --- /dev/null +++ b/lib/import/suppliers/parser.ts @@ -0,0 +1,206 @@ +import type { SupplierType } from '@/types' +import { detectSupplierColumns } from './column-detector' +import { cellOrNull, parsePaymentTerms } from '../shared/column-utils' +import { classifySupplier } from '../shared/classify' +import { readBestSheet } from '../shared/workbook-reader' +import type { + DetectedSupplierColumns, + ParsedSupplierRow, +} from './types' + +const VALID_SUPPLIER_TYPES: SupplierType[] = [ + 'swedish_business', + 'eu_business', + 'non_eu_business', +] + +const VALID_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']) + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +function normalizeSupplierType(value: string | null): SupplierType | null { + if (!value) return null + const lower = value.toLowerCase().trim() + if (lower === 'swedish_business' || lower === 'swedish' || lower === 'svensk' || lower === 'företag' || lower === 'foretag' || lower === 'business' || lower === 'ab' || lower === 'aktiebolag') { + return 'swedish_business' + } + if (lower === 'eu_business' || lower === 'eu') { + return 'eu_business' + } + if (lower === 'non_eu_business' || lower === 'non-eu' || lower === 'utomeu' || lower === 'utländsk' || lower === 'utlandsk') { + return 'non_eu_business' + } + return VALID_SUPPLIER_TYPES.includes(lower as SupplierType) + ? (lower as SupplierType) + : null +} + +function normalizeCountry(value: string | null): string { + if (!value) return 'SE' + const trimmed = value.trim() + const lower = trimmed.toLowerCase() + if (lower === 'se' || lower === 'sverige' || lower === 'sweden') return 'SE' + return trimmed +} + +function normalizeCurrency(value: string | null): string { + if (!value) return 'SEK' + const upper = value.trim().toUpperCase() + return VALID_CURRENCIES.has(upper) ? upper : 'SEK' +} + +function cleanGiroNumber(value: string | null): string | null { + if (!value) return null + const cleaned = value.replace(/[\s.]/g, '') + return cleaned === '' ? null : cleaned +} + +export function parseSuppliersFile( + buffer: ArrayBuffer, + filename: string, + columnOverrides?: DetectedSupplierColumns, +): { + filename: string + sheet_name: string + total_rows: number + detected_columns: DetectedSupplierColumns + headers: string[] + preview_rows: string[][] + rows: ParsedSupplierRow[] + warnings: string[] +} { + const { sheetName, rawData } = readBestSheet(buffer, filename) + + if (rawData.length < 2) { + const fallbackColumns: DetectedSupplierColumns = columnOverrides ?? { + name_col: 0, + org_number_col: null, + supplier_type_col: null, + email_col: null, + phone_col: null, + address_line1_col: null, + address_line2_col: null, + postal_code_col: null, + city_col: null, + country_col: null, + vat_number_col: null, + bankgiro_col: null, + plusgiro_col: null, + bank_account_col: null, + iban_col: null, + bic_col: null, + payment_terms_col: null, + default_currency_col: null, + notes_col: null, + confidence: 0, + } + return { + filename, + sheet_name: sheetName, + total_rows: 0, + detected_columns: fallbackColumns, + headers: rawData[0]?.map((h) => String(h)) || [], + preview_rows: [], + rows: [], + warnings: ['Filen innehåller för få rader.'], + } + } + + const headers = rawData[0].map((h) => String(h)) + const dataRows = rawData.slice(1) + const columns = columnOverrides || detectSupplierColumns(headers) + + const rows: ParsedSupplierRow[] = [] + const warnings: string[] = [] + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i] + const name = cellOrNull(row[columns.name_col]) + if (!name) continue + + const get = (col: number | null) => + col !== null ? cellOrNull(row[col]) : null + + const orgNumber = get(columns.org_number_col) + const email = get(columns.email_col) + const phone = get(columns.phone_col) + const addressLine1 = get(columns.address_line1_col) + const addressLine2 = get(columns.address_line2_col) + const postalCode = get(columns.postal_code_col) + const city = get(columns.city_col) + const countryRaw = get(columns.country_col) + const country = normalizeCountry(countryRaw) + const vatNumber = get(columns.vat_number_col) + const bankgiro = cleanGiroNumber(get(columns.bankgiro_col)) + const plusgiro = cleanGiroNumber(get(columns.plusgiro_col)) + const bankAccount = get(columns.bank_account_col) + const iban = get(columns.iban_col)?.replace(/\s/g, '').toUpperCase() ?? null + const bic = get(columns.bic_col)?.replace(/\s/g, '').toUpperCase() ?? null + const paymentTermsRaw = columns.payment_terms_col !== null + ? row[columns.payment_terms_col] + : null + const currencyRaw = get(columns.default_currency_col) + const notes = get(columns.notes_col) + + const explicitType = columns.supplier_type_col !== null + ? normalizeSupplierType(cellOrNull(row[columns.supplier_type_col])) + : null + const supplierType: SupplierType = + explicitType ?? classifySupplier({ + org_number: orgNumber, + vat_number: vatNumber, + country: countryRaw, + }) + + const validationErrors: string[] = [] + if (email && !EMAIL_RE.test(email)) { + validationErrors.push('Ogiltig e-postadress') + } + if (orgNumber && !/^[\d\s\-]{6,20}$/.test(orgNumber)) { + validationErrors.push('Ogiltigt org-/personnummer') + } + if (iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban)) { + validationErrors.push('Ogiltigt IBAN') + } + + rows.push({ + row_index: i + 2, + name, + supplier_type: supplierType, + org_number: orgNumber, + email, + phone, + address_line1: addressLine1, + address_line2: addressLine2, + postal_code: postalCode, + city, + country, + vat_number: vatNumber, + bankgiro, + plusgiro, + bank_account: bankAccount, + iban, + bic, + default_payment_terms: parsePaymentTerms(paymentTermsRaw, 30), + default_currency: normalizeCurrency(currencyRaw), + notes, + is_valid: validationErrors.length === 0, + validation_errors: validationErrors, + }) + } + + if (rows.length === 0) { + warnings.push('Inga giltiga leverantörsrader hittades. Kontrollera att namnkolumnen är korrekt mappad.') + } + + return { + filename, + sheet_name: sheetName, + total_rows: rows.length, + detected_columns: columns, + headers, + preview_rows: dataRows.slice(0, 5), + rows, + warnings, + } +} diff --git a/lib/import/suppliers/types.ts b/lib/import/suppliers/types.ts new file mode 100644 index 00000000..a4b6d9d3 --- /dev/null +++ b/lib/import/suppliers/types.ts @@ -0,0 +1,90 @@ +import type { SupplierType } from '@/types' + +/** Result of auto-detecting columns in a supplier register file. */ +export interface DetectedSupplierColumns { + name_col: number + org_number_col: number | null + supplier_type_col: number | null + email_col: number | null + phone_col: number | null + address_line1_col: number | null + address_line2_col: number | null + postal_code_col: number | null + city_col: number | null + country_col: number | null + vat_number_col: number | null + bankgiro_col: number | null + plusgiro_col: number | null + bank_account_col: number | null + iban_col: number | null + bic_col: number | null + payment_terms_col: number | null + default_currency_col: number | null + notes_col: number | null + /** 0-1 confidence score for the detection */ + confidence: number +} + +/** A single parsed row from the supplier register file. */ +export interface ParsedSupplierRow { + row_index: number + name: string + supplier_type: SupplierType + org_number: string | null + email: string | null + phone: string | null + address_line1: string | null + address_line2: string | null + postal_code: string | null + city: string | null + country: string + vat_number: string | null + bankgiro: string | null + plusgiro: string | null + bank_account: string | null + iban: string | null + bic: string | null + default_payment_terms: number + default_currency: string + notes: string | null + is_valid: boolean + validation_errors: string[] +} + +/** Supplier-row + dedup annotation produced by the API route. */ +export interface AnnotatedSupplierRow extends ParsedSupplierRow { + duplicate_match: { + supplier_id: string + matched_by: 'org_number' | 'email' + existing_name: string + } | null +} + +/** Full result from parsing a supplier register file. */ +export interface SupplierImportParseResult { + filename: string + sheet_name: string + total_rows: number + detected_columns: DetectedSupplierColumns + headers: string[] + preview_rows: string[][] + rows: AnnotatedSupplierRow[] + duplicate_count: number + warnings: string[] +} + +/** Input for executing the supplier import. */ +export interface SupplierImportExecuteInput { + rows: ParsedSupplierRow[] + update_duplicates: boolean +} + +/** Result of executing the supplier import. */ +export interface SupplierImportExecuteResult { + success: boolean + created: number + updated: number + skipped: number + failed: number + errors: { row_index: number; name: string; reason: string }[] +} diff --git a/lib/recapt.ts b/lib/recapt.ts new file mode 100644 index 00000000..b40c3762 --- /dev/null +++ b/lib/recapt.ts @@ -0,0 +1,13 @@ +export function clearRecaptIdentity(): void { + if (typeof window === 'undefined') return + if (typeof window.recapt !== 'function') return + try { + window.recapt('identify', { + uid: undefined, + email: undefined, + nickname: undefined, + }) + } catch { + // best-effort — we're already in a logout flow + } +} diff --git a/scripts/fix-import-mojibake.ts b/scripts/fix-import-mojibake.ts new file mode 100644 index 00000000..55fe45ee --- /dev/null +++ b/scripts/fix-import-mojibake.ts @@ -0,0 +1,186 @@ +#!/usr/bin/env npx tsx +/** + * Re-decode mojibake'd Swedish characters in supplier and customer text fields. + * + * Background: prior to the CSV-encoding fix in lib/import/shared/workbook-reader.ts, + * the supplier/customer CSV importer passed raw bytes to xlsx with `type: 'array'`, + * which decodes UTF-8 multi-byte sequences as Latin-1 — turning "GÖTEBORG" into + * "GÖTEBORG" (and similar for å, ä, å, Å, Ä, Ö). This script repairs those rows. + * + * Idempotent: lib/import/shared/encoding.ts:decodeStringContent is a no-op on + * strings that already contain correct Swedish characters, so it is safe to + * re-run. + * + * Usage: + * # Preview every company + * npx tsx scripts/fix-import-mojibake.ts + * + * # Preview a single company + * npx tsx scripts/fix-import-mojibake.ts --company-id + * + * # Apply + * npx tsx scripts/fix-import-mojibake.ts --commit + * npx tsx scripts/fix-import-mojibake.ts --company-id --commit + */ + +import { config } from 'dotenv' +config({ path: '.env.local' }) +import { createClient, type SupabaseClient } from '@supabase/supabase-js' +import { decodeStringContent, hasEncodingIssues } from '../lib/import/shared/encoding' + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : undefined +} + +const COMPANY_ID = arg('company-id') ?? null +const COMMIT = process.argv.includes('--commit') + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL +const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY + +if (!supabaseUrl || !serviceRoleKey) { + console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local') + process.exit(1) +} + +const supabase = createClient(supabaseUrl, serviceRoleKey) as SupabaseClient + +console.log('─────────────────────────────────────────────────────────') +console.log('Mojibake repair: suppliers + customers') +console.log('─────────────────────────────────────────────────────────') +console.log('Supabase URL :', supabaseUrl) +console.log('Company :', COMPANY_ID ?? '(all)') +console.log('Mode :', COMMIT ? 'COMMIT (writes)' : 'DRY RUN (no writes)') +console.log('─────────────────────────────────────────────────────────\n') + +const SUPPLIER_TEXT_FIELDS = [ + 'name', + 'address_line1', + 'address_line2', + 'city', + 'country', + 'category', + 'notes', +] as const + +const CUSTOMER_TEXT_FIELDS = [ + 'name', + 'address_line1', + 'address_line2', + 'city', + 'country', + 'notes', +] as const + +const PAGE_SIZE = 500 + +async function fetchAll(table: 'suppliers' | 'customers', columns: string[]) { + const all: Record[] = [] + let from = 0 + for (;;) { + let query = supabase + .from(table) + .select(['id', 'company_id', ...columns].join(',')) + .order('id', { ascending: true }) + .range(from, from + PAGE_SIZE - 1) + + if (COMPANY_ID) { + query = query.eq('company_id', COMPANY_ID) + } + + const { data, error } = await query + if (error) throw new Error(`Failed to read ${table}: ${error.message}`) + const rows = ((data ?? []) as unknown) as Record[] + all.push(...rows) + if (rows.length < PAGE_SIZE) break + from += PAGE_SIZE + } + return all +} + +interface RepairResult { + scanned: number + affected: number + fields: number + applied: number + failed: number +} + +async function repair( + table: 'suppliers' | 'customers', + textFields: readonly string[], +): Promise { + console.log(`[${table}]`) + const rows = await fetchAll(table, [...textFields]) + console.log(` · Scanned ${rows.length} rows`) + + let affected = 0 + let fieldsFixed = 0 + let applied = 0 + let failed = 0 + + for (const row of rows) { + const updates: Record = {} + const before: Record = {} + for (const field of textFields) { + const value = row[field] + if (typeof value !== 'string' || value === '') continue + if (!hasEncodingIssues(value)) continue + const fixed = decodeStringContent(value) + if (fixed === value) continue + updates[field] = fixed + before[field] = value + } + + if (Object.keys(updates).length === 0) continue + + affected++ + fieldsFixed += Object.keys(updates).length + + const id = row.id as string + const companyId = row.company_id as string + console.log(`\n · ${table}.${id} (company ${companyId})`) + for (const [field, fixed] of Object.entries(updates)) { + console.log(` ${field}:`) + console.log(` before: ${JSON.stringify(before[field])}`) + console.log(` after : ${JSON.stringify(fixed)}`) + } + + if (!COMMIT) continue + + const { error } = await supabase.from(table).update(updates).eq('id', id) + if (error) { + console.error(` FAILED: ${error.message}`) + failed++ + } else { + applied++ + } + } + + return { scanned: rows.length, affected, fields: fieldsFixed, applied, failed } +} + +async function main() { + try { + const sup = await repair('suppliers', SUPPLIER_TEXT_FIELDS) + const cus = await repair('customers', CUSTOMER_TEXT_FIELDS) + + console.log('\n─────────────────────────────────────────────────────────') + console.log('Summary') + console.log('─────────────────────────────────────────────────────────') + console.log(`suppliers : ${sup.affected}/${sup.scanned} rows affected (${sup.fields} fields)`) + console.log(`customers : ${cus.affected}/${cus.scanned} rows affected (${cus.fields} fields)`) + if (COMMIT) { + console.log(`Applied : ${sup.applied + cus.applied}`) + console.log(`Failed : ${sup.failed + cus.failed}`) + } else { + console.log('\nRe-run with --commit to apply.') + } + } catch (err) { + console.error('\nFATAL:', err instanceof Error ? err.message : err) + process.exit(1) + } +} + +main() diff --git a/types/recapt.d.ts b/types/recapt.d.ts index 9d96766f..cd820030 100644 --- a/types/recapt.d.ts +++ b/types/recapt.d.ts @@ -2,9 +2,21 @@ type RecaptFeedbackPayload = | { message: string; rating?: number } | { widget: 'show' | 'hide' | 'open' | 'close'; position?: string } +type RecaptIdentifyPayload = { + uid: string | undefined + email?: string + nickname?: string +} + +interface RecaptFn { + (action: 'feedback', data: RecaptFeedbackPayload): void + (action: 'identify', data: RecaptIdentifyPayload): void +} + declare global { interface Window { - recapt?: (action: 'feedback', data: RecaptFeedbackPayload) => void + Recapt?: unknown + recapt?: RecaptFn } }