diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 16d263d8..2d4e1612 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -19,12 +19,13 @@ import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' -import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react' +import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye, Landmark } from 'lucide-react' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent' import { getErrorMessage } from '@/lib/errors/get-error-message' import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import CustomerForm from '@/components/customers/CustomerForm' +import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog' import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType } from '@/types' const itemSchema = z.object({ @@ -70,6 +71,8 @@ export default function NewInvoicePage() { const [, setDefaultNotes] = useState(null) const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false) const [isCreatingCustomer, setIsCreatingCustomer] = useState(false) + const [hasBankDetails, setHasBankDetails] = useState(null) + const [showBankSetup, setShowBankSetup] = useState(false) const pendingCustomerRef = useRef(null) const { @@ -127,12 +130,15 @@ export default function NewInvoicePage() { async function fetchDefaultNotes() { const { data } = await supabase .from('company_settings') - .select('invoice_default_notes') + .select('invoice_default_notes, clearing_number, account_number, bankgiro') .single() if (data?.invoice_default_notes) { setDefaultNotes(data.invoice_default_notes) setValue('notes', data.invoice_default_notes) } + setHasBankDetails( + !!(data?.clearing_number && data?.account_number) || !!data?.bankgiro + ) } useEffect(() => { @@ -239,9 +245,21 @@ export default function NewInvoicePage() { function onSubmit(data: FormData) { setPendingData(data) + if (hasBankDetails === false && watchDocumentType === 'invoice') { + setShowBankSetup(true) + return + } setShowReview(true) } + function handleBankSetupComplete() { + setHasBankDetails(true) + setShowBankSetup(false) + if (pendingData) { + setShowReview(true) + } + } + async function handleConfirm() { if (!pendingData) return setIsSubmitting(true) @@ -380,6 +398,16 @@ export default function NewInvoicePage() { + {hasBankDetails === false && ( +
+ +

Betalningsuppgifter saknas — du behöver lägga till dem innan du skapar en faktura.

+ +
+ )} +
{/* Main content */} @@ -798,6 +826,13 @@ export default function NewInvoicePage() { + {/* Bank details setup dialog */} + + {/* Send now prompt dialog */} { if (!open && createdInvoiceId) { diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 9d869ec8..63065a9b 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -1,7 +1,7 @@ 'use client' import { useState, useEffect, Suspense } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +import { useRouter } from 'next/navigation' import Image from 'next/image' import * as Sentry from '@sentry/nextjs' import { createClient } from '@/lib/supabase/client' @@ -17,14 +17,11 @@ import Step1EntityType from '@/components/onboarding/Step1EntityType' import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails' import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration' import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting' -import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank' - const STEP_INFO = [ { title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' }, { title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' }, { title: 'F-skatt & räkenskapsår', subtitle: 'Ange din skatteregistrering och räkenskapsår.', label: 'Skatt' }, { title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' }, - { title: 'Bankuppgifter', subtitle: 'Dessa visas på dina fakturor.', label: 'Bank' }, ] function translatePeriodError(msg: string): string { @@ -68,7 +65,6 @@ function logError(message: string, extra?: Record) { function OnboardingPageContent() { const router = useRouter() - const searchParams = useSearchParams() const { toast } = useToast() const supabase = createClient() @@ -79,7 +75,7 @@ function OnboardingPageContent() { const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const [ticLookup, setTicLookup] = useState(null) - const totalSteps = 5 + const totalSteps = 4 // Detect stuck state: onboarding marked complete but still on this page useEffect(() => { @@ -195,28 +191,6 @@ function OnboardingPageContent() { } } - // Handle bank_connected callback from PSD2 flow - useEffect(() => { - if (searchParams.get('bank_connected') === 'true') { - toast({ - title: 'Bank ansluten!', - description: 'Din bank har kopplats.', - }) - // Complete onboarding after bank connection - saveSettings({ onboarding_complete: true }, totalSteps).then((success) => { - if (!success) { - logError('failed to complete onboarding after bank_connected callback') - } else { - toast({ - title: 'Välkommen!', - description: 'Din profil är nu redo.', - }) - router.push('/') - } - }) - } - }, [searchParams]) - const handleNext = async (stepData: Partial) => { // Fix org number bug: clear dependent fields when entity type changes if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { @@ -502,52 +476,6 @@ function OnboardingPageContent() { /> )} - {currentStep === 5 && ( - b.type === 'iban')?.accountNumber) ?? undefined, - bic: settings.bic ?? (ticLookup?.bankAccounts.find((b) => b.type === 'iban')?.bic) ?? undefined, - }} - onComplete={async (data) => { - if (data) { - const bankSaved = await saveSettings(data, totalSteps) - if (!bankSaved) { - logError('failed to save bank details at step 5') - return - } - } - const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) - if (!finalSuccess) { - logError('failed to set onboarding_complete at step 5') - return - } - console.log(LOG, 'onboarding completed') - toast({ - title: 'Välkommen!', - description: 'Din profil är nu redo.', - }) - router.push('/') - }} - onBack={handleBack} - onSkip={async () => { - const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) - if (!finalSuccess) { - logError('failed to set onboarding_complete when skipping step 5') - return - } - console.log(LOG, 'onboarding completed') - toast({ - title: 'Välkommen!', - description: 'Din profil är nu redo.', - }) - router.push('/') - }} - isSaving={isSaving} - /> - )} ) diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 7a1cd914..6c260064 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -1,6 +1,7 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync' +import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client' import { getEmailService } from '@/lib/email/service' import { @@ -141,6 +142,21 @@ export async function GET(request: Request) { const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a })) + // Detect SIE overlap — skip auto-categorization if the sync range + // overlaps with a completed SIE import to prevent double-booking + const { data: sieOverlap } = await supabase + .from('sie_imports') + .select('id') + .eq('user_id', connection.user_id) + .eq('status', 'completed') + .gte('fiscal_year_end', fromDate) + .limit(1) + .maybeSingle() + + const syncOptions = sieOverlap + ? { skipAutoCategorization: true } + : undefined + const syncResults = await Promise.all( accounts.map(account => syncAccountTransactions( supabase, @@ -148,7 +164,9 @@ export async function GET(request: Request) { connection.id, account, fromDate, - toDate + toDate, + undefined, + syncOptions )) ) @@ -156,6 +174,18 @@ export async function GET(request: Request) { const totalDuplicates = syncResults.reduce((sum, r) => sum + r.duplicates, 0) const totalErrors = syncResults.reduce((sum, r) => sum + r.errors, 0) + // Batch reconciliation sweep when SIE overlap detected + if (sieOverlap && totalImported > 0) { + try { + await runReconciliation(supabase, connection.user_id, { + dateFrom: fromDate, + dateTo: toDate, + }) + } catch { + // Non-critical + } + } + // Successful sync: update connection and clear any previous error state await supabase .from('bank_connections') diff --git a/components/invoices/BankDetailsSetupDialog.tsx b/components/invoices/BankDetailsSetupDialog.tsx new file mode 100644 index 00000000..713741aa --- /dev/null +++ b/components/invoices/BankDetailsSetupDialog.tsx @@ -0,0 +1,301 @@ +'use client' + +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Loader2, ChevronDown, ChevronRight } from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' +import { useToast } from '@/components/ui/use-toast' +import { BankNameCombobox } from '@/components/settings/BankNameCombobox' +import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn' + +const bankSetupSchema = z.object({ + bank_name: z.string().max(100).optional().or(z.literal('')), + clearing_number: z.string().regex(/^\d{4,5}$/, 'Clearingnummer måste vara 4–5 siffror').optional().or(z.literal('')), + account_number: z.string().regex(/^\d{6,12}$/, 'Kontonummer måste vara 6–12 siffror').optional().or(z.literal('')), + bankgiro: z.string().optional().or(z.literal('')), + iban: z.string().optional().or(z.literal('')), + bic: z.string().optional().or(z.literal('')), + invoice_prefix: z.string().optional().or(z.literal('')), + next_invoice_number: z.string().optional().or(z.literal('')), +}).refine( + (data) => { + const hasAccount = !!data.clearing_number && !!data.account_number + const hasBankgiro = !!data.bankgiro + return hasAccount || hasBankgiro + }, + { message: 'Ange antingen kontonummer (clearing + konto) eller bankgiro', path: ['clearing_number'] } +).refine( + (data) => { + // If one of clearing/account is filled, both must be + if (data.clearing_number && !data.account_number) return false + if (!data.clearing_number && data.account_number) return false + return true + }, + { message: 'Ange både clearingnummer och kontonummer', path: ['account_number'] } +).refine( + (data) => { + if (!data.bankgiro) return true + return validateBankgiroNumber(data.bankgiro) + }, + { message: 'Ogiltigt bankgironummer (7–8 siffror med kontrollsiffra)', path: ['bankgiro'] } +).refine( + (data) => { + if (!data.next_invoice_number) return true + const num = parseInt(data.next_invoice_number, 10) + return !isNaN(num) && num >= 1 + }, + { message: 'Startnummer måste vara ett positivt heltal', path: ['next_invoice_number'] } +) + +type BankSetupData = z.infer + +interface BankDetailsSetupDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onComplete: () => void +} + +export function BankDetailsSetupDialog({ open, onOpenChange, onComplete }: BankDetailsSetupDialogProps) { + const { toast } = useToast() + const [isSaving, setIsSaving] = useState(false) + const [showInternational, setShowInternational] = useState(false) + const [bankName, setBankName] = useState('') + + const { + register, + handleSubmit, + formState: { errors }, + setValue, + trigger, + } = useForm({ + resolver: zodResolver(bankSetupSchema), + defaultValues: { + bank_name: '', + clearing_number: '', + account_number: '', + bankgiro: '', + iban: '', + bic: '', + invoice_prefix: '', + next_invoice_number: '', + }, + }) + + async function onSubmit(data: BankSetupData) { + setIsSaving(true) + + // Format bankgiro if valid + if (data.bankgiro) { + data.bankgiro = formatBankgiroNumber(data.bankgiro) + } + + // Include bank name from combobox + data.bank_name = bankName + + // Clean empty strings to null for API + const payload: Record = {} + for (const [key, val] of Object.entries(data)) { + if (key === 'next_invoice_number') { + const num = val ? parseInt(val as string, 10) : null + if (num !== null) payload[key] = num + } else { + payload[key] = (val as string) || null + } + } + + try { + const response = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Kunde inte spara') + } + + toast({ + title: 'Betalningsuppgifter sparade', + description: 'Du kan ändra dem senare i Inställningar.', + }) + onComplete() + } catch (error) { + toast({ + title: 'Kunde inte spara', + description: error instanceof Error ? error.message : 'Försök igen.', + variant: 'destructive', + }) + } finally { + setIsSaving(false) + } + } + + // Flatten refine errors so they appear on the right fields + const clearingError = errors.clearing_number?.message + const accountError = errors.account_number?.message + const bankgiroError = errors.bankgiro?.message + + return ( + + e.preventDefault()} + > + + Betalningsuppgifter + + Dessa uppgifter visas på dina fakturor. Du kan ändra dem senare i{' '} + Inställningar. + + + + + {/* Bank name */} +
+ + +
+ + {/* Clearing + Account number */} +
+
+ + trigger(['clearing_number', 'account_number'])} + /> +
+
+ + trigger(['clearing_number', 'account_number'])} + /> +
+
+ {clearingError && ( +

{clearingError}

+ )} + {accountError && !clearingError && ( +

{accountError}

+ )} + + {/* Bankgiro */} +
+ + { + const val = e.target.value.trim() + if (val && validateBankgiroNumber(val)) { + setValue('bankgiro', formatBankgiroNumber(val)) + } + trigger('bankgiro') + }} + /> + {bankgiroError && ( +

{bankgiroError}

+ )} +
+ + {/* International payments — collapsible */} +
+ + {showInternational && ( +
+
+ + +
+
+ + +
+
+ )} +
+ + + + {/* Invoice prefix + starting number */} +
+
+ + +
+
+ + +
+
+

+ Prefix "F-" med startnummer 1 ger F-2026001. Lämna tomt för standard. +

+ {errors.next_invoice_number && ( +

{errors.next_invoice_number.message}

+ )} + + {/* Actions */} +
+ +
+ +
+
+ ) +} diff --git a/components/onboarding/Step6ConnectBank.tsx b/components/onboarding/Step6ConnectBank.tsx deleted file mode 100644 index d4166599..00000000 --- a/components/onboarding/Step6ConnectBank.tsx +++ /dev/null @@ -1,204 +0,0 @@ -'use client' - -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { InfoTooltip } from '@/components/ui/info-tooltip' -import { Loader2, ArrowLeft, Landmark, SkipForward } from 'lucide-react' - -const manualBankSchema = z.object({ - bank_name: z.string().optional(), - clearing_number: z.string().optional(), - account_number: z.string().optional(), - iban: z.string().optional(), - bic: z.string().optional(), -}) - -type ManualBankData = z.infer - -interface Step6Props { - initialData?: Partial - onComplete: (data?: ManualBankData) => void - onBack: () => void - onSkip: () => void - isSaving: boolean -} - -export default function Step6ConnectBank({ - initialData, - onBack, - onSkip, - onComplete, - isSaving, -}: Step6Props) { - const { - register, - handleSubmit, - } = useForm({ - resolver: zodResolver(manualBankSchema), - mode: 'onTouched', - defaultValues: { - bank_name: initialData?.bank_name || '', - clearing_number: initialData?.clearing_number || '', - account_number: initialData?.account_number || '', - iban: initialData?.iban || '', - bic: initialData?.bic || '', - }, - }) - - const onManualSubmit = (data: ManualBankData) => { - onComplete(data) - } - - return ( -
- - - - - Bankuppgifter för fakturor - - - Dessa uppgifter visas på dina fakturor så att kunder kan betala dig. - - - -
{ - const fields = Object.keys(errs).join(', ') - console.error('[onboarding] step 5 validation failed:', fields, errs) - fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 5 validation failed', extra: { fields } }) }).catch(() => {}) - })} className="space-y-4"> -
- - -
- -
-
- -

Vad är clearingnummer?

-

De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.

-
    -
  • Nordea: 3300
  • -
  • SEB: 5000
  • -
  • Swedbank: 8XXX
  • -
  • Handelsbanken: 6XXX
  • -
  • Avanza: 9550/9551
  • -
-
- } - side="top" - > - - - -
-
- - -
-
- -
-

Internationella betalningar (valfritt)

-
-
- -

Vad är IBAN?

-

Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.

-
- } - side="right" - > - - - -
-
- -

Vad är BIC/SWIFT?

-

Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.

-
- } - side="right" - > - - - -
-
- - -
-

- Du kan importera kontoutdrag (CSV, XML) från din bank under Import-sidan - efter att du slutfört registreringen. -

-
- - - - -
- - -
- - - - ) -} diff --git a/components/settings/BankNameCombobox.tsx b/components/settings/BankNameCombobox.tsx index f28b30fe..0066e690 100644 --- a/components/settings/BankNameCombobox.tsx +++ b/components/settings/BankNameCombobox.tsx @@ -25,11 +25,19 @@ const FALLBACK_BANKS: BankOption[] = [ interface BankNameComboboxProps { defaultValue?: string + value?: string + onChange?: (value: string) => void enableBankingEnabled?: boolean } -export function BankNameCombobox({ defaultValue = '', enableBankingEnabled = false }: BankNameComboboxProps) { - const [value, setValue] = useState(defaultValue) +export function BankNameCombobox({ defaultValue = '', value: controlledValue, onChange, enableBankingEnabled = false }: BankNameComboboxProps) { + const isControlled = controlledValue !== undefined + const [internalValue, setInternalValue] = useState(defaultValue) + const value = isControlled ? controlledValue : internalValue + const setValue = (v: string) => { + if (!isControlled) setInternalValue(v) + onChange?.(v) + } const [banks, setBanks] = useState(FALLBACK_BANKS) const [isOpen, setIsOpen] = useState(false) const [highlightedIndex, setHighlightedIndex] = useState(-1) diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index f84c2c20..83320c30 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -8,6 +8,7 @@ import { type ASPSP, } from './lib/api-client' import { syncAccountTransactions } from './lib/sync' +import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' import type { StoredAccount } from './types' import type { Transaction } from '@/types' @@ -220,6 +221,30 @@ export const enableBankingExtension: Extension = { // Use ctx.services.ingestTransactions when available const ingestFn = ctx?.services.ingestTransactions + // Detect SIE overlap — skip auto-categorization if the sync range + // overlaps with a completed SIE import to prevent double-booking. + // Reconciliation still links bank transactions to existing GL lines. + const { data: sieOverlap } = await supabase + .from('sie_imports') + .select('id') + .eq('user_id', user.id) + .eq('status', 'completed') + .gte('fiscal_year_end', fromDate) + .limit(1) + .maybeSingle() + + const syncOptions = sieOverlap + ? { skipAutoCategorization: true } + : undefined + + if (sieOverlap) { + log.info('SIE import overlap detected — suppressing auto-categorization', { + sieImportId: sieOverlap.id, + fromDate, + toDate, + }) + } + const results = await Promise.all( accounts.map(account => syncAccountTransactions( supabase, @@ -228,13 +253,35 @@ export const enableBankingExtension: Extension = { account, fromDate, toDate, - ingestFn + ingestFn, + syncOptions )) ) const totalImported = results.reduce((sum, r) => sum + r.imported, 0) const totalDuplicates = results.reduce((sum, r) => sum + r.duplicates, 0) + // When SIE overlap is detected, run a batch reconciliation sweep. + // The greedy algorithm considers all candidates globally (highest- + // confidence first) and catches matches the inline per-transaction + // pass may have missed due to processing order. + if (sieOverlap && totalImported > 0) { + try { + const reconResult = await runReconciliation(supabase, user.id, { + dateFrom: fromDate, + dateTo: toDate, + }) + if (reconResult.applied > 0) { + log.info('Post-sync batch reconciliation matched additional transactions', { + applied: reconResult.applied, + total: reconResult.matches.length, + }) + } + } catch { + // Non-critical — transactions remain uncategorized for manual review + } + } + const syncedAt = new Date().toISOString() await supabase .from('bank_connections') diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index a3c2524c..2905faab 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -2,16 +2,22 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { getAllTransactionsWithRaw, convertTransaction, getAccountBalance } from './api-client' import { uploadDocument } from '@/lib/core/documents/document-service' import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest' -import type { RawTransaction, IngestResult } from '@/types' +import type { RawTransaction, IngestResult, IngestOptions } from '@/types' import type { StoredAccount } from '../types' /** Ingest function signature — matches lib/transactions/ingest */ export type IngestFn = ( supabase: SupabaseClient, userId: string, - raw: RawTransaction[] + raw: RawTransaction[], + options?: IngestOptions ) => Promise +export interface SyncOptions { + /** Skip auto-categorization during ingestion (e.g. SIE overlap) */ + skipAutoCategorization?: boolean +} + export interface SyncResult { imported: number duplicates: number @@ -36,7 +42,8 @@ export async function syncAccountTransactions( account: StoredAccount, fromDate: string, toDate: string, - ingest: IngestFn = defaultIngest + ingest: IngestFn = defaultIngest, + syncOptions?: SyncOptions ): Promise { console.log('[enable-banking] syncAccountTransactions starting', { connectionId, @@ -75,7 +82,10 @@ export async function syncAccountTransactions( import_source: 'enable_banking', })) - const ingestResult = await ingest(supabase, userId, rawTransactions) + const ingestOptions: IngestOptions | undefined = syncOptions?.skipAutoCategorization + ? { skipAutoCategorization: true } + : undefined + const ingestResult = await ingest(supabase, userId, rawTransactions, ingestOptions) console.log('[enable-banking] Ingest result', { connectionId, diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index 4335a286..c364d1e3 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -1,6 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { CoreEvent, CoreEventType } from '@/lib/events/types' -import type { EntityType, RawTransaction, IngestResult } from '@/types' +import type { EntityType, RawTransaction, IngestResult, IngestOptions } from '@/types' // ============================================================ // Extension Marketplace Types @@ -142,7 +142,7 @@ export interface ExtensionStorage { /** Core services exposed to extensions */ export interface ExtensionServices { - ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[]): Promise + ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[], options?: IngestOptions): Promise } /** Context passed to extension lifecycle hooks and event handlers */ diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index 90d6349a..0a96bca9 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -8,7 +8,7 @@ import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliat import { fetchMultipleRates } from '@/lib/currency/riksbanken' import { logMatchEvent } from '@/lib/invoices/match-log' import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation' -import type { Transaction, RawTransaction, IngestResult, SupplierInvoice, Currency, ExchangeRate } from '@/types' +import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types' // Re-export types for backward compatibility export type { RawTransaction, IngestResult } from '@/types' @@ -71,7 +71,8 @@ async function buildBookedTransactionMap( export async function ingestTransactions( supabase: SupabaseClient, userId: string, - rawTransactions: RawTransaction[] + rawTransactions: RawTransaction[], + options?: IngestOptions ): Promise { const result: IngestResult = { imported: 0, @@ -313,45 +314,50 @@ export async function ingestTransactions( } // 4. Evaluate mapping rules for auto-categorization - try { - const mappingResult = await evaluateMappingRules( - supabase, - userId, - newTransaction as Transaction - ) - - if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) { - const journalEntry = await createTransactionJournalEntry( + // Skipped when SIE-imported entries overlap the sync range — prevents + // double-booking. Reconciliation (step 2.5) still links transactions to + // existing GL lines; only the "create new journal entry" path is suppressed. + if (!options?.skipAutoCategorization) { + try { + const mappingResult = await evaluateMappingRules( supabase, userId, - newTransaction as Transaction, - mappingResult + newTransaction as Transaction ) - if (journalEntry) { - await supabase - .from('transactions') - .update({ - journal_entry_id: journalEntry.id, - is_business: !mappingResult.default_private, - }) - .eq('id', newTransaction.id) + if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) { + const journalEntry = await createTransactionJournalEntry( + supabase, + userId, + newTransaction as Transaction, + mappingResult + ) - // Upsert counterparty template (auto-learned, lower confidence) - try { - await upsertCounterpartyTemplate( - supabase, userId, newTransaction as Transaction, - mappingResult, 'auto_learned' - ) - } catch { - // Non-critical + if (journalEntry) { + await supabase + .from('transactions') + .update({ + journal_entry_id: journalEntry.id, + is_business: !mappingResult.default_private, + }) + .eq('id', newTransaction.id) + + // Upsert counterparty template (auto-learned, lower confidence) + try { + await upsertCounterpartyTemplate( + supabase, userId, newTransaction as Transaction, + mappingResult, 'auto_learned' + ) + } catch { + // Non-critical + } + + result.auto_categorized++ } - - result.auto_categorized++ } + } catch { + // Non-critical — continue processing } - } catch { - // Non-critical — continue processing } } diff --git a/types/index.ts b/types/index.ts index f74009ca..c436b172 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2040,6 +2040,15 @@ export interface RawTransaction { import_source?: string } +/** Options for the transaction ingestion pipeline */ +export interface IngestOptions { + /** Skip auto-categorization (mapping engine + journal entry creation). + * Reconciliation and invoice matching still run. + * Used when SIE-imported entries overlap the sync date range + * to prevent double-booking. */ + skipAutoCategorization?: boolean +} + /** Result of the transaction ingestion pipeline */ export interface IngestResult { imported: number