refactor: clean up codebase, remove dead code and obsolete docs

Remove influencer-era documentation, unused components, boilerplate
assets, and ghost tiktok cron job. Add supplier invoice management,
document API routes, and PWA icons. Replace boilerplate README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-20 12:23:17 +01:00
co-authored by Claude Opus 4.6
parent e8743e6e03
commit 838dc6b8b5
113 changed files with 6289 additions and 19036 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ export default function CustomersPage() {
</p>
</div>
) : (
<EmptyCustomers />
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
)}
</CardContent>
</Card>
+33 -17
View File
@@ -147,29 +147,45 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setIsUpdating(true)
const updates: Partial<Invoice> = { status }
if (status === 'paid') {
updates.paid_at = new Date().toISOString()
updates.paid_amount = invoice.total
}
try {
if (status === 'sent') {
// Use mark-sent API for proper bookkeeping
const response = await fetch(`/api/invoices/${invoice.id}/mark-sent`, {
method: 'POST',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte markera som skickad')
}
} else if (status === 'paid') {
// Use mark-paid API for proper bookkeeping
const response = await fetch(`/api/invoices/${invoice.id}/mark-paid`, {
method: 'POST',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte markera som betald')
}
} else {
// Other status changes (cancelled) — direct update is fine
const { error } = await supabase
.from('invoices')
.update({ status })
.eq('id', invoice.id)
if (error) throw new Error(error.message)
}
const { error } = await supabase
.from('invoices')
.update(updates)
.eq('id', invoice.id)
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
} else {
toast({
title: 'Uppdaterad',
description: `Fakturan är nu markerad som ${statusConfig[status].label.toLowerCase()}`,
})
fetchInvoice()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
variant: 'destructive',
})
}
setIsUpdating(false)
+209 -1
View File
@@ -6,7 +6,7 @@ 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 } from 'lucide-react'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2 } from 'lucide-react'
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
import type {
@@ -124,6 +124,10 @@ export default function ReportsPage() {
<FileDown className="h-4 w-4 mr-1" />
SRU-export
</TabsTrigger>
<TabsTrigger value="supplier-ledger">
<Building2 className="h-4 w-4 mr-1" />
Lev.reskontra
</TabsTrigger>
</TabsList>
<TabsContent value="trial-balance">
@@ -146,6 +150,9 @@ export default function ReportsPage() {
<TabsContent value="sru-export">
<SRUExportView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="supplier-ledger">
<SupplierLedgerView periodId={selectedPeriod} />
</TabsContent>
</Tabs>
) : (
<Card>
@@ -898,3 +905,204 @@ function VatRutaRow({
</>
)
}
interface SupplierLedgerData {
ledger: {
entries: {
supplier_id: string
supplier_name: string
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}[]
total_outstanding: number
total_current: number
total_overdue: number
unpaid_count: number
}
reconciliation: {
supplier_ledger_total: number
account_2440_balance: number
difference: number
is_reconciled: boolean
} | null
}
function SupplierLedgerView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SupplierLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta leverantörsreskontra')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (periodId) fetchData()
}, [periodId])
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar leverantörsreskontra...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || !data.ledger) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Ingen data tillgänglig.
</CardContent>
</Card>
)
}
const { ledger, reconciliation } = data
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-green-600">{formatAmount(ledger.total_current)} kr</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-red-600">{formatAmount(ledger.total_overdue)} kr</p>
</CardContent>
</Card>
</div>
{/* Aging table */}
{ledger.entries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ålderfördelning per leverantör</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Leverantör</th>
<th className="py-2 text-right">Ej förfallet</th>
<th className="py-2 text-right">1-30 dagar</th>
<th className="py-2 text-right">31-60 dagar</th>
<th className="py-2 text-right">61-90 dagar</th>
<th className="py-2 text-right">90+ dagar</th>
<th className="py-2 text-right font-semibold">Totalt</th>
</tr>
</thead>
<tbody>
{ledger.entries.map((entry) => (
<tr key={entry.supplier_id} className="border-b last:border-0">
<td className="py-2">{entry.supplier_name}</td>
<td className="py-2 text-right">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
<td className="py-2 text-right text-red-600">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td className="py-2">Summa</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
<td className="py-2 text-right text-red-600">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
)}
{/* Reconciliation */}
{reconciliation && (
<Card className="border-2">
<CardHeader>
<CardTitle>Avstämning mot konto 2440</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Leverantörsreskontra (summa utestående)</span>
<span className="font-mono">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
</div>
<div className="flex justify-between">
<span>Konto 2440 saldo (huvudbok)</span>
<span className="font-mono">{formatAmount(reconciliation.account_2440_balance)} kr</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={reconciliation.is_reconciled ? 'text-green-600' : 'text-red-600'}>
{formatAmount(reconciliation.difference)} kr
</span>
</div>
<div className="pt-2">
{reconciliation.is_reconciled ? (
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}
+75 -2
View File
@@ -115,6 +115,10 @@ export default function SettingsPage() {
clearing_number: formData.get('clearing_number') as string,
account_number: formData.get('account_number') as string,
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
invoice_prefix: formData.get('invoice_prefix') as string || null,
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',
}
try {
@@ -364,6 +368,75 @@ export default function SettingsPage() {
</div>
</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>
<div className="pt-4 border-t">
<h3 className="font-medium mb-4">Skatteinställningar</h3>
<div className="space-y-2">
@@ -406,8 +479,8 @@ export default function SettingsPage() {
</CardHeader>
<CardContent className="space-y-4">
{activeConnections.map((connection) => {
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires_at)
const isExpiring = isConsentExpiringSoon(connection.consent_expires_at)
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
return (
<div
@@ -0,0 +1,444 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
import Link from 'next/link'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const statusColors: Record<string, string> = {
registered: 'bg-blue-100 text-blue-800',
approved: 'bg-yellow-100 text-yellow-800',
paid: 'bg-green-100 text-green-800',
partially_paid: 'bg-orange-100 text-orange-800',
overdue: 'bg-red-100 text-red-800',
disputed: 'bg-purple-100 text-purple-800',
credited: 'bg-gray-100 text-gray-800',
}
const statusLabels: Record<string, string> = {
registered: 'Registrerad',
approved: 'Godkänd',
paid: 'Betald',
partially_paid: 'Delbetald',
overdue: 'Förfallen',
disputed: 'Tvist',
credited: 'Krediterad',
}
export default function SupplierInvoiceDetailPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
useEffect(() => {
fetchInvoice()
}, [params.id])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Fel', description: error, variant: 'destructive' })
} else {
setInvoice(data)
setPayAmount(String(data.remaining_amount))
}
setIsLoading(false)
}
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Godkänd', description: 'Fakturan har godkänts' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleMarkPaid() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: parseFloat(payAmount) }),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
description: `${formatAmount(parseFloat(payAmount))} kr registrerat`,
})
setIsPayDialogOpen(false)
fetchInvoice()
}
setIsProcessing(false)
}
async function handleCredit() {
if (!confirm('Vill du registrera en kreditfaktura för denna faktura?')) return
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Kreditfaktura registrerad' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleDelete() {
if (!confirm('Vill du ta bort denna faktura?')) return
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Borttagen' })
router.push('/supplier-invoices')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<Card className="animate-pulse"><CardContent className="h-48" /></Card>
</div>
)
}
if (!invoice) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Fakturan hittades inte</p>
<Button variant="outline" className="mt-4" onClick={() => router.push('/supplier-invoices')}>
Tillbaka
</Button>
</div>
)
}
const items = (invoice.items || []) as SupplierInvoiceItem[]
const payments = (invoice.payments || []) as SupplierInvoicePayment[]
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
Ankomst #{invoice.arrival_number}
</h1>
<Badge className={statusColors[invoice.status] || ''}>
{statusLabels[invoice.status] || invoice.status}
</Badge>
</div>
<p className="text-muted-foreground">
{invoice.supplier?.name} | Faktura {invoice.supplier_invoice_number}
</p>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
{invoice.status === 'registered' && (
<>
<Button onClick={handleApprove} disabled={isProcessing}>
<CheckCircle className="mr-2 h-4 w-4" />
Godkänn
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete} disabled={isProcessing}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
{['approved', 'overdue', 'partially_paid'].includes(invoice.status) && (
<>
<Button onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
Markera betald
</Button>
{invoice.status !== 'partially_paid' && (
<Button variant="outline" onClick={handleCredit} disabled={isProcessing}>
<FileText className="mr-2 h-4 w-4" />
Kreditfaktura
</Button>
)}
</>
)}
</div>
</div>
{/* Invoice details */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Fakturainformation</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Ankomstnummer</span>
<span className="font-mono">{invoice.arrival_number}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturanummer</span>
<span>{invoice.supplier_invoice_number}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturadatum</span>
<span>{invoice.invoice_date}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Förfallodatum</span>
<span>{invoice.due_date}</span>
</div>
{invoice.delivery_date && (
<div className="flex justify-between">
<span className="text-muted-foreground">Leveransdatum</span>
<span>{invoice.delivery_date}</span>
</div>
)}
{invoice.payment_reference && (
<div className="flex justify-between">
<span className="text-muted-foreground">OCR/referens</span>
<span className="font-mono">{invoice.payment_reference}</span>
</div>
)}
{invoice.reverse_charge && (
<div className="mt-2">
<Badge className="bg-purple-100 text-purple-800">Omvänd skattskyldighet</Badge>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Belopp</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-bold text-base pt-2 border-t">
<span>Totalt</span>
<span className="font-mono">{formatAmount(invoice.total)} {invoice.currency}</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-muted-foreground">Betalt</span>
<span className="font-mono text-green-600">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Kvar att betala</span>
<span className="font-mono">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
</div>
</CardContent>
</Card>
</div>
{/* Supplier info */}
{invoice.supplier && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Leverantör</CardTitle>
</CardHeader>
<CardContent className="text-sm">
<Link href={`/suppliers/${invoice.supplier.id}`} className="text-primary hover:underline font-medium">
{invoice.supplier.name}
</Link>
<div className="text-muted-foreground mt-1">
{invoice.supplier.org_number && <span>Org.nr: {invoice.supplier.org_number} | </span>}
{invoice.supplier.email}
</div>
</CardContent>
</Card>
)}
{/* Line items */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Rader</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-16 text-right">Antal</th>
<th className="pb-2 w-16">Enhet</th>
<th className="pb-2 w-28 text-right">À-pris</th>
<th className="pb-2 w-20">Konto</th>
<th className="pb-2 w-16 text-right">Moms%</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2">{item.unit}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.unit_price)}</td>
<td className="py-2 font-mono">{item.account_number}</td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
{/* Payment history */}
{payments.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Betalningshistorik</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Datum</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2">Verifikation</th>
<th className="pb-2">Anteckning</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.payment_date}</td>
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
</td>
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{/* Journal entries (sambandskrav) */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Verifikationer (sambandskrav)</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{invoice.registration_journal_entry_id ? (
<div className="flex justify-between">
<span className="text-muted-foreground">Registreringsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.registration_journal_entry_id.substring(0, 8)}...
</Link>
</div>
) : (
<p className="text-muted-foreground">Ingen registreringsverifikation (kontantmetoden)</p>
)}
{invoice.payment_journal_entry_id && (
<div className="flex justify-between">
<span className="text-muted-foreground">Betalningsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.payment_journal_entry_id.substring(0, 8)}...
</Link>
</div>
)}
</CardContent>
</Card>
{/* Notes */}
{invoice.notes && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Anteckningar</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{invoice.notes}</p>
</CardContent>
</Card>
)}
{/* Pay Dialog */}
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Markera som betald</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Belopp att betala</Label>
<Input
type="number"
step="0.01"
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Kvar att betala: {formatAmount(invoice.remaining_amount)} {invoice.currency}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
Avbryt
</Button>
<Button onClick={handleMarkPaid} disabled={isProcessing}>
{isProcessing ? 'Bearbetar...' : 'Registrera betalning'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,505 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller, useFieldArray } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
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 type { Supplier, VatTreatment } from '@/types'
interface LineItem {
description: string
quantity: number
unit: string
unit_price: number
account_number: string
vat_rate: number
}
interface FormData {
supplier_id: string
supplier_invoice_number: string
invoice_date: string
due_date: string
delivery_date: string
currency: string
exchange_rate: string
vat_treatment: VatTreatment
reverse_charge: boolean
payment_reference: string
notes: string
items: LineItem[]
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function NewSupplierInvoicePage() {
const router = useRouter()
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const { register, control, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>({
defaultValues: {
supplier_id: '',
supplier_invoice_number: '',
invoice_date: new Date().toISOString().split('T')[0],
due_date: '',
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,
account_number: '5010',
vat_rate: 0.25,
},
],
},
})
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
useEffect(() => {
fetchSuppliers()
}, [])
// Auto-fill due date when supplier is selected
useEffect(() => {
if (watchedSupplierId) {
const supplier = suppliers.find((s) => s.id === watchedSupplierId)
if (supplier) {
const invoiceDate = watch('invoice_date')
if (invoiceDate) {
const due = new Date(invoiceDate)
due.setDate(due.getDate() + supplier.default_payment_terms)
setValue('due_date', due.toISOString().split('T')[0])
}
if (supplier.default_expense_account && fields.length > 0) {
setValue('items.0.account_number', supplier.default_expense_account)
}
if (supplier.default_currency) {
setValue('currency', supplier.default_currency)
}
if (supplier.supplier_type === 'eu_business') {
setValue('reverse_charge', true)
setValue('vat_treatment', 'reverse_charge')
}
}
}
}, [watchedSupplierId, suppliers])
async function fetchSuppliers() {
const res = await fetch('/api/suppliers')
const { data } = await res.json()
setSuppliers(data || [])
}
// Calculate totals
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 100) / 100
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
return { lineTotal, vatAmount }
})
const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0)
const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0)
const total = Math.round((subtotal + totalVat) * 100) / 100
async function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' })
return
}
if (!data.supplier_invoice_number) {
toast({ title: 'Fel', description: 'Ange fakturanummer', variant: 'destructive' })
return
}
setIsSubmitting(true)
const payload = {
supplier_id: data.supplier_id,
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: data.due_date,
delivery_date: data.delivery_date || undefined,
currency: data.currency,
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
vat_treatment: data.vat_treatment,
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
items: data.items.map((item) => ({
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
account_number: item.account_number,
vat_rate: item.vat_rate,
})),
}
const res = await fetch('/api/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte registrera faktura', variant: 'destructive' })
} else {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
router.push(`/supplier-invoices/${result.data.id}`)
}
setIsSubmitting(false)
}
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Registrera leverantörsfaktura</h1>
<p className="text-muted-foreground">
Registrera en inkommande faktura (uppfyller BFL verifikationskrav)
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Supplier & Reference */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Leverantör & referens</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Leverantör *</Label>
<Controller
name="supplier_id"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Välj leverantör" />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Leverantörens fakturanummer *</Label>
<Input
placeholder="Fakturanr från leverantören"
{...register('supplier_invoice_number')}
/>
</div>
</div>
<div className="space-y-2">
<Label>OCR / Betalningsreferens</Label>
<Input
placeholder="OCR-nummer"
{...register('payment_reference')}
/>
</div>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Datum</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Fakturadatum *</Label>
<Input type="date" {...register('invoice_date')} />
</div>
<div className="space-y-2">
<Label>Förfallodatum *</Label>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>Leveransdatum (ML krav)</Label>
<Input type="date" {...register('delivery_date')} />
</div>
</div>
</CardContent>
</Card>
{/* Currency & VAT */}
<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="space-y-2">
<Label>Valuta</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SEK">SEK</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="NOK">NOK</SelectItem>
<SelectItem value="DKK">DKK</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{watchedCurrency !== 'SEK' && (
<div className="space-y-2">
<Label>Växelkurs</Label>
<Input
type="number"
step="0.0001"
placeholder="1.0000"
{...register('exchange_rate')}
/>
</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
name="reverse_charge"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<Label>Omvänd skattskyldighet (reverse charge)</Label>
</div>
</CardContent>
</Card>
{/* Line Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Rader</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({
description: '',
quantity: 1,
unit: 'st',
unit_price: 0,
account_number: '5010',
vat_rate: 0.25,
})
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
</Button>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<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-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">
<td className="py-2 pr-2">
<Input
placeholder="Beskrivning"
{...register(`items.${index}.description`)}
/>
</td>
<td className="py-2 pr-2">
<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`)}
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select
value={String(f.value)}
onValueChange={(v) => f.onChange(parseFloat(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
)}
/>
</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">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
</td>
<td className="py-2">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
{/* Totals */}
<div className="mt-4 pt-4 border-t space-y-2 text-right">
<div className="flex justify-end gap-8">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono w-32">{formatAmount(subtotal)} kr</span>
</div>
<div className="flex justify-end gap-8">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono w-32">{formatAmount(totalVat)} kr</span>
</div>
<div className="flex justify-end gap-8 font-bold text-lg">
<span>Totalt</span>
<span className="font-mono w-32">{formatAmount(total)} kr</span>
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Anteckningar</CardTitle>
</CardHeader>
<CardContent>
<Textarea
placeholder="Interna anteckningar om denna faktura..."
{...register('notes')}
/>
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-4">
<Button type="button" variant="outline" onClick={() => router.push('/supplier-invoices')}>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrerar...
</>
) : (
'Registrera faktura'
)}
</Button>
</div>
</form>
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Plus, FileInput, AlertCircle, Clock } from 'lucide-react'
import Link from 'next/link'
import type { SupplierInvoice } from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const statusColors: Record<string, string> = {
registered: 'bg-blue-100 text-blue-800',
approved: 'bg-yellow-100 text-yellow-800',
paid: 'bg-green-100 text-green-800',
partially_paid: 'bg-orange-100 text-orange-800',
overdue: 'bg-red-100 text-red-800',
disputed: 'bg-purple-100 text-purple-800',
credited: 'bg-gray-100 text-gray-800',
}
const statusLabels: Record<string, string> = {
registered: 'Registrerad',
approved: 'Godkänd',
paid: 'Betald',
partially_paid: 'Delbetald',
overdue: 'Förfallen',
disputed: 'Tvist',
credited: 'Krediterad',
}
export default function SupplierInvoicesPage() {
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('all')
useEffect(() => {
fetchInvoices()
}, [])
async function fetchInvoices() {
setIsLoading(true)
const res = await fetch('/api/supplier-invoices?status=all')
const { data } = await res.json()
setInvoices(data || [])
setIsLoading(false)
}
const filteredInvoices = invoices.filter((inv) => {
switch (activeTab) {
case 'registered': return inv.status === 'registered'
case 'approved': return inv.status === 'approved'
case 'to_pay': return inv.status === 'approved' || inv.status === 'overdue'
case 'paid': return inv.status === 'paid'
default: return true
}
})
// Summary stats
const totalUnpaid = invoices
.filter((i) => !['paid', 'credited'].includes(i.status))
.reduce((sum, i) => sum + i.remaining_amount, 0)
const overdueAmount = invoices
.filter((i) => i.status === 'overdue')
.reduce((sum, i) => sum + i.remaining_amount, 0)
const overdueCount = invoices.filter((i) => i.status === 'overdue').length
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Leverantörsfakturor</h1>
<p className="text-muted-foreground">
Registrera och hantera inkommande fakturor
</p>
</div>
<Link href="/supplier-invoices/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Registrera faktura
</Button>
</Link>
</div>
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<FileInput className="h-4 w-4" />
Totalt obetalt
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(totalUnpaid)} kr</p>
<p className="text-xs text-muted-foreground">
{invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-500" />
Förfallet
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-red-600">{formatAmount(overdueAmount)} kr</p>
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Antal fakturor
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{invoices.length}</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="all">Alla</TabsTrigger>
<TabsTrigger value="registered">Registrerade</TabsTrigger>
<TabsTrigger value="approved">Godkända</TabsTrigger>
<TabsTrigger value="to_pay">Att betala</TabsTrigger>
<TabsTrigger value="paid">Betalda</TabsTrigger>
</TabsList>
<TabsContent value={activeTab}>
{isLoading ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar fakturor...
</CardContent>
</Card>
) : filteredInvoices.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileInput className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga fakturor</h3>
<p className="text-muted-foreground text-center mt-1">
{activeTab === 'all'
? 'Registrera din första leverantörsfaktura'
: 'Inga fakturor i denna kategori'}
</p>
{activeTab === 'all' && (
<Link href="/supplier-invoices/new">
<Button className="mt-4">
<Plus className="mr-2 h-4 w-4" />
Registrera faktura
</Button>
</Link>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="p-3">Ankomst</th>
<th className="p-3">Leverantör</th>
<th className="p-3">Fakturanr</th>
<th className="p-3">Fakturadatum</th>
<th className="p-3">Förfaller</th>
<th className="p-3 text-right">Belopp</th>
<th className="p-3 text-right">Kvar att betala</th>
<th className="p-3">Status</th>
</tr>
</thead>
<tbody>
{filteredInvoices.map((inv) => (
<tr key={inv.id} className="border-b last:border-0 hover:bg-muted/50">
<td className="p-3 font-mono">{inv.arrival_number}</td>
<td className="p-3">
<Link href={`/suppliers/${inv.supplier_id}`} className="hover:underline">
{inv.supplier?.name || '-'}
</Link>
</td>
<td className="p-3">
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline">
{inv.supplier_invoice_number}
</Link>
</td>
<td className="p-3">{inv.invoice_date}</td>
<td className="p-3">{inv.due_date}</td>
<td className="p-3 text-right font-mono">{formatAmount(inv.total)}</td>
<td className="p-3 text-right font-mono">{formatAmount(inv.remaining_amount)}</td>
<td className="p-3">
<Badge className={statusColors[inv.status] || ''}>
{statusLabels[inv.status] || inv.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
)
}
+305
View File
@@ -0,0 +1,305 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Edit, Trash2, FileText } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
swedish_business: 'Svenskt företag',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function SupplierDetailPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [supplier, setSupplier] = useState<Supplier & { stats?: { total_outstanding: number; total_paid: number; invoice_count: number } } | null>(null)
const [invoices, setInvoices] = useState<SupplierInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
fetchSupplier()
fetchInvoices()
}, [params.id])
async function fetchSupplier() {
setIsLoading(true)
const res = await fetch(`/api/suppliers/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Fel', description: error, variant: 'destructive' })
} else {
setSupplier(data)
}
setIsLoading(false)
}
async function fetchInvoices() {
const res = await fetch(`/api/supplier-invoices?status=all`)
const { data } = await res.json()
if (data) {
setInvoices(data.filter((inv: SupplierInvoice) => inv.supplier_id === params.id))
}
}
async function handleUpdate(data: CreateSupplierInput) {
setIsSaving(true)
const res = await fetch(`/api/suppliers/${params.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Sparat', description: 'Leverantören har uppdaterats' })
setSupplier({ ...result.data, stats: supplier?.stats })
setIsEditOpen(false)
}
setIsSaving(false)
}
async function handleDelete() {
if (!confirm('Är du säker på att du vill ta bort denna leverantör?')) return
const res = await fetch(`/api/suppliers/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Borttagen', description: 'Leverantören har tagits bort' })
router.push('/suppliers')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<Card className="animate-pulse">
<CardContent className="h-48" />
</Card>
</div>
)
}
if (!supplier) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Leverantören hittades inte</p>
<Button variant="outline" className="mt-4" onClick={() => router.push('/suppliers')}>
Tillbaka
</Button>
</div>
)
}
const statusColors: Record<string, string> = {
registered: 'bg-blue-100 text-blue-800',
approved: 'bg-yellow-100 text-yellow-800',
paid: 'bg-green-100 text-green-800',
partially_paid: 'bg-orange-100 text-orange-800',
overdue: 'bg-red-100 text-red-800',
credited: 'bg-gray-100 text-gray-800',
}
const statusLabels: Record<string, string> = {
registered: 'Registrerad',
approved: 'Godkänd',
paid: 'Betald',
partially_paid: 'Delbetald',
overdue: 'Förfallen',
credited: 'Krediterad',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/suppliers')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">{supplier.name}</h1>
<p className="text-muted-foreground">
{supplierTypeLabels[supplier.supplier_type]}
{supplier.org_number && ` | Org.nr: ${supplier.org_number}`}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setIsEditOpen(true)}>
<Edit className="mr-2 h-4 w-4" />
Redigera
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(supplier.stats?.total_outstanding || 0)} kr</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Totalt betalt</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(supplier.stats?.total_paid || 0)} kr</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Antal fakturor</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{supplier.stats?.invoice_count || 0}</p>
</CardContent>
</Card>
</div>
{/* Contact & Payment Info */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-lg">Kontaktuppgifter</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{supplier.email && <p>E-post: {supplier.email}</p>}
{supplier.phone && <p>Telefon: {supplier.phone}</p>}
{supplier.address_line1 && <p>{supplier.address_line1}</p>}
{supplier.postal_code && <p>{supplier.postal_code} {supplier.city}</p>}
{supplier.vat_number && <p>VAT: {supplier.vat_number}</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Betalningsuppgifter</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{supplier.bankgiro && <p>Bankgiro: {supplier.bankgiro}</p>}
{supplier.plusgiro && <p>Plusgiro: {supplier.plusgiro}</p>}
{supplier.iban && <p>IBAN: {supplier.iban}</p>}
{supplier.bic && <p>BIC: {supplier.bic}</p>}
<p>Betalningsvillkor: {supplier.default_payment_terms} dagar</p>
<p>Valuta: {supplier.default_currency}</p>
{supplier.default_expense_account && <p>Kostnadskonto: {supplier.default_expense_account}</p>}
</CardContent>
</Card>
</div>
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Fakturor</CardTitle>
<Link href="/supplier-invoices/new">
<Button size="sm">
<FileText className="mr-2 h-4 w-4" />
Ny faktura
</Button>
</Link>
</CardHeader>
<CardContent>
{invoices.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8">
Inga fakturor registrerade för denna leverantör
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Ankomst</th>
<th className="py-2">Fakturanr</th>
<th className="py-2">Datum</th>
<th className="py-2">Förfaller</th>
<th className="py-2 text-right">Belopp</th>
<th className="py-2 text-right">Kvar</th>
<th className="py-2">Status</th>
</tr>
</thead>
<tbody>
{invoices.map((inv) => (
<tr key={inv.id} className="border-b last:border-0">
<td className="py-2 font-mono">{inv.arrival_number}</td>
<td className="py-2">
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline">
{inv.supplier_invoice_number}
</Link>
</td>
<td className="py-2">{inv.invoice_date}</td>
<td className="py-2">{inv.due_date}</td>
<td className="py-2 text-right">{formatAmount(inv.total)} kr</td>
<td className="py-2 text-right">{formatAmount(inv.remaining_amount)} kr</td>
<td className="py-2">
<Badge className={statusColors[inv.status] || ''}>
{statusLabels[inv.status] || inv.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Redigera leverantör</DialogTitle>
</DialogHeader>
<SupplierForm
onSubmit={handleUpdate}
isLoading={isSaving}
initialData={{
name: supplier.name,
supplier_type: supplier.supplier_type,
email: supplier.email || '',
phone: supplier.phone || '',
address_line1: supplier.address_line1 || '',
address_line2: supplier.address_line2 || '',
postal_code: supplier.postal_code || '',
city: supplier.city || '',
country: supplier.country || 'SE',
org_number: supplier.org_number || '',
vat_number: supplier.vat_number || '',
bankgiro: supplier.bankgiro || '',
plusgiro: supplier.plusgiro || '',
iban: supplier.iban || '',
bic: supplier.bic || '',
default_expense_account: supplier.default_expense_account || '',
default_payment_terms: supplier.default_payment_terms,
default_currency: supplier.default_currency || 'SEK',
notes: supplier.notes || '',
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
+219
View File
@@ -0,0 +1,219 @@
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Search, Building2, Globe } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
swedish_business: 'Svenskt företag',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
const supplierTypeIcons: Record<SupplierType, React.ElementType> = {
swedish_business: Building2,
eu_business: Globe,
non_eu_business: Globe,
}
export default function SuppliersPage() {
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchSuppliers()
}, [])
async function fetchSuppliers() {
setIsLoading(true)
const { data, error } = await supabase
.from('suppliers')
.select('*')
.order('name', { ascending: true })
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte hämta leverantörer',
variant: 'destructive',
})
} else {
setSuppliers(data || [])
}
setIsLoading(false)
}
async function handleCreateSupplier(data: CreateSupplierInput) {
setIsCreating(true)
const response = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte skapa leverantör',
variant: 'destructive',
})
} else {
toast({
title: 'Leverantör skapad',
description: `${data.name} har lagts till`,
})
setSuppliers([...suppliers, result.data])
setIsDialogOpen(false)
}
setIsCreating(false)
}
const filteredSuppliers = suppliers.filter((s) =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.org_number?.includes(searchTerm)
)
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Leverantörer</h1>
<p className="text-muted-foreground">
Hantera dina leverantörer och deras betalningsuppgifter
</p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny leverantör
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till leverantör</DialogTitle>
</DialogHeader>
<SupplierForm
onSubmit={handleCreateSupplier}
isLoading={isCreating}
/>
</DialogContent>
</Dialog>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök på namn, e-post eller org.nr..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Supplier list */}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="h-5 bg-muted rounded w-1/2" />
<div className="h-4 bg-muted rounded w-1/3 mt-2" />
</CardHeader>
<CardContent>
<div className="h-4 bg-muted rounded w-full" />
</CardContent>
</Card>
))}
</div>
) : filteredSuppliers.length === 0 ? (
<Card>
<CardContent>
{searchTerm ? (
<div className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga träffar</h3>
<p className="text-muted-foreground text-center mt-1">
Inga leverantörer matchar &quot;{searchTerm}&quot;
</p>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga leverantörer</h3>
<p className="text-muted-foreground text-center mt-1">
Lägg till din första leverantör för att börja registrera inköpsfakturor
</p>
<Button className="mt-4" onClick={() => setIsDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Ny leverantör
</Button>
</div>
)}
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredSuppliers.map((supplier) => {
const Icon = supplierTypeIcons[supplier.supplier_type]
return (
<Link key={supplier.id} href={`/suppliers/${supplier.id}`}>
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{supplier.name}</CardTitle>
<CardDescription>{supplier.email || 'Ingen e-post'}</CardDescription>
</div>
</div>
<Badge variant="secondary">
{supplierTypeLabels[supplier.supplier_type]}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground space-y-1">
{supplier.org_number && (
<p>Org.nr: {supplier.org_number}</p>
)}
{supplier.bankgiro && (
<p>Bankgiro: {supplier.bankgiro}</p>
)}
{supplier.city && (
<p>{supplier.city}, {supplier.country}</p>
)}
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}
+8 -7
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
@@ -35,7 +35,7 @@ export async function GET(request: Request) {
return NextResponse.redirect(`${baseUrl}/settings?bank_error=missing_parameters`)
}
const supabase = await createClient()
const supabase = await createServiceClient()
try {
// Create session from authorization code
@@ -88,16 +88,17 @@ export async function GET(request: Request) {
.from('bank_connections')
.insert({
user_id: state,
bank_id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
provider: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
bank_name: aspsp.name,
session_id,
status: 'active',
accounts: accountsWithBalances,
consent_expires_at: consentExpiresAt,
accounts_data: accountsWithBalances,
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
if (insertError) {
console.error('Insert error:', insertError)
throw new Error('Failed to create connection')
}
} else {
@@ -107,8 +108,8 @@ export async function GET(request: Request) {
.update({
session_id,
status: 'active',
accounts: accountsWithBalances,
consent_expires_at: consentExpiresAt,
accounts_data: accountsWithBalances,
consent_expires: consentExpiresAt,
last_synced_at: new Date().toISOString(),
})
.eq('id', pendingConnection.id)
+1 -1
View File
@@ -65,7 +65,7 @@ export async function POST(request: Request) {
.from('bank_connections')
.insert({
user_id: user.id,
bank_id: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
authorization_id,
status: 'pending',
+4 -4
View File
@@ -72,7 +72,7 @@ export async function GET(request: Request) {
for (const connection of connections) {
try {
// Check consent expiry
const daysLeft = getDaysUntilExpiry(connection.consent_expires_at)
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
const isExpired = daysLeft !== null && daysLeft <= 0
if (isExpired) {
@@ -95,7 +95,7 @@ export async function GET(request: Request) {
continue
}
const expiringSoon = isConsentExpiringSoon(connection.consent_expires_at)
const expiringSoon = isConsentExpiringSoon(connection.consent_expires)
// Sync last 7 days (daily cron, with overlap for safety)
const toDate = new Date().toISOString().split('T')[0]
@@ -103,7 +103,7 @@ export async function GET(request: Request) {
.toISOString()
.split('T')[0]
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
let totalImported = 0
let totalDuplicates = 0
@@ -128,7 +128,7 @@ export async function GET(request: Request) {
await supabase
.from('bank_connections')
.update({
accounts,
accounts_data: accounts,
last_synced_at: new Date().toISOString(),
})
.eq('id', connection.id)
+2 -2
View File
@@ -43,7 +43,7 @@ export async function POST(request: Request) {
}
try {
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
@@ -72,7 +72,7 @@ export async function POST(request: Request) {
await supabase
.from('bank_connections')
.update({
accounts,
accounts_data: accounts,
last_synced_at: syncedAt,
})
.eq('id', connection.id)
+55
View File
@@ -0,0 +1,55 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents/:id/link
* Link a document to a journal entry (verifikation)
*
* Request body:
* - journal_entry_id: string (required)
* - journal_entry_line_id: string (optional)
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const body = await request.json()
if (!body.journal_entry_id) {
return NextResponse.json(
{ error: 'journal_entry_id is required' },
{ status: 400 }
)
}
const document = await linkToJournalEntry(
user.id,
id,
body.journal_entry_id,
body.journal_entry_line_id
)
return NextResponse.json({ data: document })
} catch (error) {
console.error('[documents/link/POST] Link failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Link failed' },
{ status: 500 }
)
}
}
+55
View File
@@ -0,0 +1,55 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
ensureInitialized()
/**
* GET /api/documents/:id
* Fetch document metadata + signed download URL (60 min expiry)
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// Fetch document record
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// Create signed download URL (60 minutes)
const { data: signedUrl, error: signError } = await supabase.storage
.from('documents')
.createSignedUrl(doc.storage_path, 3600)
if (signError) {
return NextResponse.json(
{ error: `Failed to create download URL: ${signError.message}` },
{ status: 500 }
)
}
return NextResponse.json({
data: {
...doc,
download_url: signedUrl.signedUrl,
},
})
}
+37
View File
@@ -0,0 +1,37 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { verifyIntegrity } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents/:id/verify
* Verify document integrity by re-computing SHA-256 and comparing
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const result = await verifyIntegrity(user.id, id)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[documents/verify/POST] Verification failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Verification failed' },
{ status: 500 }
)
}
}
+101
View File
@@ -0,0 +1,101 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createNewVersion } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents/:id/versions
* Create a new version of an existing document (atomic via RPC)
*
* Accepts multipart/form-data with:
* - file: The new version file
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const buffer = await file.arrayBuffer()
const newVersion = await createNewVersion(user.id, id, {
name: file.name,
buffer,
type: file.type,
})
return NextResponse.json({ data: newVersion })
} catch (error) {
console.error('[documents/versions/POST] Version creation failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Version creation failed' },
{ status: 500 }
)
}
}
/**
* GET /api/documents/:id/versions
* List all versions in the document chain
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
// First, check if the document belongs to the user
const { data: doc, error: docError } = await supabase
.from('document_attachments')
.select('id, original_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (docError || !doc) {
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
}
// The root document is either the original_id or the document itself
const rootId = doc.original_id || doc.id
// Fetch all versions in the chain
const { data: versions, error: versionsError } = await supabase
.from('document_attachments')
.select('*')
.eq('user_id', user.id)
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
.order('version', { ascending: true })
if (versionsError) {
return NextResponse.json({ error: versionsError.message }, { status: 500 })
}
return NextResponse.json({ data: versions })
}
+108
View File
@@ -0,0 +1,108 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service'
ensureInitialized()
/**
* POST /api/documents
* Upload a document to the WORM archive
*
* Accepts multipart/form-data with:
* - file: The document file
* - upload_source (optional): 'camera' | 'file_upload' | 'email' | ...
* - journal_entry_id (optional): Link to a journal entry
* - journal_entry_line_id (optional): Link to a journal entry line
*/
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 })
}
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const uploadSource = (formData.get('upload_source') as string) || 'file_upload'
const journalEntryId = formData.get('journal_entry_id') as string | null
const journalEntryLineId = formData.get('journal_entry_line_id') as string | null
const buffer = await file.arrayBuffer()
const document = await uploadDocument(user.id, {
name: file.name,
buffer,
type: file.type,
}, {
upload_source: uploadSource as import('@/types').DocumentUploadSource,
journal_entry_id: journalEntryId || undefined,
journal_entry_line_id: journalEntryLineId || undefined,
})
return NextResponse.json({ data: document })
} catch (error) {
console.error('[documents/POST] Upload failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 }
)
}
}
/**
* GET /api/documents
* List documents with optional filtering
*
* Query params:
* - journal_entry_id: Filter by journal entry
* - current_only: If 'true', only return current versions (default: true)
* - limit: Number of results (default: 50)
* - offset: Pagination offset (default: 0)
*/
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 journalEntryId = searchParams.get('journal_entry_id')
const currentOnly = searchParams.get('current_only') !== 'false'
const limit = parseInt(searchParams.get('limit') || '50')
const offset = parseInt(searchParams.get('offset') || '0')
let query = supabase
.from('document_attachments')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1)
if (journalEntryId) {
query = query.eq('journal_entry_id', journalEntryId)
}
if (currentOnly) {
query = query.eq('is_current_version', true)
}
const { data, error, count } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data, count })
}
+117
View File
@@ -0,0 +1,117 @@
import { createClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
/**
* GET /api/documents/verify/cron
* Batch integrity verification of WORM document archive
*
* Runs weekly (Sunday 03:00 UTC / 05:00 Swedish time).
* Processes up to 100 documents per run, prioritizing
* documents never checked or least recently checked.
*
* Uses service role for cross-user verification (RLS bypass).
*/
export async function GET(request: Request) {
// Verify cron secret
const authHeader = request.headers.get('authorization')
const cronSecret = process.env.CRON_SECRET
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!supabaseUrl || !supabaseServiceKey) {
return NextResponse.json(
{ error: 'Missing Supabase configuration' },
{ status: 500 }
)
}
const supabase = createClient(supabaseUrl, supabaseServiceKey)
// Fetch up to 100 current-version documents, prioritizing unchecked/oldest
const { data: documents, error: fetchError } = await supabase
.from('document_attachments')
.select('id, user_id, storage_path, sha256_hash, file_name')
.eq('is_current_version', true)
.order('last_integrity_check_at', { ascending: true, nullsFirst: true })
.limit(100)
if (fetchError) {
console.error('[doc-verify-cron] Failed to fetch documents:', fetchError)
return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 })
}
if (!documents || documents.length === 0) {
return NextResponse.json({ message: 'No documents to verify', processed: 0 })
}
let verified = 0
let failures = 0
let errors = 0
for (const doc of documents) {
try {
// Download file from storage
const { data: fileData, error: downloadError } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (downloadError || !fileData) {
console.error(`[doc-verify-cron] Download failed for ${doc.id}:`, downloadError)
errors++
continue
}
// Compute SHA-256 hash
const buffer = await fileData.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const computedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
const isValid = computedHash === doc.sha256_hash
// Update last_integrity_check_at
await supabase
.from('document_attachments')
.update({ last_integrity_check_at: new Date().toISOString() })
.eq('id', doc.id)
if (!isValid) {
// Log integrity failure to audit_log
await supabase.from('audit_log').insert({
user_id: doc.user_id,
action: 'INTEGRITY_FAILURE',
table_name: 'document_attachments',
record_id: doc.id,
description: `Integrity check failed for document "${doc.file_name}": stored hash ${doc.sha256_hash}, computed hash ${computedHash}`,
old_state: { sha256_hash: doc.sha256_hash },
new_state: { computed_hash: computedHash },
})
console.error(`[doc-verify-cron] INTEGRITY FAILURE: document ${doc.id} (${doc.file_name})`)
failures++
} else {
verified++
}
} catch (error) {
console.error(`[doc-verify-cron] Error verifying document ${doc.id}:`, error)
errors++
// Continue with other documents
}
}
console.log(
`[doc-verify-cron] Processed ${documents.length}: ${verified} verified, ${failures} failures, ${errors} errors`
)
return NextResponse.json({
processed: documents.length,
verified,
failures,
errors,
})
}
@@ -4,6 +4,7 @@ import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import { uploadDocument } from '@/lib/core/documents/document-service'
ensureInitialized()
@@ -63,6 +64,17 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
}
// WORM archive copy (non-blocking — receipt flow continues even if this fails)
try {
await uploadDocument(user.id, {
name: imageFile.name,
buffer: arrayBuffer,
type: imageFile.type,
}, { upload_source: 'camera' })
} catch (archiveErr) {
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
}
// Get public URL
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
const imageUrl = urlData.publicUrl
+110
View File
@@ -0,0 +1,110 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import type { Invoice } from '@/types'
/**
* POST /api/invoices/[id]/mark-paid
*
* Manually marks an invoice as paid (for payments received outside bank sync).
*
* Faktureringsmetoden (accrual):
* Creates payment clearing entry: Debit 1930, Credit 1510
*
* Kontantmetoden (cash):
* Creates combined revenue entry: Debit 1930, Credit 30xx, Credit 26xx
*/
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 invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
}
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
return NextResponse.json(
{ error: 'Fakturan kan inte markeras som betald i nuvarande status' },
{ status: 400 }
)
}
const now = new Date().toISOString()
const paymentDate = now.split('T')[0]
// Update status to paid
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'paid',
paid_at: now,
paid_amount: invoice.total,
})
.eq('id', id)
.eq('user_id', user.id)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
}
// 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 || 'accrual'
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
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
console.error('Failed to create payment journal entry on mark-paid:', err)
}
return NextResponse.json({
success: true,
status: 'paid',
paid_at: now,
paid_amount: invoice.total,
journal_entry_id: journalEntryId,
})
}
+93
View File
@@ -0,0 +1,93 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import type { Invoice } from '@/types'
/**
* POST /api/invoices/[id]/mark-sent
*
* Manually marks a draft invoice as sent (for invoices delivered outside the system).
* Under faktureringsmetoden (accrual): creates the journal entry (Debit 1510, Credit 30xx/26xx).
* Under kontantmetoden (cash): no journal entry — booking happens at payment.
*/
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 invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
}
if (invoice.status !== 'draft') {
return NextResponse.json(
{ error: 'Endast utkast kan markeras som skickade' },
{ status: 400 }
)
}
// Update status to sent
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
})
.eq('id', id)
.eq('user_id', user.id)
if (updateError) {
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
}
// 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 || 'accrual'
// Faktureringsmetoden: book at send
let journalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
invoice as Invoice
)
if (journalEntry) {
journalEntryId = journalEntry.id
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
}
} catch (err) {
console.error('Failed to create invoice journal entry on mark-sent:', err)
}
}
return NextResponse.json({
success: true,
status: 'sent',
journal_entry_id: journalEntryId,
})
}
+20
View File
@@ -10,6 +10,7 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject
} from '@/lib/email/invoice-templates'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
ensureInitialized()
@@ -158,6 +159,25 @@ 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) {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
invoice as Invoice
)
if (journalEntry) {
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', id)
}
} catch (err) {
console.error('Failed to create invoice journal entry on send:', err)
// Non-blocking — don't fail the send
}
}
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, userId: user.id },
+1 -18
View File
@@ -6,7 +6,6 @@ import type { CreateInvoiceInput, Invoice, CreditNote } from '@/types'
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createInvoiceJournalEntry,
createCreditNoteJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
@@ -176,24 +175,8 @@ export async function POST(request: Request) {
.eq('id', invoice.id)
.single()
// Create journal entry for the invoice (non-blocking)
// Emit event (no journal entry at draft — booking happens at send/payment based on accounting method)
if (completeInvoice) {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
completeInvoice as Invoice
)
if (journalEntry) {
await supabase
.from('invoices')
.update({ journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
}
} catch (err) {
console.error('Failed to create invoice journal entry:', err)
// Don't fail the invoice creation
}
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
+32
View File
@@ -0,0 +1,32 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
import { generateReconciliation } from '@/lib/reports/supplier-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 asOfDate = searchParams.get('as_of_date') || undefined
const periodId = searchParams.get('period_id') || undefined
const ledger = await generateSupplierLedger(user.id, asOfDate)
let reconciliation = null
if (periodId) {
reconciliation = await generateReconciliation(user.id, periodId)
}
return NextResponse.json({
data: {
ledger,
reconciliation,
},
})
}
@@ -0,0 +1,48 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: invoice } = await supabase
.from('supplier_invoices')
.select('status')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (!invoice) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (invoice.status !== 'registered') {
return NextResponse.json(
{ error: 'Kan bara godkänna registrerade fakturor' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('supplier_invoices')
.update({ status: 'approved' })
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
@@ -0,0 +1,127 @@
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'
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch original invoice with supplier and items
const { data: original, error: fetchError } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !original) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (original.status === 'credited') {
return NextResponse.json(
{ error: 'Fakturan har redan krediterats' },
{ status: 400 }
)
}
// Get next arrival number
const { data: arrivalNum } = await supabase
.rpc('get_next_arrival_number', { p_user_id: user.id })
// Create credit note invoice (negative amounts)
const { data: creditNote, error: creditError } = await supabase
.from('supplier_invoices')
.insert({
user_id: user.id,
supplier_id: original.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
invoice_date: new Date().toISOString().split('T')[0],
due_date: new Date().toISOString().split('T')[0],
status: 'registered',
currency: original.currency,
exchange_rate: original.exchange_rate,
vat_treatment: original.vat_treatment,
reverse_charge: original.reverse_charge,
subtotal: original.subtotal,
subtotal_sek: original.subtotal_sek,
vat_amount: original.vat_amount,
vat_amount_sek: original.vat_amount_sek,
total: original.total,
total_sek: original.total_sek,
remaining_amount: 0,
is_credit_note: true,
credited_invoice_id: id,
})
.select()
.single()
if (creditError || !creditNote) {
return NextResponse.json({ error: creditError?.message || 'Failed to create credit note' }, { status: 500 })
}
// Copy items to credit note
const creditItems = (original.items || []).map((item: SupplierInvoiceItem) => ({
supplier_invoice_id: creditNote.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,
account_number: item.account_number,
vat_code: item.vat_code,
vat_rate: item.vat_rate,
vat_amount: item.vat_amount,
}))
await supabase.from('supplier_invoice_items').insert(creditItems)
// Create credit note journal entry
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)
}
} catch (err) {
console.error('Failed to create credit note journal entry:', err)
}
// Update original invoice: reduce remaining_amount
const newRemaining = Math.max(0, original.remaining_amount - original.total)
const newStatus = newRemaining <= 0 ? 'credited' : original.status
await supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: newRemaining,
})
.eq('id', id)
return NextResponse.json({
data: creditNote,
journal_entry_id: journalEntryId,
})
}
@@ -0,0 +1,136 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
interface MarkPaidRequest {
amount?: number
payment_date?: string
exchange_rate_difference?: number
notes?: string
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: MarkPaidRequest = await request.json()
// Fetch invoice with supplier and items
const { data: invoice, error: fetchError } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !invoice) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (!['registered', 'approved', 'partially_paid', 'overdue'].includes(invoice.status)) {
return NextResponse.json(
{ error: 'Fakturan kan inte markeras som betald i nuvarande status' },
{ status: 400 }
)
}
const paymentDate = body.payment_date || new Date().toISOString().split('T')[0]
const paymentAmount = body.amount || invoice.remaining_amount
const now = new Date().toISOString()
// 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 || 'accrual'
// Create journal entry
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
const journalEntry = await createSupplierInvoiceCashEntry(
user.id,
invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
paymentDate,
invoice.supplier?.supplier_type || 'swedish_business'
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
user.id,
invoice as SupplierInvoice,
paymentAmount,
paymentDate,
body.exchange_rate_difference
)
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
console.error('Failed to create payment journal entry:', err)
}
// Calculate new remaining amount
const newRemaining = Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
// Update invoice
const { error: updateError } = await supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: Math.max(0, newRemaining),
paid_amount: newPaidAmount,
paid_at: isFullyPaid ? now : null,
payment_journal_entry_id: journalEntryId,
})
.eq('id', id)
if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
// Record payment
const { error: paymentError } = await supabase
.from('supplier_invoice_payments')
.insert({
supplier_invoice_id: id,
payment_date: paymentDate,
amount: paymentAmount,
currency: invoice.currency,
exchange_rate_difference: body.exchange_rate_difference || 0,
journal_entry_id: journalEntryId,
notes: body.notes || null,
})
if (paymentError) {
console.error('Failed to record payment:', paymentError)
}
return NextResponse.json({
success: true,
status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: Math.max(0, newRemaining),
journal_entry_id: journalEntryId,
})
}
+133
View File
@@ -0,0 +1,133 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data: invoice, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error || !invoice) {
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
}
return NextResponse.json({ data: invoice })
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only allow editing registered invoices
const { data: existing } = await supabase
.from('supplier_invoices')
.select('status')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (existing.status !== 'registered') {
return NextResponse.json(
{ error: 'Kan bara redigera registrerade fakturor' },
{ status: 400 }
)
}
const body = await request.json()
const { data, error } = await supabase
.from('supplier_invoices')
.update({
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
due_date: body.due_date,
delivery_date: body.delivery_date,
payment_reference: body.payment_reference,
notes: body.notes,
})
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Only allow deleting registered invoices without journal entries
const { data: existing } = await supabase
.from('supplier_invoices')
.select('status, registration_journal_entry_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (existing.status !== 'registered') {
return NextResponse.json(
{ error: 'Kan bara ta bort registrerade fakturor' },
{ status: 400 }
)
}
// Delete items first, then invoice
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', id)
const { error } = await supabase
.from('supplier_invoices')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+186
View File
@@ -0,0 +1,186 @@
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'
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 status = searchParams.get('status')
let query = supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(id, name)')
.eq('user_id', user.id)
if (status && status !== 'all') {
if (status === 'to_pay') {
query = query.in('status', ['approved', 'overdue'])
} else {
query = query.eq('status', status)
}
}
const { data, error } = await query.order('due_date', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
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: CreateSupplierInvoiceInput = await request.json()
// Validate supplier exists and belongs to user
const { data: supplier, error: supplierError } = await supabase
.from('suppliers')
.select('*')
.eq('id', body.supplier_id)
.eq('user_id', user.id)
.single()
if (supplierError || !supplier) {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
// Get next arrival number
const { data: arrivalNum, error: arrivalError } = await supabase
.rpc('get_next_arrival_number', { p_user_id: user.id })
if (arrivalError) {
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
}
// Calculate totals from items
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 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,
line_total: lineTotal,
account_number: item.account_number,
vat_code: item.vat_code || null,
vat_rate: vatRate,
vat_amount: vatAmount,
}
})
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
const total = Math.round((subtotal + vatAmount) * 100) / 100
const exchangeRate = body.exchange_rate || null
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
// Insert supplier invoice
const { data: invoice, error: invoiceError } = await supabase
.from('supplier_invoices')
.insert({
user_id: user.id,
supplier_id: body.supplier_id,
arrival_number: arrivalNum,
supplier_invoice_number: body.supplier_invoice_number,
invoice_date: body.invoice_date,
due_date: body.due_date,
delivery_date: body.delivery_date || null,
status: 'registered',
currency: body.currency || 'SEK',
exchange_rate: exchangeRate,
vat_treatment: body.vat_treatment || 'standard_25',
reverse_charge: body.reverse_charge || false,
payment_reference: body.payment_reference || null,
subtotal: Math.round(subtotal * 100) / 100,
subtotal_sek: subtotalSek,
vat_amount: Math.round(vatAmount * 100) / 100,
vat_amount_sek: vatAmountSek,
total: Math.round(total * 100) / 100,
total_sek: totalSek,
remaining_amount: Math.round(total * 100) / 100,
notes: body.notes || null,
})
.select()
.single()
if (invoiceError || !invoice) {
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
}
// Insert line items
const itemInserts = items.map((item) => ({
supplier_invoice_id: invoice.id,
...item,
}))
const { error: itemsError } = await supabase
.from('supplier_invoice_items')
.insert(itemInserts)
if (itemsError) {
// Clean up invoice on items failure
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
// Accrual method: create registration journal entry
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
let registrationJournalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
user.id,
invoice as SupplierInvoice,
items as SupplierInvoiceItem[],
supplier.supplier_type
)
if (journalEntry) {
registrationJournalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
}
} catch (err) {
console.error('Failed to create registration journal entry:', err)
}
}
return NextResponse.json({
data: {
...invoice,
items: itemInserts,
registration_journal_entry_id: registrationJournalEntryId,
},
})
}
+145
View File
@@ -0,0 +1,145 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateSupplierInput } from '@/types'
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch supplier
const { data: supplier, error } = await supabase
.from('suppliers')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (error || !supplier) {
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
}
// Fetch stats: total outstanding & total paid
const { data: invoices } = await supabase
.from('supplier_invoices')
.select('status, total, remaining_amount, paid_amount')
.eq('supplier_id', id)
.eq('user_id', user.id)
const stats = {
total_outstanding: 0,
total_paid: 0,
invoice_count: 0,
}
if (invoices) {
stats.invoice_count = invoices.length
for (const inv of invoices) {
if (inv.status !== 'paid' && inv.status !== 'credited') {
stats.total_outstanding += inv.remaining_amount || 0
}
stats.total_paid += inv.paid_amount || 0
}
}
return NextResponse.json({ data: { ...supplier, stats } })
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: Partial<CreateSupplierInput> = await request.json()
const { data, error } = await supabase
.from('suppliers')
.update({
name: body.name,
supplier_type: body.supplier_type,
email: body.email,
phone: body.phone,
address_line1: body.address_line1,
address_line2: body.address_line2,
postal_code: body.postal_code,
city: body.city,
country: body.country,
org_number: body.org_number,
vat_number: body.vat_number,
bankgiro: body.bankgiro,
plusgiro: body.plusgiro,
bank_account: body.bank_account,
iban: body.iban,
bic: body.bic,
default_expense_account: body.default_expense_account,
default_payment_terms: body.default_payment_terms,
default_currency: body.default_currency,
notes: body.notes,
})
.eq('id', id)
.eq('user_id', user.id)
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check for linked invoices
const { count } = await supabase
.from('supplier_invoices')
.select('id', { count: 'exact', head: true })
.eq('supplier_id', id)
.eq('user_id', user.id)
if (count && count > 0) {
return NextResponse.json(
{ error: 'Kan inte ta bort leverantör med kopplade fakturor' },
{ status: 400 }
)
}
const { error } = await supabase
.from('suppliers')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
+71
View File
@@ -0,0 +1,71 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateSupplierInput, Supplier } from '@/types'
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { data, error } = await supabase
.from('suppliers')
.select('*')
.eq('user_id', user.id)
.order('name', { ascending: true })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
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: CreateSupplierInput = await request.json()
const { data, error } = await supabase
.from('suppliers')
.insert({
user_id: user.id,
name: body.name,
supplier_type: body.supplier_type,
email: body.email,
phone: body.phone,
address_line1: body.address_line1,
address_line2: body.address_line2,
postal_code: body.postal_code,
city: body.city,
country: body.country || 'SE',
org_number: body.org_number,
vat_number: body.vat_number,
bankgiro: body.bankgiro,
plusgiro: body.plusgiro,
bank_account: body.bank_account,
iban: body.iban,
bic: body.bic,
default_expense_account: body.default_expense_account,
default_payment_terms: body.default_payment_terms || 30,
default_currency: body.default_currency || 'SEK',
notes: body.notes,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
+36 -12
View File
@@ -5,13 +5,14 @@ import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
ensureInitialized()
interface CategorizeRequest {
is_business: boolean
category?: TransactionCategory
vat_treatment?: VatTreatment
}
/**
@@ -20,7 +21,8 @@ interface CategorizeRequest {
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
date: string,
fiscalYearStartMonth: number = 1
): Promise<boolean> {
// Check if a fiscal period already covers this date
const { data: existing } = await supabase
@@ -36,18 +38,38 @@ async function ensureFiscalPeriod(
return true
}
// No fiscal period exists - create one for the year of the transaction
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
// Compute fiscal year period based on start month
const txDate = new Date(date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
const periodStart = `${year}-01-01`
const periodEnd = `${year}-12-31`
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
// Period ends the day before the next fiscal year starts
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
const { error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
name: periodName,
period_start: periodStart,
period_end: periodEnd,
}, {
@@ -122,14 +144,15 @@ export async function POST(
})
}
// Fetch company settings to get entity type
// Fetch company settings to get entity type and fiscal year start
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', user.id)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
// Determine the category to use
const finalCategory: TransactionCategory = is_business
@@ -141,11 +164,12 @@ export async function POST(
finalCategory,
transaction as Transaction,
is_business,
entityType
entityType,
body.vat_treatment
)
// Ensure fiscal period exists for the transaction date
await ensureFiscalPeriod(supabase, user.id, transaction.date)
await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth)
// Try to create journal entry
let journalEntryCreated = false
@@ -1,7 +1,11 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import type { Transaction, Invoice, CreateJournalEntryInput } from '@/types'
import {
getRevenueAccount,
getOutputVatAccount,
} from '@/lib/bookkeeping/invoice-entries'
import type { Transaction, Invoice, CreateJournalEntryInput, VatTreatment } from '@/types'
interface MatchInvoiceRequest {
invoice_id: string
@@ -132,7 +136,16 @@ export async function POST(
const now = new Date().toISOString()
const paidAmount = transaction.amount
// Create journal entry for payment receipt
// 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 || 'accrual'
// Create journal entry for payment receipt (method-aware)
let journalEntryId: string | null = null
let journalEntryError: string | null = null
@@ -140,26 +153,69 @@ export async function POST(
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
if (fiscalPeriodId) {
const journalInput: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
source_type: 'invoice_paid',
source_id: invoice.id,
lines: [
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)
const lines: CreateJournalEntryInput['lines'] = [
{
account_number: '1930', // Företagskonto (Bank)
debit_amount: paidAmount,
account_number: '1930',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
},
{
account_number: '1510', // Kundfordringar (Accounts Receivable)
account_number: revenueAccount,
debit_amount: 0,
credit_amount: paidAmount,
line_description: `Faktura ${invoice.invoice_number} betald`,
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)
@@ -0,0 +1,211 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
interface MatchRequest {
supplier_invoice_id: string
}
/**
* Ensure a fiscal period exists for the given date
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<string | null> {
const existingPeriodId = await findFiscalPeriod(userId, date)
if (existingPeriodId) return existingPeriodId
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const { data, error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: `${year}-01-01`,
period_end: `${year}-12-31`,
}, {
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-supplier-invoice
*
* Match a negative transaction (expense) to a supplier invoice.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient()
const { id: transactionId } = await params
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body: MatchRequest = await request.json()
const { supplier_invoice_id } = body
if (!supplier_invoice_id) {
return NextResponse.json({ error: 'supplier_invoice_id is required' }, { status: 400 })
}
// Fetch the transaction
const { data: transaction, error: fetchTxError } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', user.id)
.single()
if (fetchTxError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
// Verify transaction is an expense (amount < 0)
if (transaction.amount >= 0) {
return NextResponse.json(
{ error: 'Bara utgiftstransaktioner kan matchas mot leverantörsfakturor' },
{ status: 400 }
)
}
if (transaction.supplier_invoice_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan kopplad till en leverantörsfaktura' },
{ status: 400 }
)
}
// Fetch the invoice with supplier and items
const { data: invoice, error: fetchInvError } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
.eq('id', supplier_invoice_id)
.eq('user_id', user.id)
.single()
if (fetchInvError || !invoice) {
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
}
if (invoice.status === 'paid' || invoice.status === 'credited') {
return NextResponse.json(
{ error: 'Leverantörsfakturan är redan betald' },
{ status: 400 }
)
}
const paymentAmount = Math.abs(transaction.amount)
const now = new Date().toISOString()
// Get accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
// Create journal entry
let journalEntryId: string | null = null
try {
if (accountingMethod === 'cash') {
const journalEntry = await createSupplierInvoiceCashEntry(
user.id,
invoice as SupplierInvoice,
(invoice.items || []) as SupplierInvoiceItem[],
transaction.date,
invoice.supplier?.supplier_type || 'swedish_business'
)
if (journalEntry) journalEntryId = journalEntry.id
} else {
const journalEntry = await createSupplierInvoicePaymentEntry(
user.id,
invoice as SupplierInvoice,
paymentAmount,
transaction.date
)
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
console.error('Failed to create payment journal entry:', err)
}
// Update supplier invoice
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
const { error: updateInvError } = await supabase
.from('supplier_invoices')
.update({
status: newStatus,
remaining_amount: newRemaining,
paid_amount: newPaidAmount,
paid_at: isFullyPaid ? now : null,
payment_journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
.eq('id', supplier_invoice_id)
if (updateInvError) {
return NextResponse.json({ error: 'Failed to update supplier invoice' }, { status: 500 })
}
// Record payment
await supabase.from('supplier_invoice_payments').insert({
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
// Update transaction
const { error: updateTxError } = await supabase
.from('transactions')
.update({
supplier_invoice_id,
journal_entry_id: journalEntryId,
is_business: true,
})
.eq('id', transactionId)
if (updateTxError) {
return NextResponse.json({ error: 'Failed to link transaction' }, { status: 500 })
}
return NextResponse.json({
success: true,
invoice_status: newStatus,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
journal_entry_id: journalEntryId,
})
}