Merge remote-tracking branch 'origin/main' into code-quality-improvements
This commit is contained in:
@@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import ChartOfAccounts from '@/components/bookkeeping/ChartOfAccounts'
|
||||
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
|
||||
import { Lock } from 'lucide-react'
|
||||
|
||||
export default function BookkeepingPage() {
|
||||
@@ -45,7 +45,7 @@ export default function BookkeepingPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts">
|
||||
<ChartOfAccounts />
|
||||
<ChartOfAccountsManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -133,8 +133,10 @@ function BankFileImportWizard() {
|
||||
description: `${txCount} transaktioner hittades`,
|
||||
})
|
||||
} else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) {
|
||||
// Unrecognized format — show upload step with error
|
||||
setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".')
|
||||
} else {
|
||||
// Format detected but no transactions parsed — parser couldn't extract rows
|
||||
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
|
||||
}
|
||||
} catch (err) {
|
||||
setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
|
||||
@@ -293,8 +295,6 @@ function BankFileImportWizard() {
|
||||
// SIE Import Wizard (unchanged, extracted into component)
|
||||
// ============================================================
|
||||
|
||||
const SIE_STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result']
|
||||
|
||||
const SIE_STEP_LABELS: Record<ImportWizardStep, string> = {
|
||||
upload: 'Ladda upp',
|
||||
preview: 'Förhandsgranskning',
|
||||
@@ -320,8 +320,14 @@ function SIEImportWizard() {
|
||||
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
|
||||
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
|
||||
|
||||
const currentStepIndex = SIE_STEPS.indexOf(step)
|
||||
const progress = ((currentStepIndex + 1) / SIE_STEPS.length) * 100
|
||||
// Skip the mapping step when all accounts are already mapped
|
||||
const hasUnmapped = mappings.some((m) => !m.targetAccount)
|
||||
const sieSteps: ImportWizardStep[] = hasUnmapped
|
||||
? ['upload', 'preview', 'mapping', 'review', 'result']
|
||||
: ['upload', 'preview', 'review', 'result']
|
||||
|
||||
const currentStepIndex = sieSteps.indexOf(step)
|
||||
const progress = ((currentStepIndex + 1) / sieSteps.length) * 100
|
||||
|
||||
const handleFileSelect = useCallback(async (selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
@@ -490,7 +496,7 @@ function SIEImportWizard() {
|
||||
}, [file, mappings, toast])
|
||||
|
||||
const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) }
|
||||
const goBack = () => { const i = SIE_STEPS.indexOf(step); if (i > 0) setStep(SIE_STEPS[i - 1]) }
|
||||
const goBack = () => { const i = sieSteps.indexOf(step); if (i > 0) setStep(sieSteps[i - 1]) }
|
||||
|
||||
const handleNewImport = () => {
|
||||
setStep('upload'); setFile(null); setParsed(null); setMappings([])
|
||||
@@ -504,7 +510,7 @@ function SIEImportWizard() {
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
{SIE_STEPS.map((s, i) => (
|
||||
{sieSteps.map((s, i) => (
|
||||
<span key={s} className={i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
||||
{SIE_STEP_LABELS[s]}
|
||||
</span>
|
||||
@@ -519,7 +525,7 @@ function SIEImportWizard() {
|
||||
{step === 'preview' && preview && (
|
||||
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
|
||||
onContinue={() => goToStep('mapping')} onBack={goBack} />
|
||||
onContinue={() => goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} />
|
||||
)}
|
||||
{step === 'mapping' && (
|
||||
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder } from '@/types'
|
||||
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary', icon: FileText },
|
||||
@@ -63,6 +63,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
|
||||
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
|
||||
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
|
||||
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
@@ -139,6 +141,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
}
|
||||
}
|
||||
|
||||
// If this invoice was converted from a proforma, fetch it
|
||||
if (data.converted_from_id) {
|
||||
const { data: convertedData } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('id', data.converted_from_id)
|
||||
.single()
|
||||
|
||||
if (convertedData) {
|
||||
setConvertedFromInvoice(convertedData as Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -235,6 +250,38 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
setIsSendingEmail(false)
|
||||
}
|
||||
|
||||
async function convertToInvoice() {
|
||||
if (!invoice) return
|
||||
setIsConverting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/convert`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Kunde inte konvertera proformafakturan')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Konverterad till faktura',
|
||||
description: `Faktura ${data.data.invoice_number} har skapats`,
|
||||
})
|
||||
|
||||
router.push(`/invoices/${data.data.id}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte konvertera',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsConverting(false)
|
||||
}
|
||||
|
||||
async function downloadPDF() {
|
||||
if (!invoice) return
|
||||
|
||||
@@ -288,6 +335,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const StatusIcon = status.icon
|
||||
const customer = invoice.customer
|
||||
const customerHasEmail = !!customer.email
|
||||
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
|
||||
const isProforma = docType === 'proforma'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isRealInvoice = docType === 'invoice'
|
||||
const docLabel = isProforma ? 'Proformafaktura' : isDeliveryNote ? 'Följesedel' : 'Faktura'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -300,6 +352,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
|
||||
{isProforma && (
|
||||
<Badge variant="secondary" className="bg-blue-100 text-blue-700">Proforma</Badge>
|
||||
)}
|
||||
{isDeliveryNote && (
|
||||
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">Följesedel</Badge>
|
||||
)}
|
||||
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{status.label}
|
||||
@@ -314,7 +372,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{invoice.status === 'draft' && (
|
||||
{isProforma && invoice.status !== 'cancelled' && (
|
||||
<Button onClick={convertToInvoice} disabled={isConverting}>
|
||||
{isConverting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Konvertera till faktura
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === 'draft' && !isDeliveryNote && (
|
||||
customerHasEmail ? (
|
||||
<Button onClick={sendInvoiceEmail} disabled={isSendingEmail}>
|
||||
{isSendingEmail ? (
|
||||
@@ -331,7 +399,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
|
||||
{isDeliveryNote && invoice.status === 'draft' && (
|
||||
<Button onClick={() => updateStatus('sent')} disabled={isUpdating}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Markera som skickad
|
||||
</Button>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
|
||||
<Button onClick={() => updateStatus('paid')} disabled={isUpdating}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Markera som betald
|
||||
@@ -656,6 +730,29 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Converted from proforma */}
|
||||
{convertedFromInvoice && (
|
||||
<Card className="border-blue-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-blue-600">
|
||||
<FileText className="h-5 w-5" />
|
||||
Konverterad
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Konverterad från proformafaktura
|
||||
</p>
|
||||
<Link href={`/invoices/${convertedFromInvoice.id}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Se proforma {convertedFromInvoice.invoice_number}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status actions */}
|
||||
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
|
||||
<Card>
|
||||
@@ -663,9 +760,34 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<CardTitle>Åtgärder</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{invoice.status === 'draft' && (
|
||||
{isProforma && (
|
||||
<>
|
||||
{customerHasEmail ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={convertToInvoice}
|
||||
disabled={isConverting}
|
||||
>
|
||||
{isConverting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Konvertera till faktura
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('cancelled')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Makulera
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isProforma && invoice.status === 'draft' && (
|
||||
<>
|
||||
{!isDeliveryNote && customerHasEmail ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={sendInvoiceEmail}
|
||||
@@ -680,12 +802,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-700">
|
||||
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
|
||||
</p>
|
||||
</div>
|
||||
{!isDeliveryNote && (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-700">
|
||||
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('sent')}
|
||||
@@ -707,7 +831,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -734,7 +858,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
{invoice.status === 'paid' && isRealInvoice && (
|
||||
<Link href={`/invoices/${invoice.id}/credit`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ReceiptText className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -16,17 +16,19 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft } from 'lucide-react'
|
||||
import { getVatRules, getVatTreatmentLabel, 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 { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
|
||||
import type { Customer, Currency, CreateInvoiceInput } from '@/types'
|
||||
import type { Customer, Currency, CreateInvoiceInput, InvoiceDocumentType } from '@/types'
|
||||
|
||||
const itemSchema = z.object({
|
||||
description: z.string().min(1, 'Beskrivning krävs'),
|
||||
quantity: z.number().min(0.01, 'Minst 0.01'),
|
||||
unit: z.string().min(1, 'Enhet krävs'),
|
||||
unit_price: z.number().min(0, 'Pris måste vara positivt'),
|
||||
vat_rate: z.number().min(0).max(25),
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
@@ -34,6 +36,7 @@ const schema = z.object({
|
||||
invoice_date: z.string().min(1, 'Fakturadatum krävs'),
|
||||
due_date: z.string().min(1, 'Förfallodatum krävs'),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
@@ -58,6 +61,11 @@ export default function NewInvoicePage() {
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(null)
|
||||
const [createdInvoiceId, setCreatedInvoiceId] = useState<string | null>(null)
|
||||
const [showSendPrompt, setShowSendPrompt] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [defaultNotes, setDefaultNotes] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -73,7 +81,8 @@ export default function NewInvoicePage() {
|
||||
invoice_date: '',
|
||||
due_date: '',
|
||||
currency: 'SEK',
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0 }],
|
||||
document_type: 'invoice' as InvoiceDocumentType,
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -91,11 +100,24 @@ export default function NewInvoicePage() {
|
||||
const watchItems = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
const watchCustomerId = watch('customer_id')
|
||||
const watchDocumentType = watch('document_type') as InvoiceDocumentType
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
fetchDefaultNotes()
|
||||
}, [])
|
||||
|
||||
async function fetchDefaultNotes() {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('invoice_default_notes')
|
||||
.single()
|
||||
if (data?.invoice_default_notes) {
|
||||
setDefaultNotes(data.invoice_default_notes)
|
||||
setValue('notes', data.invoice_default_notes)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (watchCustomerId) {
|
||||
const customer = customers.find((c) => c.id === watchCustomerId)
|
||||
@@ -108,6 +130,17 @@ export default function NewInvoicePage() {
|
||||
format(addDays(new Date(), customer.default_payment_terms), 'yyyy-MM-dd')
|
||||
)
|
||||
}
|
||||
|
||||
// When customer forces a single rate (reverse charge/export), update all lines
|
||||
if (customer) {
|
||||
const rates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
if (rates.length === 1) {
|
||||
const forcedRate = rates[0].rate
|
||||
watchItems.forEach((_, i) => {
|
||||
setValue(`items.${i}.vat_rate`, forcedRate)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [watchCustomerId, customers, setValue])
|
||||
|
||||
@@ -137,7 +170,24 @@ export default function NewInvoicePage() {
|
||||
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
|
||||
: null
|
||||
|
||||
const vatAmount = vatRules ? subtotal * (vatRules.rate / 100) : 0
|
||||
const availableRates = selectedCustomer
|
||||
? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
|
||||
: []
|
||||
const isRateLocked = availableRates.length === 1
|
||||
|
||||
// Calculate per-item VAT
|
||||
const vatByRate = new Map<number, { base: number; vat: number }>()
|
||||
let vatAmount = 0
|
||||
for (const item of watchItems) {
|
||||
const rate = item.vat_rate ?? (vatRules?.rate || 25)
|
||||
const lineTotal = (item.quantity || 0) * (item.unit_price || 0)
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
vatAmount += lineVat
|
||||
const existing = vatByRate.get(rate) || { base: 0, vat: 0 }
|
||||
existing.base += lineTotal
|
||||
existing.vat += lineVat
|
||||
vatByRate.set(rate, existing)
|
||||
}
|
||||
const total = subtotal + vatAmount
|
||||
|
||||
function onSubmit(data: FormData) {
|
||||
@@ -162,13 +212,21 @@ export default function NewInvoicePage() {
|
||||
throw new Error(result.error || 'Kunde inte skapa faktura')
|
||||
}
|
||||
|
||||
const docLabel = watchDocumentType === 'proforma' ? 'Proformafaktura' : watchDocumentType === 'delivery_note' ? 'Följesedel' : 'Faktura'
|
||||
toast({
|
||||
title: 'Faktura skapad',
|
||||
description: `Faktura ${result.data.invoice_number} har skapats`,
|
||||
title: `${docLabel} skapad`,
|
||||
description: `${docLabel} ${result.data.invoice_number} har skapats`,
|
||||
})
|
||||
|
||||
setShowReview(false)
|
||||
router.push(`/invoices/${result.data.id}`)
|
||||
|
||||
// If customer has email, offer to send immediately
|
||||
if (selectedCustomer?.email) {
|
||||
setCreatedInvoiceId(result.data.id)
|
||||
setShowSendPrompt(true)
|
||||
} else {
|
||||
router.push(`/invoices/${result.data.id}`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
@@ -180,6 +238,77 @@ export default function NewInvoicePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendNow() {
|
||||
if (!createdInvoiceId) return
|
||||
setIsSending(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${createdInvoiceId}/send`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte skicka faktura')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Faktura skickad',
|
||||
description: `Fakturan har skickats till ${selectedCustomer?.email}`,
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel vid skickning',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
setShowSendPrompt(false)
|
||||
router.push(`/invoices/${createdInvoiceId}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreviewPDF() {
|
||||
if (!pendingData) return
|
||||
setIsPreviewing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/invoices/preview-pdf', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
customer_id: pendingData.customer_id,
|
||||
invoice_date: pendingData.invoice_date,
|
||||
due_date: pendingData.due_date,
|
||||
currency: pendingData.currency,
|
||||
document_type: pendingData.document_type,
|
||||
items: pendingData.items,
|
||||
your_reference: pendingData.your_reference,
|
||||
our_reference: pendingData.our_reference,
|
||||
notes: pendingData.notes,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte generera förhandsgranskning')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
window.open(url, '_blank')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte generera PDF',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -195,8 +324,12 @@ export default function NewInvoicePage() {
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Ny faktura</h1>
|
||||
<p className="text-muted-foreground">Skapa en ny faktura</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{watchDocumentType === 'proforma' ? 'Ny proformafaktura' : watchDocumentType === 'delivery_note' ? 'Ny följesedel' : 'Ny faktura'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{watchDocumentType === 'proforma' ? 'Skapa en proformafaktura (ingen bokföring)' : watchDocumentType === 'delivery_note' ? 'Skapa en följesedel (utan priser)' : 'Skapa en ny faktura'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +391,7 @@ export default function NewInvoicePage() {
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid gap-4 md:grid-cols-12 items-start">
|
||||
<div className="md:col-span-5 space-y-2">
|
||||
<div className="md:col-span-3 space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input
|
||||
placeholder="T.ex. Instagram-kampanj"
|
||||
@@ -307,6 +440,31 @@ export default function NewInvoicePage() {
|
||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>Moms</Label>
|
||||
<Controller
|
||||
name={`items.${index}.vat_rate`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value ?? 25)}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
disabled={isRateLocked}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRates.map((opt) => (
|
||||
<SelectItem key={opt.rate} value={String(opt.rate)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -325,7 +483,7 @@ export default function NewInvoicePage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
append({ description: '', quantity: 1, unit: 'st', unit_price: 0 })
|
||||
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
@@ -358,6 +516,26 @@ export default function NewInvoicePage() {
|
||||
<CardTitle>Fakturadetaljer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Dokumenttyp</Label>
|
||||
<Controller
|
||||
name="document_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="invoice">Faktura</SelectItem>
|
||||
<SelectItem value="proforma">Proformafaktura</SelectItem>
|
||||
<SelectItem value="delivery_note">Följesedel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Valuta</Label>
|
||||
<Controller
|
||||
@@ -417,12 +595,21 @@ export default function NewInvoicePage() {
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span>{formatCurrency(subtotal, watchCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Moms ({vatRules?.rate || 25}%)
|
||||
</span>
|
||||
<span>{formatCurrency(vatAmount, watchCurrency)}</span>
|
||||
</div>
|
||||
{Array.from(vatByRate.entries())
|
||||
.filter(([, group]) => group.vat > 0)
|
||||
.sort(([a], [b]) => b - a)
|
||||
.map(([rate, group]) => (
|
||||
<div key={rate} className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms {rate}%</span>
|
||||
<span>{formatCurrency(group.vat, watchCurrency)}</span>
|
||||
</div>
|
||||
))}
|
||||
{vatByRate.size === 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatCurrency(0, watchCurrency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Totalt</span>
|
||||
@@ -445,15 +632,37 @@ export default function NewInvoicePage() {
|
||||
onOpenChange={setShowReview}
|
||||
onConfirm={handleConfirm}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Granska faktura"
|
||||
warningText="En faktura skapas och en verifikation bokförs. Verifikationen kan inte ändras efteråt."
|
||||
title={watchDocumentType === 'proforma' ? 'Granska proformafaktura' : watchDocumentType === 'delivery_note' ? 'Granska följesedel' : 'Granska faktura'}
|
||||
warningText={watchDocumentType === 'invoice'
|
||||
? 'En faktura skapas och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en kreditnota.'
|
||||
: watchDocumentType === 'proforma'
|
||||
? 'En proformafaktura skapas. Ingen verifikation bokförs. Proforman kan senare konverteras till en riktig faktura.'
|
||||
: 'En följesedel skapas utan priser. Ingen verifikation bokförs.'}
|
||||
confirmLabel={watchDocumentType === 'proforma' ? 'Skapa proformafaktura' : watchDocumentType === 'delivery_note' ? 'Skapa följesedel' : 'Bekräfta & skapa'}
|
||||
extraActions={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePreviewPDF}
|
||||
disabled={isPreviewing || isSubmitting}
|
||||
>
|
||||
{isPreviewing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isPreviewing ? 'Genererar...' : 'Förhandsgranska PDF'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<InvoiceReviewContent
|
||||
customer={selectedCustomer}
|
||||
invoiceDate={pendingData?.invoice_date || ''}
|
||||
dueDate={pendingData?.due_date || ''}
|
||||
currency={(pendingData?.currency || 'SEK') as Currency}
|
||||
items={pendingData?.items || []}
|
||||
items={(pendingData?.items || []).map((item) => ({
|
||||
...item,
|
||||
vat_rate: item.vat_rate ?? (vatRules?.rate || 25),
|
||||
}))}
|
||||
subtotal={subtotal}
|
||||
vatRate={vatRules.rate}
|
||||
vatAmount={vatAmount}
|
||||
@@ -465,6 +674,43 @@ export default function NewInvoicePage() {
|
||||
/>
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
||||
{/* Send now prompt dialog */}
|
||||
<Dialog open={showSendPrompt} onOpenChange={(open) => {
|
||||
if (!open && createdInvoiceId) {
|
||||
setShowSendPrompt(false)
|
||||
router.push(`/invoices/${createdInvoiceId}`)
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skicka fakturan nu?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fakturan skapades. Vill du skicka den till {selectedCustomer?.email} direkt?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowSendPrompt(false)
|
||||
if (createdInvoiceId) router.push(`/invoices/${createdInvoiceId}`)
|
||||
}}
|
||||
disabled={isSending}
|
||||
>
|
||||
Skicka senare
|
||||
</Button>
|
||||
<Button onClick={handleSendNow} disabled={isSending}>
|
||||
{isSending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isSending ? 'Skickar...' : 'Skicka nu'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle } from 'lucide-react'
|
||||
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle, FileQuestion, Truck } from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
import type { Invoice, InvoiceStatus } from '@/types'
|
||||
|
||||
@@ -82,11 +82,14 @@ export default function InvoicesPage() {
|
||||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote) ||
|
||||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') ||
|
||||
(activeTab === 'credit' && isCreditNote) ||
|
||||
invoice.status === activeTab
|
||||
(activeTab === 'proforma' && docType === 'proforma') ||
|
||||
(activeTab === 'delivery_note' && docType === 'delivery_note') ||
|
||||
(activeTab !== 'proforma' && activeTab !== 'delivery_note' && invoice.status === activeTab)
|
||||
|
||||
return matchesSearch && matchesTab
|
||||
})
|
||||
@@ -181,6 +184,8 @@ export default function InvoicesPage() {
|
||||
<TabsTrigger value="unpaid">Obetalda</TabsTrigger>
|
||||
<TabsTrigger value="paid">Betalda</TabsTrigger>
|
||||
<TabsTrigger value="draft">Utkast</TabsTrigger>
|
||||
<TabsTrigger value="proforma">Proforma</TabsTrigger>
|
||||
<TabsTrigger value="delivery_note">Följesedel</TabsTrigger>
|
||||
<TabsTrigger value="credit">Kredit</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -232,9 +237,12 @@ export default function InvoicesPage() {
|
||||
{filteredInvoices.map((invoice) => {
|
||||
const status = statusConfig[invoice.status]
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const StatusIcon = isCreditNote ? ReceiptText : status.icon
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const isProforma = docType === 'proforma'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const StatusIcon = isCreditNote ? ReceiptText : isProforma ? FileQuestion : isDeliveryNote ? Truck : status.icon
|
||||
const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null
|
||||
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : `border-l-4 ${status.borderColor}`
|
||||
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : isProforma ? 'border-l-4 border-l-blue-400' : isDeliveryNote ? 'border-l-4 border-l-emerald-400' : `border-l-4 ${status.borderColor}`
|
||||
|
||||
return (
|
||||
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
|
||||
@@ -253,6 +261,16 @@ export default function InvoicesPage() {
|
||||
Kredit
|
||||
</Badge>
|
||||
)}
|
||||
{isProforma && (
|
||||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700">
|
||||
Proforma
|
||||
</Badge>
|
||||
)}
|
||||
{isDeliveryNote && (
|
||||
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700">
|
||||
Följesedel
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
|
||||
@@ -6,10 +6,15 @@ import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight, ArrowLeftRight } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
|
||||
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
|
||||
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
|
||||
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
|
||||
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
|
||||
import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart'
|
||||
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
|
||||
import type {
|
||||
FiscalPeriod,
|
||||
TrialBalanceRow,
|
||||
@@ -78,7 +83,7 @@ export default function ReportsPage() {
|
||||
>
|
||||
{periods.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.name} ({p.period_start} — {p.period_end})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -98,8 +103,9 @@ export default function ReportsPage() {
|
||||
|
||||
{selectedPeriod ? (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="trial-balance">
|
||||
<div className="relative">
|
||||
<TabsList className="w-full justify-start overflow-x-auto flex-nowrap scrollbar-hide">
|
||||
<TabsTrigger value="trial-balance">
|
||||
<Scale className="h-4 w-4 mr-1" />
|
||||
Saldobalans
|
||||
</TabsTrigger>
|
||||
@@ -141,7 +147,13 @@ export default function ReportsPage() {
|
||||
<Building2 className="h-4 w-4 mr-1" />
|
||||
Lev.reskontra
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsTrigger value="bank-reconciliation">
|
||||
<ArrowLeftRight className="h-4 w-4 mr-1" />
|
||||
Bankavstämning
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none md:hidden" />
|
||||
</div>
|
||||
|
||||
<TabsContent value="trial-balance">
|
||||
<TrialBalanceView periodId={selectedPeriod} />
|
||||
@@ -175,6 +187,9 @@ export default function ReportsPage() {
|
||||
<TabsContent value="supplier-ledger">
|
||||
<SupplierLedgerView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
<TabsContent value="bank-reconciliation">
|
||||
<BankReconciliationView />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Card>
|
||||
@@ -248,22 +263,24 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Saldobalans</CardTitle>
|
||||
{data.isBalanced ? (
|
||||
<Badge className="bg-green-100 text-green-800">Balanserad</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej balanserad</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<div className="space-y-4">
|
||||
<TrialBalanceChart rows={data.rows} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Saldobalans</CardTitle>
|
||||
{data.isBalanced ? (
|
||||
<Badge className="bg-green-100 text-green-800">Balanserad</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej balanserad</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-20">Konto</th>
|
||||
<th className="py-2">Namn</th>
|
||||
<th className="py-2 w-28 text-right">Period debet</th>
|
||||
<th className="py-2 w-28 text-right">Period kredit</th>
|
||||
@@ -311,17 +328,22 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<IncomeStatementReport | null>(null)
|
||||
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
|
||||
const [monthlyLoading, setMonthlyLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setMonthlyLoading(true)
|
||||
|
||||
fetch(`/api/reports/income-statement?period_id=${periodId}`)
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
@@ -336,6 +358,18 @@ function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
setError('Kunde inte hämta resultaträkning')
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`)
|
||||
.then((res) => res.json())
|
||||
.then((result) => {
|
||||
if (result.data?.months) {
|
||||
setMonthlyData(result.data.months)
|
||||
}
|
||||
setMonthlyLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setMonthlyLoading(false)
|
||||
})
|
||||
}, [periodId])
|
||||
|
||||
if (loading) {
|
||||
@@ -371,6 +405,10 @@ function IncomeStatementView({ periodId }: { periodId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!monthlyLoading && monthlyData.length > 0 && (
|
||||
<IncomeExpenseChart months={monthlyData} />
|
||||
)}
|
||||
|
||||
{/* Revenue */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -741,6 +779,8 @@ function VatDeclarationView() {
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<VatCompositionChart rutor={data.rutor} />
|
||||
|
||||
{/* Summary */}
|
||||
<Card className="border-2">
|
||||
<CardHeader>
|
||||
|
||||
+198
-173
@@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
@@ -145,6 +146,7 @@ export default function SettingsPage() {
|
||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
|
||||
accounting_method: formData.get('accounting_method') as string || 'accrual',
|
||||
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -332,191 +334,214 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Company settings */}
|
||||
<TabsContent value="company">
|
||||
<form onSubmit={handleSaveSettings}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Företagsuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa uppgifter visas på dina fakturor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings?.org_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||
{/* Företagsuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Företagsuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Namn, organisationsnummer och adress
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings?.address_line1 || ''}
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings?.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings?.city || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings?.org_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Bankuppgifter för fakturor</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
name="bank_name"
|
||||
defaultValue={settings?.bank_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
defaultValue={settings?.clearing_number || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
defaultValue={settings?.account_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings?.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings?.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Fakturainställningar</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings?.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings?.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings?.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
|
||||
{settings?.entity_type === 'aktiebolag' ? (
|
||||
<>
|
||||
<input type="hidden" name="accounting_method" value="accrual" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value="Faktureringsmetoden"
|
||||
disabled
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Obligatorisk för aktiebolag
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings?.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag måste använda faktureringsmetoden enligt BFL.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings?.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Skatteinställningar</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings?.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
</div>
|
||||
{/* Bankuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bankuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Betalningsuppgifter som visas på dina fakturor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
name="bank_name"
|
||||
defaultValue={settings?.bank_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
defaultValue={settings?.clearing_number || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
defaultValue={settings?.account_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
{/* Fakturainställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fakturainställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Numrering, betalningsvillkor och bokföringsmetod
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings?.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings?.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings?.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings?.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden enligt BFL. Mindre aktiebolag kan välja kontantmetoden.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
defaultValue={settings?.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Texten föreslås automatiskt i anteckningsfältet vid ny faktura.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skatteinställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skatteinställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Preliminärskatt och F-skatt
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings?.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
{/* Banking settings (only shown when extension is active or connections exist) */}
|
||||
|
||||
@@ -11,16 +11,16 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeft, Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
|
||||
import type { Supplier, VatTreatment } from '@/types'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
|
||||
import type { Supplier, BASAccount, VatTreatment } from '@/types'
|
||||
|
||||
interface LineItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
amount: number
|
||||
account_number: string
|
||||
vat_rate: number
|
||||
}
|
||||
@@ -33,7 +33,6 @@ interface FormData {
|
||||
delivery_date: string
|
||||
currency: string
|
||||
exchange_rate: string
|
||||
vat_treatment: VatTreatment
|
||||
reverse_charge: boolean
|
||||
payment_reference: string
|
||||
notes: string
|
||||
@@ -44,15 +43,31 @@ function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment {
|
||||
if (reverseCharge) return 'reverse_charge'
|
||||
|
||||
const rates = new Set(items.map((i) => i.vat_rate))
|
||||
if (rates.size === 1) {
|
||||
const rate = rates.values().next().value!
|
||||
if (rate === 0.25) return 'standard_25'
|
||||
if (rate === 0.12) return 'reduced_12'
|
||||
if (rate === 0.06) return 'reduced_6'
|
||||
if (rate === 0) return 'exempt'
|
||||
}
|
||||
|
||||
return 'standard_25'
|
||||
}
|
||||
|
||||
export default function NewSupplierInvoicePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [showReview, setShowReview] = useState(false)
|
||||
const [pendingData, setPendingData] = useState<FormData | null>(null)
|
||||
|
||||
const { register, control, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>({
|
||||
const { register, control, handleSubmit, watch, setValue } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
supplier_id: '',
|
||||
supplier_invoice_number: '',
|
||||
@@ -61,16 +76,13 @@ export default function NewSupplierInvoicePage() {
|
||||
delivery_date: '',
|
||||
currency: 'SEK',
|
||||
exchange_rate: '',
|
||||
vat_treatment: 'standard_25',
|
||||
reverse_charge: false,
|
||||
payment_reference: '',
|
||||
notes: '',
|
||||
items: [
|
||||
{
|
||||
description: '',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 0,
|
||||
amount: 0,
|
||||
account_number: '5010',
|
||||
vat_rate: 0.25,
|
||||
},
|
||||
@@ -85,6 +97,7 @@ export default function NewSupplierInvoicePage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers()
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
// Auto-fill due date when supplier is selected
|
||||
@@ -106,7 +119,6 @@ export default function NewSupplierInvoicePage() {
|
||||
}
|
||||
if (supplier.supplier_type === 'eu_business') {
|
||||
setValue('reverse_charge', true)
|
||||
setValue('vat_treatment', 'reverse_charge')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,9 +130,27 @@ export default function NewSupplierInvoicePage() {
|
||||
setSuppliers(data || [])
|
||||
}
|
||||
|
||||
async function fetchAccounts() {
|
||||
const res = await fetch('/api/bookkeeping/accounts')
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
}
|
||||
|
||||
function handleAccountChange(index: number, accountNumber: string) {
|
||||
setValue(`items.${index}.account_number`, accountNumber)
|
||||
// Auto-fill description from account name if description is empty
|
||||
const currentDesc = watch(`items.${index}.description`)
|
||||
if (!currentDesc && accountNumber.length === 4) {
|
||||
const desc = getAccountDescription(accountNumber)
|
||||
if (desc) {
|
||||
setValue(`items.${index}.description`, desc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const itemTotals = (watchedItems || []).map((item) => {
|
||||
const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 100) / 100
|
||||
const lineTotal = Math.round((item.amount || 0) * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
|
||||
return { lineTotal, vatAmount }
|
||||
})
|
||||
@@ -147,6 +177,8 @@ export default function NewSupplierInvoicePage() {
|
||||
if (!pendingData) return
|
||||
setIsSubmitting(true)
|
||||
|
||||
const vatTreatment = inferVatTreatment(pendingData.items, pendingData.reverse_charge)
|
||||
|
||||
const payload = {
|
||||
supplier_id: pendingData.supplier_id,
|
||||
supplier_invoice_number: pendingData.supplier_invoice_number,
|
||||
@@ -155,15 +187,13 @@ export default function NewSupplierInvoicePage() {
|
||||
delivery_date: pendingData.delivery_date || undefined,
|
||||
currency: pendingData.currency,
|
||||
exchange_rate: pendingData.exchange_rate ? parseFloat(pendingData.exchange_rate) : undefined,
|
||||
vat_treatment: pendingData.vat_treatment,
|
||||
vat_treatment: vatTreatment,
|
||||
reverse_charge: pendingData.reverse_charge,
|
||||
payment_reference: pendingData.payment_reference || undefined,
|
||||
notes: pendingData.notes || undefined,
|
||||
items: pendingData.items.map((item) => ({
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
amount: item.amount,
|
||||
account_number: item.account_number,
|
||||
vat_rate: item.vat_rate,
|
||||
})),
|
||||
@@ -270,13 +300,13 @@ export default function NewSupplierInvoicePage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Currency & VAT */}
|
||||
{/* Currency & Reverse Charge */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Valuta & moms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Valuta</Label>
|
||||
<Controller
|
||||
@@ -310,27 +340,6 @@ export default function NewSupplierInvoicePage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Momsbehandling</Label>
|
||||
<Controller
|
||||
name="vat_treatment"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard_25">Standard 25%</SelectItem>
|
||||
<SelectItem value="reduced_12">Reducerad 12%</SelectItem>
|
||||
<SelectItem value="reduced_6">Reducerad 6%</SelectItem>
|
||||
<SelectItem value="reverse_charge">Omvänd skattskyldighet</SelectItem>
|
||||
<SelectItem value="exempt">Momsfritt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
@@ -351,7 +360,7 @@ export default function NewSupplierInvoicePage() {
|
||||
{/* Line Items */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Rader</CardTitle>
|
||||
<CardTitle className="text-lg">Kontering</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -359,10 +368,8 @@ export default function NewSupplierInvoicePage() {
|
||||
onClick={() =>
|
||||
append({
|
||||
description: '',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 0,
|
||||
account_number: '5010',
|
||||
amount: 0,
|
||||
account_number: '',
|
||||
vat_rate: 0.25,
|
||||
})
|
||||
}
|
||||
@@ -375,20 +382,30 @@ export default function NewSupplierInvoicePage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 w-28">Konto</th>
|
||||
<th className="pb-2">Beskrivning</th>
|
||||
<th className="pb-2 w-16">Antal</th>
|
||||
<th className="pb-2 w-16">Enhet</th>
|
||||
<th className="pb-2 w-28">À-pris (exkl.)</th>
|
||||
<th className="pb-2 w-24">Konto</th>
|
||||
<th className="pb-2 w-32">Belopp (exkl.)</th>
|
||||
<th className="pb-2 w-24">Momssats</th>
|
||||
<th className="pb-2 w-28 text-right">Belopp</th>
|
||||
<th className="pb-2 w-24 text-right">Moms</th>
|
||||
<th className="pb-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field, index) => (
|
||||
<tr key={field.id} className="border-b last:border-0">
|
||||
<tr key={field.id} className="border-b last:border-0 align-top">
|
||||
<td className="py-2 pr-2">
|
||||
<Controller
|
||||
name={`items.${index}.account_number`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<AccountCombobox
|
||||
value={f.value}
|
||||
accounts={accounts}
|
||||
onChange={(val) => handleAccountChange(index, val)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
placeholder="Beskrivning"
|
||||
@@ -399,23 +416,8 @@ export default function NewSupplierInvoicePage() {
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input {...register(`items.${index}.unit`)} />
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
placeholder="5010"
|
||||
{...register(`items.${index}.account_number`)}
|
||||
placeholder="0,00"
|
||||
{...register(`items.${index}.amount`, { valueAsNumber: true })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
@@ -440,13 +442,10 @@ export default function NewSupplierInvoicePage() {
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right font-mono">
|
||||
{formatAmount(itemTotals[index]?.lineTotal || 0)}
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right font-mono">
|
||||
<td className="py-2 pr-2 text-right font-mono pt-4">
|
||||
{formatAmount(itemTotals[index]?.vatAmount || 0)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<td className="py-2 pt-3">
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -515,7 +514,7 @@ export default function NewSupplierInvoicePage() {
|
||||
onConfirm={handleConfirm}
|
||||
isSubmitting={isSubmitting}
|
||||
title="Granska leverantörsfaktura"
|
||||
warningText="Leverantörsfakturan registreras och en verifikation bokförs. Verifikationen kan inte ändras efteråt."
|
||||
warningText="Leverantörsfakturan registreras och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en ändringsverifikation."
|
||||
confirmLabel="Bekräfta & registrera"
|
||||
>
|
||||
<SupplierInvoiceReviewContent
|
||||
@@ -526,7 +525,6 @@ export default function NewSupplierInvoicePage() {
|
||||
deliveryDate={pendingData.delivery_date || undefined}
|
||||
currency={pendingData.currency}
|
||||
exchangeRate={pendingData.exchange_rate || undefined}
|
||||
vatTreatment={pendingData.vat_treatment}
|
||||
reverseCharge={pendingData.reverse_charge}
|
||||
paymentReference={pendingData.payment_reference || undefined}
|
||||
items={pendingData.items}
|
||||
|
||||
@@ -12,9 +12,11 @@ import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
|
||||
import Link from 'next/link'
|
||||
import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload } from 'lucide-react'
|
||||
import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2, Upload, CheckSquare, X } from 'lucide-react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import TransactionForm from '@/components/transactions/TransactionForm'
|
||||
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
|
||||
import BatchCategorySelector from '@/components/transactions/BatchCategorySelector'
|
||||
import type { Transaction, TransactionCategory, CreateTransactionInput, Invoice, Customer } from '@/types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
@@ -35,6 +37,15 @@ export default function TransactionsPage() {
|
||||
const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
|
||||
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
|
||||
const [isBatchMode, setIsBatchMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBatchSelector, setShowBatchSelector] = useState(false)
|
||||
const [batchProgress, setBatchProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const [hideCategorizationHint, setHideCategorizationHint] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
setHideCategorizationHint(localStorage.getItem('hideCategorizationHint') === 'true')
|
||||
}, [])
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
@@ -177,18 +188,18 @@ export default function TransactionsPage() {
|
||||
if (result.journal_entry_created) {
|
||||
toast({
|
||||
title: 'Bokförd',
|
||||
description: 'Transaktion kategoriserad och verifikation skapad',
|
||||
description: 'Transaktion bokförd och verifikation skapad',
|
||||
})
|
||||
} else if (result.journal_entry_error) {
|
||||
toast({
|
||||
title: 'Kategoriserad',
|
||||
description: `Bokföring misslyckades: ${result.journal_entry_error}`,
|
||||
title: 'Delvis bokförd',
|
||||
description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Kategoriserad',
|
||||
description: 'Transaktion uppdaterad men kunde inte bokföras',
|
||||
title: 'Delvis bokförd',
|
||||
description: 'Transaktion uppdaterad men verifikation kunde inte skapas',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,7 +207,7 @@ export default function TransactionsPage() {
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Något gick fel vid kategorisering',
|
||||
description: 'Något gick fel vid bokföring',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
@@ -357,6 +368,58 @@ export default function TransactionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBatchSelect(id: string) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function exitBatchMode() {
|
||||
setIsBatchMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
async function handleBatchMarkPrivate() {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchProgress({ done: 0, total: ids.length })
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await handleCategorize(ids[i], false, 'private')
|
||||
setBatchProgress({ done: i + 1, total: ids.length })
|
||||
}
|
||||
|
||||
setBatchProgress(null)
|
||||
toast({
|
||||
title: 'Klart',
|
||||
description: `${ids.length} transaktioner markerade som privat`,
|
||||
})
|
||||
exitBatchMode()
|
||||
}
|
||||
|
||||
async function handleBatchCategorize(category: TransactionCategory) {
|
||||
const ids = Array.from(selectedIds)
|
||||
setBatchProgress({ done: 0, total: ids.length })
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await handleCategorize(ids[i], true, category)
|
||||
setBatchProgress({ done: i + 1, total: ids.length })
|
||||
}
|
||||
|
||||
setBatchProgress(null)
|
||||
setShowBatchSelector(false)
|
||||
toast({
|
||||
title: 'Klart',
|
||||
description: `${ids.length} transaktioner bokförda`,
|
||||
})
|
||||
exitBatchMode()
|
||||
}
|
||||
|
||||
async function openSwipeView() {
|
||||
// Run batch invoice matching for income transactions first
|
||||
await runBatchInvoiceMatching()
|
||||
@@ -405,7 +468,7 @@ export default function TransactionsPage() {
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Transaktioner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera och kategorisera dina transaktioner
|
||||
Hantera och bokför dina transaktioner
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -416,10 +479,19 @@ export default function TransactionsPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
{uncategorizedTransactions.length > 0 && (
|
||||
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{isLoadingSuggestions ? 'Laddar...' : `Kategorisera (${uncategorizedTransactions.length})`}
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{isLoadingSuggestions ? 'Laddar...' : `Bokför (${uncategorizedTransactions.length})`}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isBatchMode ? 'default' : 'outline'}
|
||||
onClick={() => isBatchMode ? exitBatchMode() : setIsBatchMode(true)}
|
||||
>
|
||||
<CheckSquare className="mr-2 h-4 w-4" />
|
||||
{isBatchMode ? 'Avsluta' : 'Välj flera'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -453,7 +525,7 @@ export default function TransactionsPage() {
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alla</TabsTrigger>
|
||||
<TabsTrigger value="uncategorized">
|
||||
Ej kategoriserade
|
||||
Ej bokförda
|
||||
{uncategorizedTransactions.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{uncategorizedTransactions.length}
|
||||
@@ -474,6 +546,26 @@ export default function TransactionsPage() {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Feature discovery hint */}
|
||||
{!hideCategorizationHint && uncategorizedTransactions.length > 0 && (
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg border border-primary/20 bg-primary/[0.03]">
|
||||
<Sparkles className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground flex-1">
|
||||
<span className="font-medium text-foreground">Tips:</span> Klicka "Bokför" ovan för att snabbt bokföra transaktioner en i taget. Använd "Välj flera" för att hantera flera samtidigt.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.setItem('hideCategorizationHint', 'true')
|
||||
setHideCategorizationHint(true)
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Stäng tips"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
@@ -519,16 +611,31 @@ export default function TransactionsPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredTransactions.map((transaction) => (
|
||||
{filteredTransactions.map((transaction) => {
|
||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||
const isSelected = selectedIds.has(transaction.id)
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={transaction.id}
|
||||
className={`hover:border-primary/50 transition-colors ${
|
||||
transaction.is_business === null ? 'border-warning/50' : ''
|
||||
} ${transaction.potential_invoice && !transaction.invoice_id ? 'border-blue-500/50' : ''}`}
|
||||
isUncategorized ? 'border-warning/50' : ''
|
||||
} ${transaction.potential_invoice && !transaction.invoice_id ? 'border-blue-500/50' : ''} ${
|
||||
isSelected ? 'border-primary bg-primary/[0.02]' : ''
|
||||
}`}
|
||||
onClick={showCheckbox ? () => toggleBatchSelect(transaction.id) : undefined}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleBatchSelect(transaction.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`h-10 w-10 rounded-full flex items-center justify-center ${
|
||||
transaction.amount > 0
|
||||
@@ -546,7 +653,7 @@ export default function TransactionsPage() {
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(transaction.date)}</span>
|
||||
{transaction.is_business !== null && (
|
||||
{transaction.is_business !== null && !(transaction.is_business && transaction.category === 'uncategorized' && transaction.journal_entry_id) && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge
|
||||
@@ -567,7 +674,7 @@ export default function TransactionsPage() {
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.journal_entry_id && (
|
||||
{transaction.journal_entry_id ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
@@ -575,15 +682,14 @@ export default function TransactionsPage() {
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.is_business === null && !transaction.potential_invoice && (
|
||||
) : transaction.is_business === null && !transaction.potential_invoice ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-warning border-warning">
|
||||
Ej kategoriserad
|
||||
Ej bokförd
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
{transaction.potential_invoice && !transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
@@ -618,10 +724,48 @@ export default function TransactionsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch mode floating action bar */}
|
||||
{isBatchMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 bg-background border rounded-xl shadow-lg px-4 py-3">
|
||||
<Badge variant="secondary">{selectedIds.size} valda</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
Avmarkera
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBatchMarkPrivate}
|
||||
>
|
||||
Markera som privat
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowBatchSelector(true)}
|
||||
>
|
||||
Bokför {selectedIds.size} st
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Category Selector */}
|
||||
<BatchCategorySelector
|
||||
open={showBatchSelector}
|
||||
onOpenChange={setShowBatchSelector}
|
||||
selectedCount={selectedIds.size}
|
||||
onSelectCategory={handleBatchCategorize}
|
||||
progress={batchProgress}
|
||||
/>
|
||||
|
||||
{/* Invoice Match Confirmation Dialog */}
|
||||
<Dialog open={matchDialogOpen} onOpenChange={setMatchDialogOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -110,8 +110,12 @@ function OnboardingPageContent() {
|
||||
onboarding_step: nextStep || currentStep,
|
||||
}
|
||||
|
||||
// Remove read-only fields before updating
|
||||
const { id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, ...settingsToSave } = updatedSettings as Record<string, unknown>
|
||||
// Remove read-only and transient fields before updating
|
||||
const {
|
||||
id: _id, user_id: _uid, created_at: _ca, updated_at: _ua,
|
||||
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
|
||||
...settingsToSave
|
||||
} = updatedSettings as Record<string, unknown>
|
||||
|
||||
const { error } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -159,30 +163,70 @@ function OnboardingPageContent() {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
const startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1
|
||||
const currentYear = new Date().getFullYear()
|
||||
const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined
|
||||
const firstYearStart = stepData.first_year_start as string | undefined
|
||||
const firstYearEnd = stepData.first_year_end as string | undefined
|
||||
|
||||
const startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01`
|
||||
let endYear: number
|
||||
let endMonth: number
|
||||
if (startMonth === 1) {
|
||||
endYear = currentYear
|
||||
endMonth = 12
|
||||
let startStr: string
|
||||
let endStr: string
|
||||
let periodName: string
|
||||
|
||||
if (isFirstYear && firstYearStart && firstYearEnd) {
|
||||
// First fiscal year: use exact dates provided
|
||||
startStr = firstYearStart
|
||||
endStr = firstYearEnd
|
||||
|
||||
const startYear = new Date(firstYearStart).getFullYear()
|
||||
const endYear = new Date(firstYearEnd).getFullYear()
|
||||
periodName = startYear === endYear
|
||||
? `Första räkenskapsåret ${startYear}`
|
||||
: `Första räkenskapsåret ${startYear}/${endYear}`
|
||||
} else {
|
||||
endYear = currentYear + 1
|
||||
endMonth = startMonth - 1
|
||||
}
|
||||
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
||||
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||||
// Ongoing: compute 12-month period from fiscal_year_start_month
|
||||
let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1
|
||||
|
||||
await supabase.from('fiscal_periods').upsert({
|
||||
user_id: user.id,
|
||||
name: `Räkenskapsår ${currentYear}`,
|
||||
period_start: startStr,
|
||||
period_end: endStr,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
// For enskild firma: force calendar year
|
||||
if (settings.entity_type === 'enskild_firma') {
|
||||
startMonth = 1
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01`
|
||||
|
||||
let endYear: number
|
||||
let endMonth: number
|
||||
if (startMonth === 1) {
|
||||
endYear = currentYear
|
||||
endMonth = 12
|
||||
} else {
|
||||
endYear = currentYear + 1
|
||||
endMonth = startMonth - 1
|
||||
}
|
||||
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
||||
endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||||
|
||||
periodName = startMonth === 1
|
||||
? `Räkenskapsår ${currentYear}`
|
||||
: `Räkenskapsår ${currentYear}/${currentYear + 1}`
|
||||
}
|
||||
|
||||
// Validate period duration (max 18 months)
|
||||
const startDate = new Date(startStr)
|
||||
const endDate = new Date(endStr)
|
||||
const months = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
|
||||
(endDate.getMonth() - startDate.getMonth()) + 1
|
||||
if (months > 18) {
|
||||
console.error(`Period duration ${months} months exceeds 18-month maximum`)
|
||||
} else {
|
||||
await supabase.from('fiscal_periods').upsert({
|
||||
user_id: user.id,
|
||||
name: periodName,
|
||||
period_start: startStr,
|
||||
period_end: endStr,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create fiscal period:', err)
|
||||
@@ -292,7 +336,9 @@ function OnboardingPageContent() {
|
||||
vat_registered: settings.vat_registered ?? undefined,
|
||||
vat_number: settings.vat_number ?? undefined,
|
||||
moms_period: settings.moms_period as MomsPeriod | undefined,
|
||||
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
|
||||
}}
|
||||
entityType={settings.entity_type as EntityType}
|
||||
onNext={(data) => handleNext(data)}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
|
||||
@@ -1,6 +1,63 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const { number } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch the account to check if it's a system account
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, is_system_account')
|
||||
.eq('user_id', user.id)
|
||||
.eq('account_number', number)
|
||||
.single()
|
||||
|
||||
if (fetchError || !account) {
|
||||
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (account.is_system_account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Systemkonton kan inte tas bort' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if account is referenced in posted journal entries
|
||||
const { count } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_number', number)
|
||||
|
||||
if (count && count > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.delete()
|
||||
.eq('id', account.id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
@@ -15,14 +72,17 @@ export async function PUT(
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Build update object with only provided fields
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (body.account_name !== undefined) updates.account_name = body.account_name
|
||||
if (body.is_active !== undefined) updates.is_active = body.is_active
|
||||
if (body.description !== undefined) updates.description = body.description
|
||||
if (body.default_vat_code !== undefined) updates.default_vat_code = body.default_vat_code
|
||||
if (body.sru_code !== undefined) updates.sru_code = body.sru_code
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({
|
||||
account_name: body.account_name,
|
||||
is_active: body.is_active,
|
||||
description: body.description,
|
||||
default_vat_code: body.default_vat_code,
|
||||
})
|
||||
.update(updates)
|
||||
.eq('user_id', user.id)
|
||||
.eq('account_number', number)
|
||||
.select()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/accounts/activate
|
||||
*
|
||||
* Batch-activate BAS accounts for a user.
|
||||
* Accepts { account_numbers: string[] } and creates chart_of_accounts rows from reference data.
|
||||
* Skips any accounts that already exist for the user.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const accountNumbers: string[] = body.account_numbers
|
||||
|
||||
if (!Array.isArray(accountNumbers) || accountNumbers.length === 0) {
|
||||
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check which accounts already exist
|
||||
const { data: existing } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', user.id)
|
||||
.in('account_number', accountNumbers)
|
||||
|
||||
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
|
||||
|
||||
// Build rows for accounts that don't already exist
|
||||
const newAccounts = accountNumbers
|
||||
.filter((num) => !existingNumbers.has(num))
|
||||
.map((num) => {
|
||||
const ref = getBASReference(num)
|
||||
if (!ref) return null
|
||||
|
||||
return {
|
||||
user_id: user.id,
|
||||
account_number: ref.account_number,
|
||||
account_name: ref.account_name,
|
||||
account_class: ref.account_class,
|
||||
account_group: ref.account_group,
|
||||
account_type: ref.account_type,
|
||||
normal_balance: ref.normal_balance,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
description: ref.description,
|
||||
sru_code: ref.sru_code,
|
||||
sort_order: parseInt(ref.account_number),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
message: 'All accounts already activated',
|
||||
activated: 0,
|
||||
skipped: accountNumbers.length,
|
||||
})
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(newAccounts)
|
||||
.select()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
activated: data?.length || 0,
|
||||
skipped: accountNumbers.length - (data?.length || 0),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accounts/reference
|
||||
*
|
||||
* Returns the full BAS reference catalog merged with the user's activation status.
|
||||
* Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
|
||||
try {
|
||||
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, is_active, is_system_account')
|
||||
.eq('user_id', user.id)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Build lookup map
|
||||
const userAccountMap = new Map(
|
||||
userAccounts.map((a) => [a.account_number, a])
|
||||
)
|
||||
|
||||
// Merge reference with user status
|
||||
const merged = BAS_REFERENCE.map((ref) => {
|
||||
const userAccount = userAccountMap.get(ref.account_number)
|
||||
return {
|
||||
...ref,
|
||||
is_activated: !!userAccount,
|
||||
is_active: userAccount?.is_active ?? false,
|
||||
is_system_account: userAccount?.is_system_account ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
// Also identify custom accounts (in user's chart but not in BAS reference)
|
||||
const basNumbers = new Set(BAS_REFERENCE.map((r) => r.account_number))
|
||||
const customAccounts = userAccounts
|
||||
.filter((a) => !basNumbers.has(a.account_number))
|
||||
.map((a) => ({
|
||||
account_number: a.account_number,
|
||||
is_custom: true,
|
||||
is_activated: true,
|
||||
is_active: a.is_active,
|
||||
is_system_account: a.is_system_account,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ data: merged, customAccounts })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -13,27 +14,29 @@ export async function GET(request: Request) {
|
||||
const accountClass = searchParams.get('class')
|
||||
const activeOnly = searchParams.get('active') !== 'false'
|
||||
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('sort_order')
|
||||
try {
|
||||
const data = await fetchAllRows(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('sort_order')
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.eq('is_active', true)
|
||||
if (activeOnly) {
|
||||
query = query.eq('is_active', true)
|
||||
}
|
||||
|
||||
if (accountClass) {
|
||||
query = query.eq('account_class', parseInt(accountClass))
|
||||
}
|
||||
|
||||
return query.range(from, to)
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (accountClass) {
|
||||
query = query.eq('account_class', parseInt(accountClass))
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateFiscalPeriodInput } from '@/types'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -33,6 +34,12 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = await request.json() as CreateFiscalPeriodInput
|
||||
|
||||
// Validate period duration (max 18 months per BFL 3 kap.)
|
||||
const durationError = validatePeriodDuration(body.period_start, body.period_end)
|
||||
if (durationError) {
|
||||
return NextResponse.json({ error: durationError }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/documents/counts?journal_entry_ids=id1,id2,...
|
||||
* Returns attachment counts per journal entry ID.
|
||||
* Max 50 IDs per request.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const idsParam = searchParams.get('journal_entry_ids')
|
||||
|
||||
if (!idsParam) {
|
||||
return NextResponse.json({ error: 'journal_entry_ids is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const ids = idsParam.split(',').filter(Boolean)
|
||||
|
||||
if (ids.length === 0) {
|
||||
return NextResponse.json({ data: {} })
|
||||
}
|
||||
|
||||
if (ids.length > 50) {
|
||||
return NextResponse.json({ error: 'Maximum 50 IDs per request' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('journal_entry_id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_current_version', true)
|
||||
.in('journal_entry_id', ids)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Group and count by journal_entry_id
|
||||
const counts: Record<string, number> = {}
|
||||
for (const row of data || []) {
|
||||
if (row.journal_entry_id) {
|
||||
counts[row.journal_entry_id] = (counts[row.journal_entry_id] || 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: counts })
|
||||
}
|
||||
@@ -65,12 +65,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// WORM archive copy (non-blocking — receipt flow continues even if this fails)
|
||||
let wormDocumentId: string | null = null
|
||||
try {
|
||||
await uploadDocument(user.id, {
|
||||
const wormDoc = await uploadDocument(user.id, {
|
||||
name: imageFile.name,
|
||||
buffer: arrayBuffer,
|
||||
type: imageFile.type,
|
||||
}, { upload_source: 'camera' })
|
||||
wormDocumentId = wormDoc.id
|
||||
} catch (archiveErr) {
|
||||
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
|
||||
}
|
||||
@@ -86,6 +88,7 @@ export async function POST(request: Request) {
|
||||
user_id: user.id,
|
||||
image_url: imageUrl,
|
||||
status: 'processing',
|
||||
document_id: wormDocumentId,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -178,7 +181,7 @@ export async function POST(request: Request) {
|
||||
type: 'receipt.extracted',
|
||||
payload: {
|
||||
receipt: completeReceipt,
|
||||
documentId: null,
|
||||
documentId: wormDocumentId,
|
||||
confidence: extraction.confidence,
|
||||
userId: user.id,
|
||||
},
|
||||
|
||||
@@ -71,27 +71,8 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing accounts to avoid duplicates
|
||||
const { data: existingAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const existingNumbers = new Set(existingAccounts?.map(a => a.account_number) || [])
|
||||
|
||||
// Filter to only accounts that don't exist
|
||||
const newAccounts = accounts.filter(a => !existingNumbers.has(a.number))
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: 0,
|
||||
message: 'All accounts already exist'
|
||||
})
|
||||
}
|
||||
|
||||
// Prepare accounts for insertion
|
||||
const accountsToInsert = newAccounts.map(account => {
|
||||
// Prepare accounts for upsert (idempotent — safe to retry)
|
||||
const accountsToUpsert = accounts.map(account => {
|
||||
const accountClass = parseInt(account.number.charAt(0), 10) || 1
|
||||
const accountGroup = account.number.substring(0, 2)
|
||||
const accountType = getAccountType(account.number)
|
||||
@@ -112,26 +93,32 @@ export async function POST(request: Request) {
|
||||
}
|
||||
})
|
||||
|
||||
// Insert in batches of 100 to avoid timeout
|
||||
// Upsert in batches of 100 to avoid timeout
|
||||
// ignoreDuplicates skips rows that already exist (no update)
|
||||
const batchSize = 100
|
||||
let totalCreated = 0
|
||||
|
||||
for (let i = 0; i < accountsToInsert.length; i += batchSize) {
|
||||
const batch = accountsToInsert.slice(i, i + batchSize)
|
||||
for (let i = 0; i < accountsToUpsert.length; i += batchSize) {
|
||||
const batch = accountsToUpsert.slice(i, i + batchSize)
|
||||
|
||||
const { error } = await supabase
|
||||
const { data: upserted, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(batch)
|
||||
.upsert(batch, {
|
||||
onConflict: 'user_id,account_number',
|
||||
ignoreDuplicates: true,
|
||||
count: 'exact',
|
||||
})
|
||||
.select('account_number')
|
||||
|
||||
if (error) {
|
||||
console.error('Error inserting accounts batch:', error)
|
||||
console.error('Error upserting accounts batch:', error)
|
||||
return NextResponse.json({
|
||||
error: `Failed to create accounts: ${error.message}`,
|
||||
created: totalCreated,
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
totalCreated += batch.length
|
||||
totalCreated += upserted?.length ?? batch.length
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings } from '@/lib/import/account-mapper'
|
||||
@@ -53,15 +54,18 @@ export async function POST(request: Request) {
|
||||
if (mappingsJson) {
|
||||
mappings = JSON.parse(mappingsJson)
|
||||
} else {
|
||||
// Fetch user's chart of accounts and generate mappings
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
|
||||
const basAccounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
if (basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
parseSIEFile,
|
||||
@@ -77,15 +78,18 @@ export async function POST(request: Request) {
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch user's chart of accounts
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
|
||||
const basAccounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
if (basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/convert
|
||||
*
|
||||
* Converts a proforma invoice to a real invoice.
|
||||
* Copies all data, generates a real invoice number, and marks the proforma as cancelled.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch proforma with items
|
||||
const { data: proforma, error: proformaError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (proformaError || !proforma) {
|
||||
return NextResponse.json({ error: 'Proformafakturan hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (proforma.document_type !== 'proforma') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endast proformafakturor kan konverteras' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (proforma.status === 'cancelled') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Denna proformafaktura har redan makuleras' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate real invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
|
||||
// Create the real invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
customer_id: proforma.customer_id,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: proforma.due_date,
|
||||
currency: proforma.currency,
|
||||
exchange_rate: proforma.exchange_rate,
|
||||
exchange_rate_date: proforma.exchange_rate_date,
|
||||
subtotal: proforma.subtotal,
|
||||
subtotal_sek: proforma.subtotal_sek,
|
||||
vat_amount: proforma.vat_amount,
|
||||
vat_amount_sek: proforma.vat_amount_sek,
|
||||
total: proforma.total,
|
||||
total_sek: proforma.total_sek,
|
||||
vat_treatment: proforma.vat_treatment,
|
||||
vat_rate: proforma.vat_rate,
|
||||
moms_ruta: proforma.moms_ruta,
|
||||
reverse_charge_text: proforma.reverse_charge_text,
|
||||
your_reference: proforma.your_reference,
|
||||
our_reference: proforma.our_reference,
|
||||
notes: proforma.notes,
|
||||
document_type: 'invoice',
|
||||
converted_from_id: id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError) {
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Copy invoice items
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.line_total,
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(items)
|
||||
|
||||
if (itemsError) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Mark proforma as cancelled
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', id)
|
||||
|
||||
// Fetch complete invoice
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
if (completeInvoice) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeInvoice })
|
||||
}
|
||||
@@ -77,29 +77,33 @@ export async function POST(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry on mark-paid:', err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry on mark-paid:', err)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -45,10 +45,7 @@ export async function POST(
|
||||
// Update status to sent
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
@@ -65,9 +62,10 @@ export async function POST(
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Faktureringsmetoden: book at send
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
|
||||
@@ -113,11 +113,19 @@ export async function POST(
|
||||
company: company as CompanySettings
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
// Determine filename based on document type
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
let filename: string
|
||||
if (isCreditNote) {
|
||||
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
} else if (docType === 'proforma') {
|
||||
filename = `proformafaktura-${invoice.invoice_number}.pdf`
|
||||
} else if (docType === 'delivery_note') {
|
||||
filename = `foljesedel-${invoice.invoice_number}.pdf`
|
||||
} else {
|
||||
filename = `faktura-${invoice.invoice_number}.pdf`
|
||||
}
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
@@ -144,13 +152,10 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Update invoice status to "sent" and set sent_at timestamp
|
||||
// Update invoice status to "sent"
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString()
|
||||
})
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
@@ -159,8 +164,9 @@ export async function POST(
|
||||
// Don't fail the request - the email was sent successfully
|
||||
}
|
||||
|
||||
// Faktureringsmetoden: create journal entry when invoice is issued
|
||||
if ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method) {
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
if (isRealInvoice && ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method)) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import { getVatRules } from '@/lib/invoice/vat-rules'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/preview-pdf
|
||||
*
|
||||
* Generates a preview PDF from form data without creating an invoice.
|
||||
* Returns the PDF as an inline blob for display in a new browser tab.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
|
||||
|
||||
if (!customer_id || !items || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Kunduppgifter och rader krävs' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch customer
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
|
||||
const docType: InvoiceDocumentType = document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
|
||||
// Build items with line totals
|
||||
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({
|
||||
id: `preview-${index}`,
|
||||
invoice_id: 'preview',
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: Math.round(item.quantity * item.unit_price * 100) / 100,
|
||||
vat_rate: item.vat_rate ?? vatRules.rate,
|
||||
vat_amount: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
|
||||
const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100
|
||||
const total = isDeliveryNote ? 0 : subtotal + vatAmount
|
||||
|
||||
// Construct a temporary Invoice-like object
|
||||
const previewInvoice = {
|
||||
id: 'preview',
|
||||
user_id: user.id,
|
||||
customer_id,
|
||||
invoice_number: 'FÖRHANDSGRANSKNING',
|
||||
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
|
||||
due_date: due_date || new Date().toISOString().split('T')[0],
|
||||
status: 'draft',
|
||||
currency: currency || 'SEK',
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
subtotal: isDeliveryNote ? 0 : subtotal,
|
||||
subtotal_sek: null,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: null,
|
||||
total,
|
||||
total_sek: null,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isDeliveryNote ? 0 : vatRules.rate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
your_reference: your_reference || null,
|
||||
our_reference: our_reference || null,
|
||||
notes: notes || null,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
credited_invoice_id: null,
|
||||
document_type: docType,
|
||||
converted_from_id: null,
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as Invoice
|
||||
|
||||
try {
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: previewInvoice,
|
||||
customer: customer as Customer,
|
||||
items: invoiceItems,
|
||||
company: company as CompanySettings,
|
||||
})
|
||||
)
|
||||
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'inline; filename="forhandsvisning.pdf"',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Preview PDF generation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+77
-30
@@ -2,8 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateInvoiceInput, EntityType, Invoice, CreditNote } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoices/vat-rules'
|
||||
import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
createCreditNoteJournalEntry,
|
||||
@@ -67,6 +67,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const invoiceInput = body as CreateInvoiceInput
|
||||
const documentType: InvoiceDocumentType = body.document_type || 'invoice'
|
||||
|
||||
// Get customer for VAT calculation
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
@@ -80,16 +81,37 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate VAT rules
|
||||
// Calculate VAT rules (default for customer)
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
|
||||
const allowedRates = new Set(availableRates.map((r) => r.rate))
|
||||
|
||||
// Calculate subtotal from items
|
||||
// Calculate per-item VAT and subtotals
|
||||
const subtotal = invoiceInput.items.reduce((sum, item) => {
|
||||
return sum + item.quantity * item.unit_price
|
||||
}, 0)
|
||||
|
||||
const vatAmount = calculateVat(subtotal, vatRules.rate)
|
||||
const total = subtotal + vatAmount
|
||||
// Calculate VAT per item, respecting per-line vat_rate
|
||||
let vatAmount = 0
|
||||
if (documentType !== 'delivery_note') {
|
||||
for (const item of invoiceInput.items) {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
// Validate rate is allowed for this customer
|
||||
if (!allowedRates.has(itemRate)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
}
|
||||
}
|
||||
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
|
||||
|
||||
// Determine if this is a mixed-rate invoice
|
||||
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
|
||||
const isMixedRate = uniqueRates.size > 1
|
||||
|
||||
// Handle currency conversion
|
||||
let exchangeRate: number | null = null
|
||||
@@ -109,10 +131,15 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
// Generate invoice number with appropriate prefix
|
||||
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
const invoiceNumber = documentType === 'proforma'
|
||||
? `PF-${baseNumber}`
|
||||
: documentType === 'delivery_note'
|
||||
? `FS-${baseNumber}`
|
||||
: baseNumber
|
||||
|
||||
// Create invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
@@ -126,19 +153,20 @@ export async function POST(request: Request) {
|
||||
currency: invoiceInput.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
|
||||
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
total_sek: documentType === 'delivery_note' ? null : totalSek,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: vatRules.rate,
|
||||
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
your_reference: invoiceInput.your_reference,
|
||||
our_reference: invoiceInput.our_reference,
|
||||
notes: invoiceInput.notes,
|
||||
document_type: documentType,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
@@ -147,16 +175,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const items = invoiceInput.items.map((item, index) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.quantity * item.unit_price,
|
||||
}))
|
||||
// Create invoice items with per-line VAT
|
||||
const items = invoiceInput.items.map((item, index) => {
|
||||
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
|
||||
return {
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
vat_rate: itemRate,
|
||||
vat_amount: itemVat,
|
||||
}
|
||||
})
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
@@ -175,8 +210,8 @@ export async function POST(request: Request) {
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Emit event (no journal entry at draft — booking happens at send/payment based on accounting method)
|
||||
if (completeInvoice) {
|
||||
// Emit event only for real invoices (proformas and delivery notes are informational)
|
||||
if (completeInvoice && documentType === 'invoice') {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
||||
@@ -204,6 +239,14 @@ async function createCreditNote(
|
||||
return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Credit notes can only be created from real invoices
|
||||
if (originalInvoice.document_type && originalInvoice.document_type !== 'invoice') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Credit notes can only be created from standard invoices' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if invoice is already credited
|
||||
if (originalInvoice.status === 'credited') {
|
||||
return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 })
|
||||
@@ -258,8 +301,8 @@ async function createCreditNote(
|
||||
return NextResponse.json({ error: creditNoteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create credit note items (negated from original)
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }, index: number) => ({
|
||||
// Create credit note items (negated from original, preserving per-line VAT)
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number }) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
@@ -267,6 +310,8 @@ async function createCreditNote(
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
vat_rate: item.vat_rate ?? 25,
|
||||
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
@@ -292,17 +337,19 @@ async function createCreditNote(
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
// Fetch entity type for correct account mapping
|
||||
// Fetch entity type and accounting method for correct account mapping
|
||||
const { data: creditNoteSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.select('entity_type, accounting_method')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
// Create journal entry for the credit note (non-blocking)
|
||||
if (completeCreditNote) {
|
||||
// Cash method: skip — no original invoice entry exists to reverse; deferred until refund
|
||||
if (completeCreditNote && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
userId,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id, journal_entry_id } = body
|
||||
|
||||
if (!transaction_id || !journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'transaction_id and journal_entry_id are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { date_from, date_to, dry_run } = body
|
||||
|
||||
const result = await runReconciliation(supabase, user.id, {
|
||||
dateFrom: date_from,
|
||||
dateTo: date_to,
|
||||
dryRun: dry_run ?? false,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
matches: result.matches.map((m) => ({
|
||||
transaction_id: m.transaction.id,
|
||||
transaction_date: m.transaction.date,
|
||||
transaction_description: m.transaction.description,
|
||||
transaction_amount: m.transaction.amount,
|
||||
journal_entry_id: m.glLine.journal_entry_id,
|
||||
voucher_number: m.glLine.voucher_number,
|
||||
voucher_series: m.glLine.voucher_series,
|
||||
entry_date: m.glLine.entry_date,
|
||||
entry_description: m.glLine.entry_description,
|
||||
method: m.method,
|
||||
confidence: m.confidence,
|
||||
})),
|
||||
applied: result.applied,
|
||||
errors: result.errors,
|
||||
dry_run: dry_run ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const dateFrom = searchParams.get('date_from') || undefined
|
||||
const dateTo = searchParams.get('date_to') || undefined
|
||||
|
||||
const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo)
|
||||
|
||||
return NextResponse.json({ data: status })
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id } = body
|
||||
|
||||
if (!transaction_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'transaction_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const dateFrom = searchParams.get('date_from') || undefined
|
||||
const dateTo = searchParams.get('date_to') || undefined
|
||||
|
||||
const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo)
|
||||
|
||||
return NextResponse.json({ data: lines })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await generateMonthlyBreakdown(user.id, periodId)
|
||||
return NextResponse.json({ data })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
calculateVatDeclaration,
|
||||
formatPeriodLabel,
|
||||
} from '@/lib/reports/vat-declaration'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import type { VatPeriodType, AccountingMethod } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/reports/vat-declaration
|
||||
@@ -90,12 +90,22 @@ export async function GET(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
try {
|
||||
const declaration = await calculateVatDeclaration(
|
||||
user.id,
|
||||
periodType,
|
||||
year,
|
||||
period
|
||||
period,
|
||||
accountingMethod
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -42,6 +42,16 @@ export async function PUT(request: Request) {
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Validate: enskild firma must use calendar year (BFL 3 kap.)
|
||||
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
|
||||
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
|
||||
if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.update(body)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
@@ -88,24 +88,36 @@ export async function POST(
|
||||
|
||||
await supabase.from('supplier_invoice_items').insert(creditItems)
|
||||
|
||||
// Create credit note journal entry
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
// Create credit note journal entry (accrual only)
|
||||
// Cash method: skip — no original registration entry exists to reverse; deferred until refund
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
const journalEntry = await createSupplierCreditNoteEntry(
|
||||
user.id,
|
||||
creditNote as SupplierInvoice,
|
||||
creditItems as SupplierInvoiceItem[],
|
||||
original.supplier?.supplier_type || 'swedish_business'
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierCreditNoteEntry(
|
||||
user.id,
|
||||
creditNote as SupplierInvoice,
|
||||
creditItems as SupplierInvoiceItem[],
|
||||
original.supplier?.supplier_type || 'swedish_business'
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create credit note journal entry:', err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create credit note journal entry:', err)
|
||||
}
|
||||
|
||||
// Update original invoice: reduce remaining_amount
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
@@ -69,17 +68,19 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate totals from items
|
||||
// Calculate totals from items (supports both amount-based and legacy quantity*price)
|
||||
const items = body.items.map((item, index) => {
|
||||
const vatRate = item.vat_rate ?? 0.25
|
||||
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
|
||||
const lineTotal = item.amount != null
|
||||
? Math.round(item.amount * 100) / 100
|
||||
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||||
return {
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || 'st',
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.amount != null ? 1 : (item.quantity ?? 1),
|
||||
unit: item.amount != null ? 'st' : (item.unit || 'st'),
|
||||
unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0),
|
||||
line_total: lineTotal,
|
||||
account_number: item.account_number,
|
||||
vat_code: item.vat_code || null,
|
||||
|
||||
@@ -32,9 +32,9 @@ async function ensureFiscalPeriod(
|
||||
.lte('period_start', date)
|
||||
.gte('period_end', date)
|
||||
.eq('is_closed', false)
|
||||
.single()
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
if (existing && existing.length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -209,6 +209,27 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// Link receipt document to journal entry if both exist
|
||||
if (journalEntryId && transaction.receipt_id) {
|
||||
try {
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('document_id')
|
||||
.eq('id', transaction.receipt_id)
|
||||
.single()
|
||||
|
||||
if (receipt?.document_id) {
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', receipt.document_id)
|
||||
.eq('user_id', user.id)
|
||||
}
|
||||
} catch (linkErr) {
|
||||
console.error('[categorize] Failed to link receipt document:', linkErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the transaction
|
||||
const { error: updateError } = await supabase
|
||||
.from('transactions')
|
||||
|
||||
@@ -1,58 +1,15 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
getRevenueAccount,
|
||||
getOutputVatAccount,
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Transaction, Invoice, CreateJournalEntryInput, EntityType, VatTreatment } from '@/types'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
interface MatchInvoiceRequest {
|
||||
invoice_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date, create one if needed
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
date: string
|
||||
): Promise<string | null> {
|
||||
// Check if a fiscal period already covers this date
|
||||
const existingPeriodId = await findFiscalPeriod(userId, date)
|
||||
if (existingPeriodId) {
|
||||
return existingPeriodId
|
||||
}
|
||||
|
||||
// No fiscal period exists - create one for the year of the transaction
|
||||
const transactionDate = new Date(date)
|
||||
const year = transactionDate.getFullYear()
|
||||
|
||||
const periodStart = `${year}-01-01`
|
||||
const periodEnd = `${year}-12-31`
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
name: `Räkenskapsår ${year}`,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create fiscal period:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data?.id || null
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-invoice
|
||||
*
|
||||
@@ -113,10 +70,10 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the invoice (validates ownership)
|
||||
// Fetch the invoice with items (validates ownership, items needed for per-line VAT)
|
||||
const { data: invoice, error: fetchInvError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
@@ -151,76 +108,23 @@ export async function POST(
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
try {
|
||||
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
|
||||
|
||||
if (fiscalPeriodId) {
|
||||
let journalInput: CreateJournalEntryInput
|
||||
|
||||
if (accountingMethod === 'cash') {
|
||||
// Kontantmetoden: combined revenue entry at payment
|
||||
// Debit 1930 Företagskonto, Credit 30xx Försäljning, Credit 26xx Utgående moms
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment, entityType)
|
||||
const lines: CreateJournalEntryInput['lines'] = [
|
||||
{
|
||||
account_number: '1930',
|
||||
debit_amount: invoice.total,
|
||||
credit_amount: 0,
|
||||
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
{
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.subtotal,
|
||||
line_description: `Försäljning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
]
|
||||
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatAccount = getOutputVatAccount(invoice.vat_treatment as VatTreatment)
|
||||
lines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.vat_amount,
|
||||
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
}
|
||||
|
||||
journalInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
|
||||
source_type: 'invoice_cash_payment',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
}
|
||||
} else {
|
||||
// Faktureringsmetoden: clear receivable
|
||||
// Debit 1930 Företagskonto, Credit 1510 Kundfordringar
|
||||
journalInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines: [
|
||||
{
|
||||
account_number: '1930',
|
||||
debit_amount: paidAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: paidAmount,
|
||||
line_description: `Faktura ${invoice.invoice_number} betald`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const journalEntry = await createJournalEntry(user.id, journalInput)
|
||||
journalEntryId = journalEntry.id
|
||||
if (accountingMethod === 'cash') {
|
||||
// Kontantmetoden: combined revenue entry with per-line VAT rates
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
transaction.date,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
transaction.date
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry:', err)
|
||||
|
||||
@@ -293,6 +293,15 @@ h1, h2, h3 {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
|
||||
Reference in New Issue
Block a user