feat: move bank details from onboarding to first invoice creation (#140)
* feat: event log, pending operations, and MCP staging - Event log system: persist bus events to event_log table for external automation platforms. Batch insert for transaction.synced. Daily cleanup cron at 02:00 UTC. - Pending operations: MCP write tools (categorize, create customer, create invoice) now stage to pending_operations instead of executing directly. Users review and commit/reject from /pending in the web UI. - Granskning page: card-based review UI with expandable previews, commit/reject dialogs. Only shown in nav when pending ops exist. - Commit route re-executes using core lib functions (no extension imports). Guards against stale state (double-commit, deleted entities). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stage new MCP write tools after main merge Add staging for 4 new write tools from #133: - mark_invoice_paid, send_invoice, mark_invoice_sent, match_transaction_invoice - Expand pending_operations CHECK constraint - Add commit executors with full execution logic - Add UI labels and generic preview component - Remove confirm parameter from categorize (single-call staging) - Fix UUID in pending op title (fetch transaction description) - Hide Granskning nav when no pending ops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback - Fix TS build error: use `select('*, customer:customers(*)')` for match_transaction_invoice to avoid array type inference - Add status guard to commitSendInvoice (prevents duplicate sends) - Replace auth.admin.getUserById with user email from session auth - Restore optimistic lock check in commitMatchTransactionInvoice - Fix tool description typo: expense_software → expense_office Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add support contact links and improve SIE import UX Add a SupportLink component with a contact dialog throughout the app (nav, help page, settings, MFA, error pages, empty states). Improve SIE import flow with phased loading states, structured skip breakdowns, and an elapsed-time counter. Fix MFA enroll stale factor cleanup and URL encoding for settings return path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — open redirect, XSS, test cleanup, fallback email - Validate returnTo is a relative path in MFA enroll (prevents open redirect) - Add afterEach import to event-log-handler tests (fixes handler leak) - HTML-escape user-supplied subject and message in support email body - Replace hardcoded personal email with support@gnubok.se fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: move bank details from onboarding to first invoice creation Bank details (account, bankgiro, invoice prefix) are now collected contextually when the user creates their first invoice, rather than during onboarding where most users skip them. This ensures invoices always have payment information on the PDF. - Remove onboarding step 5 (bank details), simplify to 4 steps - Delete Step6ConnectBank component - Add BankDetailsSetupDialog with bank account, bankgiro (Luhn), IBAN/BIC (collapsible), and invoice prefix fields - Intercept at "Granska & skapa" for invoice document type only (proforma and delivery notes pass through without bank details) - Show soft info banner on invoice form when bank details are missing - Add controlled mode (value/onChange) to BankNameCombobox for reuse Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — null-check race, escape key, starting number - Fix P1: use `hasBankDetails === false` instead of `!hasBankDetails` to avoid treating null (loading) state as missing bank details - Fix P2: remove onEscapeKeyDown override so keyboard users can dismiss the dialog (WCAG AA compliance) - Add starting invoice number field alongside prefix, so users can choose e.g. starting at 14 instead of 1 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: skip auto-categorization during bank sync when SIE overlap detected Prevents double-booking when bank transactions are synced for a period that already has journal entries from a SIE import. Reconciliation still links transactions to existing GL lines; only new journal entry creation is suppressed. A batch reconciliation sweep runs post-sync to catch additional matches. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
fa7c742ee3
commit
77a316fec1
@@ -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<string | null>(null)
|
||||
const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false)
|
||||
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
|
||||
const [hasBankDetails, setHasBankDetails] = useState<boolean | null>(null)
|
||||
const [showBankSetup, setShowBankSetup] = useState(false)
|
||||
const pendingCustomerRef = useRef<Customer | null>(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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBankDetails === false && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-sm">
|
||||
<Landmark className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Betalningsuppgifter saknas — du behöver lägga till dem innan du skapar en faktura.</p>
|
||||
<Button variant="link" size="sm" className="ml-auto shrink-0 px-0" onClick={() => setShowBankSetup(true)}>
|
||||
Lägg till nu
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
@@ -798,6 +826,13 @@ export default function NewInvoicePage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bank details setup dialog */}
|
||||
<BankDetailsSetupDialog
|
||||
open={showBankSetup}
|
||||
onOpenChange={setShowBankSetup}
|
||||
onComplete={handleBankSetupComplete}
|
||||
/>
|
||||
|
||||
{/* Send now prompt dialog */}
|
||||
<Dialog open={showSendPrompt} onOpenChange={(open) => {
|
||||
if (!open && createdInvoiceId) {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
|
||||
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<CompanyLookupResult | null>(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<CompanySettings>) => {
|
||||
// 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 && (
|
||||
<Step5ConnectBank
|
||||
initialData={{
|
||||
bank_name: settings.bank_name ?? undefined,
|
||||
clearing_number: settings.clearing_number ?? undefined,
|
||||
account_number: settings.account_number ?? undefined,
|
||||
iban: settings.iban ?? (ticLookup?.bankAccounts.find((b) => 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<typeof bankSetupSchema>
|
||||
|
||||
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<BankSetupData>({
|
||||
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<string, string | number | null> = {}
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-xl tracking-tight">Betalningsuppgifter</DialogTitle>
|
||||
<DialogDescription>
|
||||
Dessa uppgifter visas på dina fakturor. Du kan ändra dem senare i{' '}
|
||||
<a href="/settings" className="underline underline-offset-2 hover:text-foreground">Inställningar</a>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-2">
|
||||
{/* Bank name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<BankNameCombobox
|
||||
value={bankName}
|
||||
onChange={setBankName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clearing + Account number */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="8000"
|
||||
maxLength={5}
|
||||
inputMode="numeric"
|
||||
{...register('clearing_number')}
|
||||
onBlur={() => trigger(['clearing_number', 'account_number'])}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="12345678"
|
||||
maxLength={12}
|
||||
inputMode="numeric"
|
||||
{...register('account_number')}
|
||||
onBlur={() => trigger(['clearing_number', 'account_number'])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{clearingError && (
|
||||
<p className="text-sm text-destructive -mt-2">{clearingError}</p>
|
||||
)}
|
||||
{accountError && !clearingError && (
|
||||
<p className="text-sm text-destructive -mt-2">{accountError}</p>
|
||||
)}
|
||||
|
||||
{/* Bankgiro */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
placeholder="123-4567"
|
||||
maxLength={9}
|
||||
{...register('bankgiro')}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (val && validateBankgiroNumber(val)) {
|
||||
setValue('bankgiro', formatBankgiroNumber(val))
|
||||
}
|
||||
trigger('bankgiro')
|
||||
}}
|
||||
/>
|
||||
{bankgiroError && (
|
||||
<p className="text-sm text-destructive">{bankgiroError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* International payments — collapsible */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowInternational(!showInternational)}
|
||||
>
|
||||
{showInternational ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Internationella betalningar
|
||||
</button>
|
||||
{showInternational && (
|
||||
<div className="space-y-3 pt-3 animate-in slide-in-from-top-1 duration-150">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
<Input
|
||||
id="iban"
|
||||
placeholder="SE12 3456 7890 1234 5678 9012"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="NDEASESS"
|
||||
maxLength={11}
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Invoice prefix + starting number */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
maxLength={10}
|
||||
{...register('invoice_prefix')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Startnummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
placeholder="1"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
{...register('next_invoice_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Prefix "F-" med startnummer 1 ger F-2026001. Lämna tomt för standard.
|
||||
</p>
|
||||
{errors.next_invoice_number && (
|
||||
<p className="text-sm text-destructive -mt-2">{errors.next_invoice_number.message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Spara & fortsätt
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof manualBankSchema>
|
||||
|
||||
interface Step6Props {
|
||||
initialData?: Partial<ManualBankData>
|
||||
onComplete: (data?: ManualBankData) => void
|
||||
onBack: () => void
|
||||
onSkip: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function Step6ConnectBank({
|
||||
initialData,
|
||||
onBack,
|
||||
onSkip,
|
||||
onComplete,
|
||||
isSaving,
|
||||
}: Step6Props) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
} = useForm<ManualBankData>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Landmark className="h-5 w-5" />
|
||||
Bankuppgifter för fakturor
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onManualSubmit, (errs) => {
|
||||
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">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
placeholder="t.ex. Nordea, SEB, Swedbank"
|
||||
{...register('bank_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är clearingnummer?</p>
|
||||
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: 3300</li>
|
||||
<li>SEB: 5000</li>
|
||||
<li>Swedbank: 8XXX</li>
|
||||
<li>Handelsbanken: 6XXX</li>
|
||||
<li>Avanza: 9550/9551</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="XXXX"
|
||||
{...register('clearing_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="XXX XXX XXX"
|
||||
{...register('account_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är IBAN?</p>
|
||||
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="iban"
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är BIC/SWIFT?</p>
|
||||
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="XXXXSESS"
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Du kan importera kontoutdrag (CSV, XML) från din bank under <strong>Import</strong>-sidan
|
||||
efter att du slutfört registreringen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara och slutför'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-between gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSkip}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<SkipForward className="mr-2 h-4 w-4" />
|
||||
Hoppa över
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<BankOption[]>(FALLBACK_BANKS)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<IngestResult>
|
||||
|
||||
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<SyncResult> {
|
||||
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,
|
||||
|
||||
@@ -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<IngestResult>
|
||||
ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[], options?: IngestOptions): Promise<IngestResult>
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks and event handlers */
|
||||
|
||||
+39
-33
@@ -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<IngestResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user