feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination

Per-line VAT rates:
- Add generatePerRateLines() to group invoice items by vat_rate with separate
  revenue + VAT lines per rate group (invoice-entries.ts)
- Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts)
- PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices
- Invoice create/review UI supports per-line rate selection
- Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput

Invoice document types (proforma, delivery note):
- Add InvoiceDocumentType, document_type and converted_from_id to Invoice type
- PDF hides prices for delivery notes, adds proforma notice
- Email templates support all document types
- mark-paid skips journal entries for non-invoice document types
- Migration 031: invoice_document_type

Accounting method support:
- Add AccountingMethod type (accrual/cash)
- Migration 032: add_accounting_method column to company_settings

VAT declaration rewrite:
- Rewrite to read directly from general ledger (26xx/3xxx account lines)
  instead of aggregating invoices/transactions/receipts
- ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances

Bank reconciliation:
- Transaction ingest now pre-fetches unlinked GL lines and attempts
  auto-reconciliation during import
- Add transaction.reconciled event type
- Add ReconciliationMethod type and reconciliation_method on Transaction
- Migration 030: bank_reconciliation
- New reconciliation engine, API routes, and BankReconciliationView component

Pagination (fetchAllRows):
- New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit
- Adopted in all report generators, SIE/SRU export, account list APIs

Fiscal period validation:
- New validate-period-duration.ts enforces max 18 months per BFL 3 kap.
- Applied in period-service.ts and fiscal-periods API

Account mapper simplification:
- Remove Levenshtein/fuzzy matching, use exact account number match only

Swedbank parser improvements:
- Support abbreviated headers (Clnr, Bokfdag, Radnr)
- Use Referens column as counterparty

Chart of accounts management:
- Add DELETE endpoint with system account and usage protection
- PUT uses partial updates
- New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager

Tax deadline corrections:
- Rewrite inkomstdeklaration_ab using Skatteverket lookup table
- Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3

Onboarding first fiscal year:
- Add first fiscal year toggle with date pickers and 18-month validation

UI terminology:
- Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout

Report column fix:
- Fix start_date/end_date to period_start/period_end in report queries

Supplier invoice input:
- CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept)

Misc:
- SIE import uses upsert for idempotent account creation
- account-descriptions.ts falls back to BAS reference data
- Add invoice_default_notes to CompanySettings
- Update CLAUDE.md to reflect current project state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-21 14:57:15 +01:00
parent f1d187c005
commit 91e2c1705a
106 changed files with 9950 additions and 1641 deletions
+57 -12
View File
@@ -40,7 +40,13 @@ app/
components/
ui/ shadcn/ui primitives (button, card, dialog, table, etc.)
[feature]/ Feature-organized components (banking, invoices, suppliers, etc.)
bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs
calendar/ Calendar views, deadline cards, payment summary, todo widgets
chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage
dashboard/ DashboardContent, DashboardNav, FSkattWarningCard
reports/ Report views (including BankReconciliationView)
settings/ CalendarFeedSettings
[feature]/ Feature-organized components (invoices, suppliers, import, etc.)
extensions/ First-party extension implementations
ai-categorization/ AI-powered transaction categorization
@@ -55,12 +61,15 @@ extensions/ First-party extension implementations
lib/
bookkeeping/ Core journal entry engine and all entry generators
engine.ts Draft/commit workflow, balance validation, voucher numbering
invoice-entries.ts Sales invoice journal entries
invoice-entries.ts Sales invoice journal entries (supports per-line VAT rates)
transaction-entries.ts Bank transaction journal entries
supplier-invoice-entries.ts Purchase invoice journal entries
category-mapping.ts Category-to-BAS-account mapping
mapping-engine.ts Rule-based auto-categorization (MCC codes, merchant patterns)
vat-entries.ts VAT line generation
bas-reference.ts BAS account catalog (~180 accounts with metadata, SRU codes)
account-descriptions.ts Human-readable account name lookup
validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.)
core/
bookkeeping/ Period service, storno reversal, year-end closing
documents/ Document archive (upload, versioning, SHA-256 integrity)
@@ -74,23 +83,28 @@ lib/
events/ Event bus (bus.ts, types.ts)
extensions/ Extension registry, loader, types
import/ SIE and bank file parser
invoice/ VAT rules for invoicing
invoices/ Invoice business logic helpers
bank-file/ Bank file parser with format modules
formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank
invoice/ VAT rules, invoice matching (vat-rules.ts, invoice-matching.ts)
invoices/ Invoice business logic (reminder-processor)
reconciliation/ Bank reconciliation engine (4-pass matching algorithm)
reports/ Financial reports (trial-balance, income-statement,
balance-sheet, vat-declaration, sie-export,
supplier-ledger, supplier-reconciliation,
general-ledger, journal-register,
ar-ledger, ar-reconciliation)
supabase/ Client setup (client.ts = browser, server.ts = server/admin)
ar-ledger, ar-reconciliation, monthly-breakdown)
supabase/ Client setup (client.ts = browser, server.ts = server/admin,
fetch-all.ts = pagination helper for large queries)
tax/ Tax calculations, deadlines, Swedish holidays
transactions/ Transaction processing helpers
init.ts Extension loader (idempotent, called by API routes)
utils.ts Shared utility functions
types/index.ts Canonical type definitions (110+ types, single source of truth)
types/index.ts Canonical type definitions (120+ types, single source of truth)
types/chat.ts Chat-specific type definitions
tests/helpers.ts Mock factories and fixture builders
supabase/migrations/ SQL migration files
scripts/ Utility scripts (clear-user-data.sql)
dev_docs/ Extensive project documentation (PRD, architecture, BAS guide, etc.)
```
@@ -118,10 +132,10 @@ The bookkeeping engine (`lib/bookkeeping/engine.ts`) is the most critical system
| Function | File | Purpose |
|----------|------|---------|
| `createInvoiceJournalEntry()` | `invoice-entries.ts` | Debit 1510, Credit 30xx + 26xx VAT |
| `createInvoiceJournalEntry()` | `invoice-entries.ts` | Debit 1510, Credit 30xx + 26xx VAT (per-line VAT rates) |
| `createInvoicePaymentJournalEntry()` | `invoice-entries.ts` | Debit 1930, Credit 1510 |
| `createCreditNoteJournalEntry()` | `invoice-entries.ts` | Reverses original invoice entry |
| `createInvoiceCashEntry()` | `invoice-entries.ts` | Cash method: revenue + VAT at payment |
| `createCreditNoteJournalEntry()` | `invoice-entries.ts` | Reverses original invoice entry (per-rate lines) |
| `createInvoiceCashEntry()` | `invoice-entries.ts` | Cash method: revenue + VAT at payment (per-rate) |
| `createTransactionJournalEntry()` | `transaction-entries.ts` | Maps bank transactions via MappingResult |
| `createSupplierInvoiceRegistrationEntry()` | `supplier-invoice-entries.ts` | Debit expense + 2641, Credit 2440 |
| `createSupplierInvoicePaymentEntry()` | `supplier-invoice-entries.ts` | Debit 2440, Credit 1930 |
@@ -147,6 +161,23 @@ The bookkeeping engine (`lib/bookkeeping/engine.ts`) is the most critical system
`standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt`
### Per-Line VAT
Invoice items support individual `vat_rate` values, enabling mixed-rate invoices. The helper `generatePerRateLines()` in `invoice-entries.ts` groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoice/vat-rules.ts`.
### Bank Reconciliation
The reconciliation engine (`lib/reconciliation/bank-reconciliation.ts`) matches bank transactions to journal entry lines on account 1930 using a 4-pass algorithm:
| Pass | Method | Confidence | Match Criteria |
|------|--------|------------|----------------|
| 1 | `auto_exact` | 0.95 | Exact amount + exact date |
| 2 | `auto_reference` | 0.90 | Exact amount + reference/description match |
| 3 | `auto_date_range` | 0.85 | Exact amount + date within ±3 days |
| 4 | `auto_fuzzy` | 0.75 | Fuzzy amount (±0.01) + exact date |
Manual linking (`manual` method) is also supported. Only SEK transactions are reconciled. Greedy assignment prevents double-matching.
---
## Accounting Guard Rails
@@ -251,6 +282,7 @@ All defined in `lib/events/types.ts`:
| `receipt.extracted` | `{ receipt, documentId, confidence, userId }` |
| `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` |
| `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` |
| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` |
| `audit.security_event` | `{ event, userId }` |
### Event Bus Behavior
@@ -305,10 +337,17 @@ mockResult({ data: makeTransaction(), error: null })
### Reference Tests
- `lib/bookkeeping/__tests__/engine.test.ts` — Balance validation
- `lib/bookkeeping/__tests__/invoice-entries.test.ts` — Per-line VAT, mixed-rate invoices, credit notes
- `lib/core/bookkeeping/__tests__/storno-service.test.ts` — Complex mock queues
- `lib/core/documents/__tests__/document-service.test.ts` — Storage mocking
- `lib/events/__tests__/bus.test.ts` — Event bus behavior
- `lib/extensions/__tests__/registry.test.ts` — Extension registration
- `lib/import/__tests__/sie-parser.test.ts` — SIE file parsing
- `lib/import/bank-file/__tests__/parser.test.ts` — Bank file format parsing
- `lib/reconciliation/__tests__/bank-reconciliation.test.ts` — Reconciliation matching algorithm
- `lib/reports/__tests__/vat-declaration.test.ts` — VAT declaration report
- `lib/tax/__tests__/deadline-config.test.ts` — Tax deadline configuration
- `lib/transactions/__tests__/ingest.test.ts` — Transaction ingestion and dedup
---
@@ -316,11 +355,11 @@ mockResult({ data: makeTransaction(), error: null })
### Location
`supabase/migrations/` — currently 28 files numbered `20240101000001` through `20240101000028`.
`supabase/migrations/` — currently 32 files numbered `20240101000001` through `20240101000032`.
### Naming Convention
`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000029_*.sql`
`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000033_*.sql`
### Migration Rules
@@ -354,6 +393,12 @@ mockResult({ data: makeTransaction(), error: null })
- `set_committed_at` — Auto-sets timestamp on draft-to-posted transition
- `calculate_retention_expiry` — Auto-sets `retention_expires_at = period_end + 7 years`
### Recent Migrations
- **Migration 030 (`bank_reconciliation`)** — Adds `reconciliation_method` column to `transactions` (CHECK constraint for method types), indexes for unmatched transaction lookup, and RPC `get_unlinked_1930_lines()` for finding unreconciled GL lines.
- **Migration 031 (`invoice_document_type`)** — Adds `document_type` column to `invoices` (CHECK: invoice/proforma/delivery_note, default 'invoice') and `converted_from_id` FK for tracking proforma-to-invoice conversions.
- **Migration 032 (`add_accounting_method`)** — Adds `accounting_method` column to `company_settings` (CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden.
---
## Type System
+2 -2
View File
@@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
import ChartOfAccounts from '@/components/bookkeeping/ChartOfAccounts'
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
import { Lock } from 'lucide-react'
export default function BookkeepingPage() {
@@ -45,7 +45,7 @@ export default function BookkeepingPage() {
</TabsContent>
<TabsContent value="accounts">
<ChartOfAccounts />
<ChartOfAccountsManager />
</TabsContent>
</Tabs>
</div>
+14 -8
View File
@@ -133,8 +133,10 @@ function BankFileImportWizard() {
description: `${txCount} transaktioner hittades`,
})
} else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) {
// Unrecognized format — show upload step with error
setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".')
} else {
// Format detected but no transactions parsed — parser couldn't extract rows
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
}
} catch (err) {
setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
@@ -293,8 +295,6 @@ function BankFileImportWizard() {
// SIE Import Wizard (unchanged, extracted into component)
// ============================================================
const SIE_STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result']
const SIE_STEP_LABELS: Record<ImportWizardStep, string> = {
upload: 'Ladda upp',
preview: 'Förhandsgranskning',
@@ -320,8 +320,14 @@ function SIEImportWizard() {
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
const currentStepIndex = SIE_STEPS.indexOf(step)
const progress = ((currentStepIndex + 1) / SIE_STEPS.length) * 100
// Skip the mapping step when all accounts are already mapped
const hasUnmapped = mappings.some((m) => !m.targetAccount)
const sieSteps: ImportWizardStep[] = hasUnmapped
? ['upload', 'preview', 'mapping', 'review', 'result']
: ['upload', 'preview', 'review', 'result']
const currentStepIndex = sieSteps.indexOf(step)
const progress = ((currentStepIndex + 1) / sieSteps.length) * 100
const handleFileSelect = useCallback(async (selectedFile: File) => {
setFile(selectedFile)
@@ -490,7 +496,7 @@ function SIEImportWizard() {
}, [file, mappings, toast])
const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) }
const goBack = () => { const i = SIE_STEPS.indexOf(step); if (i > 0) setStep(SIE_STEPS[i - 1]) }
const goBack = () => { const i = sieSteps.indexOf(step); if (i > 0) setStep(sieSteps[i - 1]) }
const handleNewImport = () => {
setStep('upload'); setFile(null); setParsed(null); setMappings([])
@@ -504,7 +510,7 @@ function SIEImportWizard() {
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
{SIE_STEPS.map((s, i) => (
{sieSteps.map((s, i) => (
<span key={s} className={i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'}>
{SIE_STEP_LABELS[s]}
</span>
@@ -519,7 +525,7 @@ function SIEImportWizard() {
{step === 'preview' && preview && (
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
onContinue={() => goToStep('mapping')} onBack={goBack} />
onContinue={() => goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} />
)}
{step === 'mapping' && (
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
+137 -13
View File
@@ -30,7 +30,7 @@ import {
AlertTriangle,
MessageSquare,
} from 'lucide-react'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder } from '@/types'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
draft: { label: 'Utkast', variant: 'secondary', icon: FileText },
@@ -63,6 +63,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
const [isConverting, setIsConverting] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isUpdating, setIsUpdating] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
@@ -139,6 +141,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
}
// If this invoice was converted from a proforma, fetch it
if (data.converted_from_id) {
const { data: convertedData } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('id', data.converted_from_id)
.single()
if (convertedData) {
setConvertedFromInvoice(convertedData as Invoice)
}
}
setIsLoading(false)
}
@@ -235,6 +250,38 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setIsSendingEmail(false)
}
async function convertToInvoice() {
if (!invoice) return
setIsConverting(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/convert`, {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte konvertera proformafakturan')
}
toast({
title: 'Konverterad till faktura',
description: `Faktura ${data.data.invoice_number} har skapats`,
})
router.push(`/invoices/${data.data.id}`)
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte konvertera',
variant: 'destructive',
})
}
setIsConverting(false)
}
async function downloadPDF() {
if (!invoice) return
@@ -288,6 +335,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const StatusIcon = status.icon
const customer = invoice.customer
const customerHasEmail = !!customer.email
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
const isRealInvoice = docType === 'invoice'
const docLabel = isProforma ? 'Proformafaktura' : isDeliveryNote ? 'Följesedel' : 'Faktura'
return (
<div className="space-y-6">
@@ -300,6 +352,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
{isProforma && (
<Badge variant="secondary" className="bg-blue-100 text-blue-700">Proforma</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">Följesedel</Badge>
)}
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
@@ -314,7 +372,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{/* Actions */}
<div className="flex items-center gap-2">
{invoice.status === 'draft' && (
{isProforma && invoice.status !== 'cancelled' && (
<Button onClick={convertToInvoice} disabled={isConverting}>
{isConverting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Konvertera till faktura
</Button>
)}
{invoice.status === 'draft' && !isDeliveryNote && (
customerHasEmail ? (
<Button onClick={sendInvoiceEmail} disabled={isSendingEmail}>
{isSendingEmail ? (
@@ -331,7 +399,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
)
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
{isDeliveryNote && invoice.status === 'draft' && (
<Button onClick={() => updateStatus('sent')} disabled={isUpdating}>
<Send className="mr-2 h-4 w-4" />
Markera som skickad
</Button>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
<Button onClick={() => updateStatus('paid')} disabled={isUpdating}>
<CheckCircle className="mr-2 h-4 w-4" />
Markera som betald
@@ -656,6 +730,29 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Card>
)}
{/* Converted from proforma */}
{convertedFromInvoice && (
<Card className="border-blue-300">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-600">
<FileText className="h-5 w-5" />
Konverterad
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
Konverterad från proformafaktura
</p>
<Link href={`/invoices/${convertedFromInvoice.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
Se proforma {convertedFromInvoice.invoice_number}
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Status actions */}
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
<Card>
@@ -663,9 +760,34 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<CardTitle>Åtgärder</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{invoice.status === 'draft' && (
{isProforma && (
<>
{customerHasEmail ? (
<Button
className="w-full"
onClick={convertToInvoice}
disabled={isConverting}
>
{isConverting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
Konvertera till faktura
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => updateStatus('cancelled')}
disabled={isUpdating}
>
<XCircle className="mr-2 h-4 w-4" />
Makulera
</Button>
</>
)}
{!isProforma && invoice.status === 'draft' && (
<>
{!isDeliveryNote && customerHasEmail ? (
<Button
className="w-full"
onClick={sendInvoiceEmail}
@@ -680,12 +802,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
) : (
<>
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-700">
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
</p>
</div>
{!isDeliveryNote && (
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-700">
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
</p>
</div>
)}
<Button
className="w-full"
onClick={() => updateStatus('sent')}
@@ -707,7 +831,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
</>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
<>
<Button
className="w-full"
@@ -734,7 +858,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</Button>
</>
)}
{invoice.status === 'paid' && (
{invoice.status === 'paid' && isRealInvoice && (
<Link href={`/invoices/${invoice.id}/credit`} className="block">
<Button variant="outline" className="w-full">
<ReceiptText className="mr-2 h-4 w-4" />
+188 -21
View File
@@ -16,18 +16,19 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
import { getVatRules, getVatTreatmentLabel, getAvailableVatRates } from '@/lib/invoice/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send } from 'lucide-react'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import type { Customer, Currency, CreateInvoiceInput } from '@/types'
import type { Customer, Currency, CreateInvoiceInput, InvoiceDocumentType } from '@/types'
const itemSchema = z.object({
description: z.string().min(1, 'Beskrivning krävs'),
quantity: z.number().min(0.01, 'Minst 0.01'),
unit: z.string().min(1, 'Enhet krävs'),
unit_price: z.number().min(0, 'Pris måste vara positivt'),
vat_rate: z.number().min(0).max(25),
})
const schema = z.object({
@@ -35,6 +36,7 @@ const schema = z.object({
invoice_date: z.string().min(1, 'Fakturadatum krävs'),
due_date: z.string().min(1, 'Förfallodatum krävs'),
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
document_type: z.enum(['invoice', 'proforma', 'delivery_note']),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
@@ -62,6 +64,8 @@ export default function NewInvoicePage() {
const [createdInvoiceId, setCreatedInvoiceId] = useState<string | null>(null)
const [showSendPrompt, setShowSendPrompt] = useState(false)
const [isSending, setIsSending] = useState(false)
const [isPreviewing, setIsPreviewing] = useState(false)
const [defaultNotes, setDefaultNotes] = useState<string | null>(null)
const {
register,
@@ -77,7 +81,8 @@ export default function NewInvoicePage() {
invoice_date: '',
due_date: '',
currency: 'SEK',
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0 }],
document_type: 'invoice' as InvoiceDocumentType,
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
},
})
@@ -95,11 +100,24 @@ export default function NewInvoicePage() {
const watchItems = watch('items')
const watchCurrency = watch('currency')
const watchCustomerId = watch('customer_id')
const watchDocumentType = watch('document_type') as InvoiceDocumentType
useEffect(() => {
fetchCustomers()
fetchDefaultNotes()
}, [])
async function fetchDefaultNotes() {
const { data } = await supabase
.from('company_settings')
.select('invoice_default_notes')
.single()
if (data?.invoice_default_notes) {
setDefaultNotes(data.invoice_default_notes)
setValue('notes', data.invoice_default_notes)
}
}
useEffect(() => {
if (watchCustomerId) {
const customer = customers.find((c) => c.id === watchCustomerId)
@@ -112,6 +130,17 @@ export default function NewInvoicePage() {
format(addDays(new Date(), customer.default_payment_terms), 'yyyy-MM-dd')
)
}
// When customer forces a single rate (reverse charge/export), update all lines
if (customer) {
const rates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
if (rates.length === 1) {
const forcedRate = rates[0].rate
watchItems.forEach((_, i) => {
setValue(`items.${i}.vat_rate`, forcedRate)
})
}
}
}
}, [watchCustomerId, customers, setValue])
@@ -141,7 +170,24 @@ export default function NewInvoicePage() {
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
: null
const vatAmount = vatRules ? subtotal * (vatRules.rate / 100) : 0
const availableRates = selectedCustomer
? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
: []
const isRateLocked = availableRates.length === 1
// Calculate per-item VAT
const vatByRate = new Map<number, { base: number; vat: number }>()
let vatAmount = 0
for (const item of watchItems) {
const rate = item.vat_rate ?? (vatRules?.rate || 25)
const lineTotal = (item.quantity || 0) * (item.unit_price || 0)
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatAmount += lineVat
const existing = vatByRate.get(rate) || { base: 0, vat: 0 }
existing.base += lineTotal
existing.vat += lineVat
vatByRate.set(rate, existing)
}
const total = subtotal + vatAmount
function onSubmit(data: FormData) {
@@ -166,9 +212,10 @@ export default function NewInvoicePage() {
throw new Error(result.error || 'Kunde inte skapa faktura')
}
const docLabel = watchDocumentType === 'proforma' ? 'Proformafaktura' : watchDocumentType === 'delivery_note' ? 'Följesedel' : 'Faktura'
toast({
title: 'Faktura skapad',
description: `Faktura ${result.data.invoice_number} har skapats`,
title: `${docLabel} skapad`,
description: `${docLabel} ${result.data.invoice_number} har skapats`,
})
setShowReview(false)
@@ -222,6 +269,46 @@ export default function NewInvoicePage() {
}
}
async function handlePreviewPDF() {
if (!pendingData) return
setIsPreviewing(true)
try {
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: pendingData.customer_id,
invoice_date: pendingData.invoice_date,
due_date: pendingData.due_date,
currency: pendingData.currency,
document_type: pendingData.document_type,
items: pendingData.items,
your_reference: pendingData.your_reference,
our_reference: pendingData.our_reference,
notes: pendingData.notes,
}),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte generera förhandsgranskning')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
window.open(url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte generera PDF',
variant: 'destructive',
})
} finally {
setIsPreviewing(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -237,8 +324,12 @@ export default function NewInvoicePage() {
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Ny faktura</h1>
<p className="text-muted-foreground">Skapa en ny faktura</p>
<h1 className="text-3xl font-bold tracking-tight">
{watchDocumentType === 'proforma' ? 'Ny proformafaktura' : watchDocumentType === 'delivery_note' ? 'Ny följesedel' : 'Ny faktura'}
</h1>
<p className="text-muted-foreground">
{watchDocumentType === 'proforma' ? 'Skapa en proformafaktura (ingen bokföring)' : watchDocumentType === 'delivery_note' ? 'Skapa en följesedel (utan priser)' : 'Skapa en ny faktura'}
</p>
</div>
</div>
@@ -300,7 +391,7 @@ export default function NewInvoicePage() {
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="grid gap-4 md:grid-cols-12 items-start">
<div className="md:col-span-5 space-y-2">
<div className="md:col-span-4 space-y-2">
<Label>Beskrivning</Label>
<Input
placeholder="T.ex. Instagram-kampanj"
@@ -312,7 +403,7 @@ export default function NewInvoicePage() {
</p>
)}
</div>
<div className="md:col-span-2 space-y-2">
<div className="md:col-span-1 space-y-2">
<Label>Antal</Label>
<Input
type="number"
@@ -349,6 +440,31 @@ export default function NewInvoicePage() {
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label>Moms</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field }) => (
<Select
value={String(field.value ?? 25)}
onValueChange={(v) => field.onChange(Number(v))}
disabled={isRateLocked}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableRates.map((opt) => (
<SelectItem key={opt.rate} value={String(opt.rate)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="md:col-span-1 flex items-end">
<Button
type="button"
@@ -367,7 +483,7 @@ export default function NewInvoicePage() {
type="button"
variant="outline"
onClick={() =>
append({ description: '', quantity: 1, unit: 'st', unit_price: 0 })
append({ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: availableRates[0]?.rate ?? 25 })
}
>
<Plus className="mr-2 h-4 w-4" />
@@ -400,6 +516,26 @@ export default function NewInvoicePage() {
<CardTitle>Fakturadetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Dokumenttyp</Label>
<Controller
name="document_type"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="invoice">Faktura</SelectItem>
<SelectItem value="proforma">Proformafaktura</SelectItem>
<SelectItem value="delivery_note">Följesedel</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Valuta</Label>
<Controller
@@ -459,12 +595,21 @@ export default function NewInvoicePage() {
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(subtotal, watchCurrency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
Moms ({vatRules?.rate || 25}%)
</span>
<span>{formatCurrency(vatAmount, watchCurrency)}</span>
</div>
{Array.from(vatByRate.entries())
.filter(([, group]) => group.vat > 0)
.sort(([a], [b]) => b - a)
.map(([rate, group]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(group.vat, watchCurrency)}</span>
</div>
))}
{vatByRate.size === 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatCurrency(0, watchCurrency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
@@ -487,15 +632,37 @@ export default function NewInvoicePage() {
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska faktura"
warningText="En faktura skapas och en verifikation bokförs. Verifikationen kan inte ändras efteråt."
title={watchDocumentType === 'proforma' ? 'Granska proformafaktura' : watchDocumentType === 'delivery_note' ? 'Granska följesedel' : 'Granska faktura'}
warningText={watchDocumentType === 'invoice'
? 'En faktura skapas och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en kreditnota.'
: watchDocumentType === 'proforma'
? 'En proformafaktura skapas. Ingen verifikation bokförs. Proforman kan senare konverteras till en riktig faktura.'
: 'En följesedel skapas utan priser. Ingen verifikation bokförs.'}
confirmLabel={watchDocumentType === 'proforma' ? 'Skapa proformafaktura' : watchDocumentType === 'delivery_note' ? 'Skapa följesedel' : 'Bekräfta & skapa'}
extraActions={
<Button
variant="outline"
onClick={handlePreviewPDF}
disabled={isPreviewing || isSubmitting}
>
{isPreviewing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
{isPreviewing ? 'Genererar...' : 'Förhandsgranska PDF'}
</Button>
}
>
<InvoiceReviewContent
customer={selectedCustomer}
invoiceDate={pendingData?.invoice_date || ''}
dueDate={pendingData?.due_date || ''}
currency={(pendingData?.currency || 'SEK') as Currency}
items={pendingData?.items || []}
items={(pendingData?.items || []).map((item) => ({
...item,
vat_rate: item.vat_rate ?? (vatRules?.rate || 25),
}))}
subtotal={subtotal}
vatRate={vatRules.rate}
vatAmount={vatAmount}
+23 -5
View File
@@ -11,7 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle } from 'lucide-react'
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle, FileQuestion, Truck } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import type { Invoice, InvoiceStatus } from '@/types'
@@ -82,11 +82,14 @@ export default function InvoicesPage() {
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
const isCreditNote = !!invoice.credited_invoice_id
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const matchesTab =
activeTab === 'all' ||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote) ||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') ||
(activeTab === 'credit' && isCreditNote) ||
invoice.status === activeTab
(activeTab === 'proforma' && docType === 'proforma') ||
(activeTab === 'delivery_note' && docType === 'delivery_note') ||
(activeTab !== 'proforma' && activeTab !== 'delivery_note' && invoice.status === activeTab)
return matchesSearch && matchesTab
})
@@ -181,6 +184,8 @@ export default function InvoicesPage() {
<TabsTrigger value="unpaid">Obetalda</TabsTrigger>
<TabsTrigger value="paid">Betalda</TabsTrigger>
<TabsTrigger value="draft">Utkast</TabsTrigger>
<TabsTrigger value="proforma">Proforma</TabsTrigger>
<TabsTrigger value="delivery_note">Följesedel</TabsTrigger>
<TabsTrigger value="credit">Kredit</TabsTrigger>
</TabsList>
</Tabs>
@@ -232,9 +237,12 @@ export default function InvoicesPage() {
{filteredInvoices.map((invoice) => {
const status = statusConfig[invoice.status]
const isCreditNote = !!invoice.credited_invoice_id
const StatusIcon = isCreditNote ? ReceiptText : status.icon
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
const StatusIcon = isCreditNote ? ReceiptText : isProforma ? FileQuestion : isDeliveryNote ? Truck : status.icon
const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : `border-l-4 ${status.borderColor}`
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : isProforma ? 'border-l-4 border-l-blue-400' : isDeliveryNote ? 'border-l-4 border-l-emerald-400' : `border-l-4 ${status.borderColor}`
return (
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
@@ -253,6 +261,16 @@ export default function InvoicesPage() {
Kredit
</Badge>
)}
{isProforma && (
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700">
Proforma
</Badge>
)}
{isDeliveryNote && (
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700">
Följesedel
</Badge>
)}
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
</Badge>
+10 -2
View File
@@ -6,10 +6,11 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight } from 'lucide-react'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight, ArrowLeftRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart'
@@ -82,7 +83,7 @@ export default function ReportsPage() {
>
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.name} ({p.period_start} {p.period_end})
</option>
))}
</select>
@@ -146,6 +147,10 @@ export default function ReportsPage() {
<Building2 className="h-4 w-4 mr-1" />
Lev.reskontra
</TabsTrigger>
<TabsTrigger value="bank-reconciliation">
<ArrowLeftRight className="h-4 w-4 mr-1" />
Bankavstämning
</TabsTrigger>
</TabsList>
<div className="absolute right-0 top-0 bottom-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none md:hidden" />
</div>
@@ -182,6 +187,9 @@ export default function ReportsPage() {
<TabsContent value="supplier-ledger">
<SupplierLedgerView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="bank-reconciliation">
<BankReconciliationView />
</TabsContent>
</Tabs>
) : (
<Card>
+26 -26
View File
@@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -134,6 +135,7 @@ export default function SettingsPage() {
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
accounting_method: formData.get('accounting_method') as string || 'accrual',
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
}
try {
@@ -457,37 +459,35 @@ export default function SettingsPage() {
<div className="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>
)}
<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.'
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden enligt BFL. Mindre aktiebolag kan välja kontantmetoden.'
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="invoice_default_notes">Standardtext fakturor</Label>
<Textarea
id="invoice_default_notes"
name="invoice_default_notes"
rows={3}
placeholder="T.ex. betalningsvillkor, leveransinfo..."
defaultValue={settings?.invoice_default_notes || ''}
/>
<p className="text-xs text-muted-foreground">
Texten föreslås automatiskt i anteckningsfältet vid ny faktura.
</p>
</div>
</CardContent>
</Card>
+73 -75
View File
@@ -11,16 +11,16 @@ import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Plus, Trash2, Loader2 } from 'lucide-react'
import { ArrowLeft, Plus, Trash2 } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import type { Supplier, VatTreatment } from '@/types'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import type { Supplier, BASAccount, VatTreatment } from '@/types'
interface LineItem {
description: string
quantity: number
unit: string
unit_price: number
amount: number
account_number: string
vat_rate: number
}
@@ -33,7 +33,6 @@ interface FormData {
delivery_date: string
currency: string
exchange_rate: string
vat_treatment: VatTreatment
reverse_charge: boolean
payment_reference: string
notes: string
@@ -44,15 +43,31 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment {
if (reverseCharge) return 'reverse_charge'
const rates = new Set(items.map((i) => i.vat_rate))
if (rates.size === 1) {
const rate = rates.values().next().value!
if (rate === 0.25) return 'standard_25'
if (rate === 0.12) return 'reduced_12'
if (rate === 0.06) return 'reduced_6'
if (rate === 0) return 'exempt'
}
return 'standard_25'
}
export default function NewSupplierInvoicePage() {
const router = useRouter()
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const { register, control, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>({
const { register, control, handleSubmit, watch, setValue } = useForm<FormData>({
defaultValues: {
supplier_id: '',
supplier_invoice_number: '',
@@ -61,16 +76,13 @@ export default function NewSupplierInvoicePage() {
delivery_date: '',
currency: 'SEK',
exchange_rate: '',
vat_treatment: 'standard_25',
reverse_charge: false,
payment_reference: '',
notes: '',
items: [
{
description: '',
quantity: 1,
unit: 'st',
unit_price: 0,
amount: 0,
account_number: '5010',
vat_rate: 0.25,
},
@@ -85,6 +97,7 @@ export default function NewSupplierInvoicePage() {
useEffect(() => {
fetchSuppliers()
fetchAccounts()
}, [])
// Auto-fill due date when supplier is selected
@@ -106,7 +119,6 @@ export default function NewSupplierInvoicePage() {
}
if (supplier.supplier_type === 'eu_business') {
setValue('reverse_charge', true)
setValue('vat_treatment', 'reverse_charge')
}
}
}
@@ -118,9 +130,27 @@ export default function NewSupplierInvoicePage() {
setSuppliers(data || [])
}
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}
function handleAccountChange(index: number, accountNumber: string) {
setValue(`items.${index}.account_number`, accountNumber)
// Auto-fill description from account name if description is empty
const currentDesc = watch(`items.${index}.description`)
if (!currentDesc && accountNumber.length === 4) {
const desc = getAccountDescription(accountNumber)
if (desc) {
setValue(`items.${index}.description`, desc.name)
}
}
}
// Calculate totals
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 100) / 100
const lineTotal = Math.round((item.amount || 0) * 100) / 100
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
return { lineTotal, vatAmount }
})
@@ -147,6 +177,8 @@ export default function NewSupplierInvoicePage() {
if (!pendingData) return
setIsSubmitting(true)
const vatTreatment = inferVatTreatment(pendingData.items, pendingData.reverse_charge)
const payload = {
supplier_id: pendingData.supplier_id,
supplier_invoice_number: pendingData.supplier_invoice_number,
@@ -155,15 +187,13 @@ export default function NewSupplierInvoicePage() {
delivery_date: pendingData.delivery_date || undefined,
currency: pendingData.currency,
exchange_rate: pendingData.exchange_rate ? parseFloat(pendingData.exchange_rate) : undefined,
vat_treatment: pendingData.vat_treatment,
vat_treatment: vatTreatment,
reverse_charge: pendingData.reverse_charge,
payment_reference: pendingData.payment_reference || undefined,
notes: pendingData.notes || undefined,
items: pendingData.items.map((item) => ({
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
amount: item.amount,
account_number: item.account_number,
vat_rate: item.vat_rate,
})),
@@ -270,13 +300,13 @@ export default function NewSupplierInvoicePage() {
</CardContent>
</Card>
{/* Currency & VAT */}
{/* Currency & Reverse Charge */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Valuta & moms</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Valuta</Label>
<Controller
@@ -310,27 +340,6 @@ export default function NewSupplierInvoicePage() {
/>
</div>
)}
<div className="space-y-2">
<Label>Momsbehandling</Label>
<Controller
name="vat_treatment"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard_25">Standard 25%</SelectItem>
<SelectItem value="reduced_12">Reducerad 12%</SelectItem>
<SelectItem value="reduced_6">Reducerad 6%</SelectItem>
<SelectItem value="reverse_charge">Omvänd skattskyldighet</SelectItem>
<SelectItem value="exempt">Momsfritt</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Controller
@@ -351,7 +360,7 @@ export default function NewSupplierInvoicePage() {
{/* Line Items */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Rader</CardTitle>
<CardTitle className="text-lg">Kontering</CardTitle>
<Button
type="button"
variant="outline"
@@ -359,10 +368,8 @@ export default function NewSupplierInvoicePage() {
onClick={() =>
append({
description: '',
quantity: 1,
unit: 'st',
unit_price: 0,
account_number: '5010',
amount: 0,
account_number: '',
vat_rate: 0.25,
})
}
@@ -375,20 +382,30 @@ export default function NewSupplierInvoicePage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 w-28">Konto</th>
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-16">Antal</th>
<th className="pb-2 w-16">Enhet</th>
<th className="pb-2 w-28">À-pris (exkl.)</th>
<th className="pb-2 w-24">Konto</th>
<th className="pb-2 w-32">Belopp (exkl.)</th>
<th className="pb-2 w-24">Momssats</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.id} className="border-b last:border-0">
<tr key={field.id} className="border-b last:border-0 align-top">
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
control={control}
render={({ field: f }) => (
<AccountCombobox
value={f.value}
accounts={accounts}
onChange={(val) => handleAccountChange(index, val)}
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Input
placeholder="Beskrivning"
@@ -399,23 +416,8 @@ export default function NewSupplierInvoicePage() {
<Input
type="number"
step="0.01"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
</td>
<td className="py-2 pr-2">
<Input {...register(`items.${index}.unit`)} />
</td>
<td className="py-2 pr-2">
<Input
type="number"
step="0.01"
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
/>
</td>
<td className="py-2 pr-2">
<Input
placeholder="5010"
{...register(`items.${index}.account_number`)}
placeholder="0,00"
{...register(`items.${index}.amount`, { valueAsNumber: true })}
/>
</td>
<td className="py-2 pr-2">
@@ -440,13 +442,10 @@ export default function NewSupplierInvoicePage() {
)}
/>
</td>
<td className="py-2 pr-2 text-right font-mono">
{formatAmount(itemTotals[index]?.lineTotal || 0)}
</td>
<td className="py-2 pr-2 text-right font-mono">
<td className="py-2 pr-2 text-right font-mono pt-4">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
</td>
<td className="py-2">
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button
type="button"
@@ -515,7 +514,7 @@ export default function NewSupplierInvoicePage() {
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska leverantörsfaktura"
warningText="Leverantörsfakturan registreras och en verifikation bokförs. Verifikationen kan inte ändras efteråt."
warningText="Leverantörsfakturan registreras och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en ändringsverifikation."
confirmLabel="Bekräfta & registrera"
>
<SupplierInvoiceReviewContent
@@ -526,7 +525,6 @@ export default function NewSupplierInvoicePage() {
deliveryDate={pendingData.delivery_date || undefined}
currency={pendingData.currency}
exchangeRate={pendingData.exchange_rate || undefined}
vatTreatment={pendingData.vat_treatment}
reverseCharge={pendingData.reverse_charge}
paymentReference={pendingData.payment_reference || undefined}
items={pendingData.items}
+18 -19
View File
@@ -188,18 +188,18 @@ export default function TransactionsPage() {
if (result.journal_entry_created) {
toast({
title: 'Bokförd',
description: 'Transaktion kategoriserad och verifikation skapad',
description: 'Transaktion bokförd och verifikation skapad',
})
} else if (result.journal_entry_error) {
toast({
title: 'Kategoriserad',
description: `Bokföring misslyckades: ${result.journal_entry_error}`,
title: 'Delvis bokförd',
description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`,
variant: 'destructive',
})
} else {
toast({
title: 'Kategoriserad',
description: 'Transaktion uppdaterad men kunde inte bokföras',
title: 'Delvis bokförd',
description: 'Transaktion uppdaterad men verifikation kunde inte skapas',
})
}
@@ -207,7 +207,7 @@ export default function TransactionsPage() {
} catch (err) {
toast({
title: 'Fel',
description: 'Något gick fel vid kategorisering',
description: 'Något gick fel vid bokföring',
variant: 'destructive',
})
return false
@@ -415,7 +415,7 @@ export default function TransactionsPage() {
setShowBatchSelector(false)
toast({
title: 'Klart',
description: `${ids.length} transaktioner kategoriserade`,
description: `${ids.length} transaktioner bokförda`,
})
exitBatchMode()
}
@@ -468,7 +468,7 @@ export default function TransactionsPage() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Transaktioner</h1>
<p className="text-muted-foreground">
Hantera och kategorisera dina transaktioner
Hantera och bokför dina transaktioner
</p>
</div>
<div className="flex gap-2">
@@ -482,7 +482,7 @@ export default function TransactionsPage() {
<>
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
<Sparkles className="mr-2 h-4 w-4" />
{isLoadingSuggestions ? 'Laddar...' : `Kategorisera (${uncategorizedTransactions.length})`}
{isLoadingSuggestions ? 'Laddar...' : `Bokför (${uncategorizedTransactions.length})`}
</Button>
<Button
variant={isBatchMode ? 'default' : 'outline'}
@@ -525,7 +525,7 @@ export default function TransactionsPage() {
<TabsList>
<TabsTrigger value="all">Alla</TabsTrigger>
<TabsTrigger value="uncategorized">
Ej kategoriserade
Ej bokförda
{uncategorizedTransactions.length > 0 && (
<Badge variant="secondary" className="ml-2">
{uncategorizedTransactions.length}
@@ -551,7 +551,7 @@ export default function TransactionsPage() {
<div className="flex items-start gap-3 px-4 py-3 rounded-lg border border-primary/20 bg-primary/[0.03]">
<Sparkles className="h-4 w-4 text-primary flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground flex-1">
<span className="font-medium text-foreground">Tips:</span> Klicka &quot;Kategorisera&quot; ovan för att snabbt svepkategorisera transaktioner en i taget. Använd &quot;Välj flera&quot; för att hantera flera samtidigt.
<span className="font-medium text-foreground">Tips:</span> Klicka &quot;Bokför&quot; ovan för att snabbt bokföra transaktioner en i taget. Använd &quot;Välj flera&quot; för att hantera flera samtidigt.
</p>
<button
onClick={() => {
@@ -612,7 +612,7 @@ export default function TransactionsPage() {
) : (
<div className="space-y-2">
{filteredTransactions.map((transaction) => {
const isUncategorized = transaction.is_business === null
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const isSelected = selectedIds.has(transaction.id)
const showCheckbox = isBatchMode && isUncategorized
@@ -653,7 +653,7 @@ export default function TransactionsPage() {
<p className="font-medium">{transaction.description}</p>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{formatDate(transaction.date)}</span>
{transaction.is_business !== null && (
{transaction.is_business !== null && !(transaction.is_business && transaction.category === 'uncategorized' && transaction.journal_entry_id) && (
<>
<span>·</span>
<Badge
@@ -674,7 +674,7 @@ export default function TransactionsPage() {
</Badge>
</>
)}
{transaction.journal_entry_id && (
{transaction.journal_entry_id ? (
<>
<span>·</span>
<Badge variant="outline" className="text-success border-success">
@@ -682,15 +682,14 @@ export default function TransactionsPage() {
Bokförd
</Badge>
</>
)}
{transaction.is_business === null && !transaction.potential_invoice && (
) : transaction.is_business === null && !transaction.potential_invoice ? (
<>
<span>·</span>
<Badge variant="outline" className="text-warning border-warning">
Ej kategoriserad
Ej bokförd
</Badge>
</>
)}
) : null}
{transaction.potential_invoice && !transaction.invoice_id && (
<>
<span>·</span>
@@ -753,7 +752,7 @@ export default function TransactionsPage() {
size="sm"
onClick={() => setShowBatchSelector(true)}
>
Kategorisera {selectedIds.size} st
Bokför {selectedIds.size} st
</Button>
</div>
)}
+69 -23
View File
@@ -102,8 +102,12 @@ function OnboardingPageContent() {
onboarding_step: nextStep || currentStep,
}
// Remove read-only fields before updating
const { id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, ...settingsToSave } = updatedSettings as Record<string, unknown>
// Remove read-only and transient fields before updating
const {
id: _id, user_id: _uid, created_at: _ca, updated_at: _ua,
is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye,
...settingsToSave
} = updatedSettings as Record<string, unknown>
const { error } = await supabase
.from('company_settings')
@@ -151,30 +155,70 @@ function OnboardingPageContent() {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1
const currentYear = new Date().getFullYear()
const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined
const firstYearStart = stepData.first_year_start as string | undefined
const firstYearEnd = stepData.first_year_end as string | undefined
const startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01`
let endYear: number
let endMonth: number
if (startMonth === 1) {
endYear = currentYear
endMonth = 12
let startStr: string
let endStr: string
let periodName: string
if (isFirstYear && firstYearStart && firstYearEnd) {
// First fiscal year: use exact dates provided
startStr = firstYearStart
endStr = firstYearEnd
const startYear = new Date(firstYearStart).getFullYear()
const endYear = new Date(firstYearEnd).getFullYear()
periodName = startYear === endYear
? `Första räkenskapsåret ${startYear}`
: `Första räkenskapsåret ${startYear}/${endYear}`
} else {
endYear = currentYear + 1
endMonth = startMonth - 1
}
const lastDay = new Date(endYear, endMonth, 0).getDate()
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
// Ongoing: compute 12-month period from fiscal_year_start_month
let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1
await supabase.from('fiscal_periods').upsert({
user_id: user.id,
name: `Räkenskapsår ${currentYear}`,
period_start: startStr,
period_end: endStr,
}, {
onConflict: 'user_id,period_start,period_end',
})
// For enskild firma: force calendar year
if (settings.entity_type === 'enskild_firma') {
startMonth = 1
}
const currentYear = new Date().getFullYear()
startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01`
let endYear: number
let endMonth: number
if (startMonth === 1) {
endYear = currentYear
endMonth = 12
} else {
endYear = currentYear + 1
endMonth = startMonth - 1
}
const lastDay = new Date(endYear, endMonth, 0).getDate()
endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
periodName = startMonth === 1
? `Räkenskapsår ${currentYear}`
: `Räkenskapsår ${currentYear}/${currentYear + 1}`
}
// Validate period duration (max 18 months)
const startDate = new Date(startStr)
const endDate = new Date(endStr)
const months = (endDate.getFullYear() - startDate.getFullYear()) * 12 +
(endDate.getMonth() - startDate.getMonth()) + 1
if (months > 18) {
console.error(`Period duration ${months} months exceeds 18-month maximum`)
} else {
await supabase.from('fiscal_periods').upsert({
user_id: user.id,
name: periodName,
period_start: startStr,
period_end: endStr,
}, {
onConflict: 'user_id,period_start,period_end',
})
}
}
} catch (err) {
console.error('Failed to create fiscal period:', err)
@@ -261,7 +305,9 @@ function OnboardingPageContent() {
vat_registered: settings.vat_registered ?? undefined,
vat_number: settings.vat_number ?? undefined,
moms_period: settings.moms_period as MomsPeriod | undefined,
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
}}
entityType={settings.entity_type as EntityType}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
+66 -6
View File
@@ -1,6 +1,63 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ number: string }> }
) {
const { number } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch the account to check if it's a system account
const { data: account, error: fetchError } = await supabase
.from('chart_of_accounts')
.select('id, is_system_account')
.eq('user_id', user.id)
.eq('account_number', number)
.single()
if (fetchError || !account) {
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
}
if (account.is_system_account) {
return NextResponse.json(
{ error: 'Systemkonton kan inte tas bort' },
{ status: 400 }
)
}
// Check if account is referenced in posted journal entries
const { count } = await supabase
.from('journal_entry_lines')
.select('id', { count: 'exact', head: true })
.eq('account_number', number)
if (count && count > 0) {
return NextResponse.json(
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
{ status: 400 }
)
}
const { error: deleteError } = await supabase
.from('chart_of_accounts')
.delete()
.eq('id', account.id)
.eq('user_id', user.id)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
return NextResponse.json({ success: true })
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ number: string }> }
@@ -15,14 +72,17 @@ export async function PUT(
const body = await request.json()
// Build update object with only provided fields
const updates: Record<string, unknown> = {}
if (body.account_name !== undefined) updates.account_name = body.account_name
if (body.is_active !== undefined) updates.is_active = body.is_active
if (body.description !== undefined) updates.description = body.description
if (body.default_vat_code !== undefined) updates.default_vat_code = body.default_vat_code
if (body.sru_code !== undefined) updates.sru_code = body.sru_code
const { data, error } = await supabase
.from('chart_of_accounts')
.update({
account_name: body.account_name,
is_active: body.is_active,
description: body.description,
default_vat_code: body.default_vat_code,
})
.update(updates)
.eq('user_id', user.id)
.eq('account_number', number)
.select()
@@ -0,0 +1,84 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
/**
* POST /api/bookkeeping/accounts/activate
*
* Batch-activate BAS accounts for a user.
* Accepts { account_numbers: string[] } and creates chart_of_accounts rows from reference data.
* Skips any accounts that already exist for the user.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const accountNumbers: string[] = body.account_numbers
if (!Array.isArray(accountNumbers) || accountNumbers.length === 0) {
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
}
// Check which accounts already exist
const { data: existing } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
.in('account_number', accountNumbers)
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
// Build rows for accounts that don't already exist
const newAccounts = accountNumbers
.filter((num) => !existingNumbers.has(num))
.map((num) => {
const ref = getBASReference(num)
if (!ref) return null
return {
user_id: user.id,
account_number: ref.account_number,
account_name: ref.account_name,
account_class: ref.account_class,
account_group: ref.account_group,
account_type: ref.account_type,
normal_balance: ref.normal_balance,
plan_type: 'full_bas' as const,
is_active: true,
is_system_account: false,
description: ref.description,
sru_code: ref.sru_code,
sort_order: parseInt(ref.account_number),
}
})
.filter(Boolean)
if (newAccounts.length === 0) {
return NextResponse.json({
data: [],
message: 'All accounts already activated',
activated: 0,
skipped: accountNumbers.length,
})
}
const { data, error } = await supabase
.from('chart_of_accounts')
.insert(newAccounts)
.select()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({
data,
activated: data?.length || 0,
skipped: accountNumbers.length - (data?.length || 0),
})
}
@@ -0,0 +1,62 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
/**
* GET /api/bookkeeping/accounts/reference
*
* Returns the full BAS reference catalog merged with the user's activation status.
* Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
try {
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, is_active, is_system_account')
.eq('user_id', user.id)
.range(from, to)
)
// Build lookup map
const userAccountMap = new Map(
userAccounts.map((a) => [a.account_number, a])
)
// Merge reference with user status
const merged = BAS_REFERENCE.map((ref) => {
const userAccount = userAccountMap.get(ref.account_number)
return {
...ref,
is_activated: !!userAccount,
is_active: userAccount?.is_active ?? false,
is_system_account: userAccount?.is_system_account ?? false,
}
})
// Also identify custom accounts (in user's chart but not in BAS reference)
const basNumbers = new Set(BAS_REFERENCE.map((r) => r.account_number))
const customAccounts = userAccounts
.filter((a) => !basNumbers.has(a.account_number))
.map((a) => ({
account_number: a.account_number,
is_custom: true,
is_activated: true,
is_active: a.is_active,
is_system_account: a.is_system_account,
}))
return NextResponse.json({ data: merged, customAccounts })
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
}
}
+22 -19
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
@@ -13,27 +14,29 @@ export async function GET(request: Request) {
const accountClass = searchParams.get('class')
const activeOnly = searchParams.get('active') !== 'false'
let query = supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.order('sort_order')
try {
const data = await fetchAllRows(({ from, to }) => {
let query = supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.order('sort_order')
if (activeOnly) {
query = query.eq('is_active', true)
if (activeOnly) {
query = query.eq('is_active', true)
}
if (accountClass) {
query = query.eq('account_class', parseInt(accountClass))
}
return query.range(from, to)
})
return NextResponse.json({ data })
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
}
if (accountClass) {
query = query.eq('account_class', parseInt(accountClass))
}
const { data, error } = await query
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
export async function POST(request: Request) {
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { CreateFiscalPeriodInput } from '@/types'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
export async function GET() {
const supabase = await createClient()
@@ -33,6 +34,12 @@ export async function POST(request: Request) {
const body = await request.json() as CreateFiscalPeriodInput
// Validate period duration (max 18 months per BFL 3 kap.)
const durationError = validatePeriodDuration(body.period_start, body.period_end)
if (durationError) {
return NextResponse.json({ error: durationError }, { status: 400 })
}
const { data, error } = await supabase
.from('fiscal_periods')
.insert({
+15 -28
View File
@@ -71,27 +71,8 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
}
// Fetch existing accounts to avoid duplicates
const { data: existingAccounts } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('user_id', user.id)
const existingNumbers = new Set(existingAccounts?.map(a => a.account_number) || [])
// Filter to only accounts that don't exist
const newAccounts = accounts.filter(a => !existingNumbers.has(a.number))
if (newAccounts.length === 0) {
return NextResponse.json({
success: true,
created: 0,
message: 'All accounts already exist'
})
}
// Prepare accounts for insertion
const accountsToInsert = newAccounts.map(account => {
// Prepare accounts for upsert (idempotent — safe to retry)
const accountsToUpsert = accounts.map(account => {
const accountClass = parseInt(account.number.charAt(0), 10) || 1
const accountGroup = account.number.substring(0, 2)
const accountType = getAccountType(account.number)
@@ -112,26 +93,32 @@ export async function POST(request: Request) {
}
})
// Insert in batches of 100 to avoid timeout
// Upsert in batches of 100 to avoid timeout
// ignoreDuplicates skips rows that already exist (no update)
const batchSize = 100
let totalCreated = 0
for (let i = 0; i < accountsToInsert.length; i += batchSize) {
const batch = accountsToInsert.slice(i, i + batchSize)
for (let i = 0; i < accountsToUpsert.length; i += batchSize) {
const batch = accountsToUpsert.slice(i, i + batchSize)
const { error } = await supabase
const { data: upserted, error } = await supabase
.from('chart_of_accounts')
.insert(batch)
.upsert(batch, {
onConflict: 'user_id,account_number',
ignoreDuplicates: true,
count: 'exact',
})
.select('account_number')
if (error) {
console.error('Error inserting accounts batch:', error)
console.error('Error upserting accounts batch:', error)
return NextResponse.json({
error: `Failed to create accounts: ${error.message}`,
created: totalCreated,
}, { status: 500 })
}
totalCreated += batch.length
totalCreated += upserted?.length ?? batch.length
}
return NextResponse.json({
+12 -8
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { suggestMappings } from '@/lib/import/account-mapper'
@@ -53,15 +54,18 @@ export async function POST(request: Request) {
if (mappingsJson) {
mappings = JSON.parse(mappingsJson)
} else {
// Fetch user's chart of accounts and generate mappings
const { data: basAccounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
const basAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
if (!basAccounts || basAccounts.length === 0) {
if (basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
+12 -8
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { NextResponse } from 'next/server'
import {
parseSIEFile,
@@ -77,15 +78,18 @@ export async function POST(request: Request) {
}, { status: 400 })
}
// Fetch user's chart of accounts
const { data: basAccounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
const basAccounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', user.id)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
if (!basAccounts || basAccounts.length === 0) {
if (basAccounts.length === 0) {
return NextResponse.json({
error: 'No chart of accounts found. Please complete onboarding first.',
}, { status: 400 })
+137
View File
@@ -0,0 +1,137 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import type { Invoice } from '@/types'
ensureInitialized()
/**
* POST /api/invoices/[id]/convert
*
* Converts a proforma invoice to a real invoice.
* Copies all data, generates a real invoice number, and marks the proforma as cancelled.
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch proforma with items
const { data: proforma, error: proformaError } = await supabase
.from('invoices')
.select('*, items:invoice_items(*)')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (proformaError || !proforma) {
return NextResponse.json({ error: 'Proformafakturan hittades inte' }, { status: 404 })
}
if (proforma.document_type !== 'proforma') {
return NextResponse.json(
{ error: 'Endast proformafakturor kan konverteras' },
{ status: 400 }
)
}
if (proforma.status === 'cancelled') {
return NextResponse.json(
{ error: 'Denna proformafaktura har redan makuleras' },
{ status: 400 }
)
}
// Generate real invoice number
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
// Create the real invoice
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.insert({
user_id: user.id,
customer_id: proforma.customer_id,
invoice_number: invoiceNumber,
invoice_date: new Date().toISOString().split('T')[0],
due_date: proforma.due_date,
currency: proforma.currency,
exchange_rate: proforma.exchange_rate,
exchange_rate_date: proforma.exchange_rate_date,
subtotal: proforma.subtotal,
subtotal_sek: proforma.subtotal_sek,
vat_amount: proforma.vat_amount,
vat_amount_sek: proforma.vat_amount_sek,
total: proforma.total,
total_sek: proforma.total_sek,
vat_treatment: proforma.vat_treatment,
vat_rate: proforma.vat_rate,
moms_ruta: proforma.moms_ruta,
reverse_charge_text: proforma.reverse_charge_text,
your_reference: proforma.your_reference,
our_reference: proforma.our_reference,
notes: proforma.notes,
document_type: 'invoice',
converted_from_id: id,
})
.select()
.single()
if (invoiceError) {
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
// Copy invoice items
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
invoice_id: invoice.id,
sort_order: item.sort_order,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.line_total,
}))
if (items.length > 0) {
const { error: itemsError } = await supabase
.from('invoice_items')
.insert(items)
if (itemsError) {
await supabase.from('invoices').delete().eq('id', invoice.id)
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
}
// Mark proforma as cancelled
await supabase
.from('invoices')
.update({ status: 'cancelled' })
.eq('id', id)
// Fetch complete invoice
const { data: completeInvoice } = await supabase
.from('invoices')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice.id)
.single()
if (completeInvoice) {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
})
}
return NextResponse.json({ data: completeInvoice })
}
+24 -20
View File
@@ -77,29 +77,33 @@ export async function POST(
const accountingMethod = settings?.accounting_method || 'accrual'
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
try {
if (accountingMethod === 'accrual') {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
user.id,
invoice as Invoice,
paymentDate
)
journalEntryId = journalEntry?.id ?? null
} else {
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
const journalEntry = await createInvoiceCashEntry(
user.id,
invoice as Invoice,
paymentDate,
entityType
)
journalEntryId = journalEntry?.id ?? null
if (isRealInvoice) {
try {
if (accountingMethod === 'accrual') {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
user.id,
invoice as Invoice,
paymentDate
)
journalEntryId = journalEntry?.id ?? null
} else {
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
const journalEntry = await createInvoiceCashEntry(
user.id,
invoice as Invoice,
paymentDate,
entityType
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
console.error('Failed to create payment journal entry on mark-paid:', err)
}
} catch (err) {
console.error('Failed to create payment journal entry on mark-paid:', err)
}
return NextResponse.json({
+4 -6
View File
@@ -45,10 +45,7 @@ export async function POST(
// Update status to sent
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'sent',
sent_at: new Date().toISOString(),
})
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
@@ -65,9 +62,10 @@ export async function POST(
const accountingMethod = settings?.accounting_method || 'accrual'
// Faktureringsmetoden: book at send
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
let journalEntryId: string | null = null
if (accountingMethod === 'accrual') {
if (isRealInvoice && accountingMethod === 'accrual') {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
+17 -11
View File
@@ -113,11 +113,19 @@ export async function POST(
company: company as CompanySettings
}
// Determine filename
// Determine filename based on document type
const isCreditNote = !!invoice.credited_invoice_id
const filename = isCreditNote
? `kreditfaktura-${invoice.invoice_number}.pdf`
: `faktura-${invoice.invoice_number}.pdf`
const docType = invoice.document_type || 'invoice'
let filename: string
if (isCreditNote) {
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'proforma') {
filename = `proformafaktura-${invoice.invoice_number}.pdf`
} else if (docType === 'delivery_note') {
filename = `foljesedel-${invoice.invoice_number}.pdf`
} else {
filename = `faktura-${invoice.invoice_number}.pdf`
}
// Send email
const result = await sendEmail({
@@ -144,13 +152,10 @@ export async function POST(
)
}
// Update invoice status to "sent" and set sent_at timestamp
// Update invoice status to "sent"
const { error: updateError } = await supabase
.from('invoices')
.update({
status: 'sent',
sent_at: new Date().toISOString()
})
.update({ status: 'sent' })
.eq('id', id)
.eq('user_id', user.id)
@@ -159,8 +164,9 @@ export async function POST(
// Don't fail the request - the email was sent successfully
}
// Faktureringsmetoden: create journal entry when invoice is issued
if ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method) {
// Only create journal entries for real invoices (not proformas or delivery notes)
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
if (isRealInvoice && ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method)) {
try {
const journalEntry = await createInvoiceJournalEntry(
user.id,
+134
View File
@@ -0,0 +1,134 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoice/pdf-template'
import { getVatRules } from '@/lib/invoice/vat-rules'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
/**
* POST /api/invoices/preview-pdf
*
* Generates a preview PDF from form data without creating an invoice.
* Returns the PDF as an inline blob for display in a new browser tab.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
if (!customer_id || !items || items.length === 0) {
return NextResponse.json({ error: 'Kunduppgifter och rader krävs' }, { status: 400 })
}
// Fetch customer
const { data: customer, error: customerError } = await supabase
.from('customers')
.select('*')
.eq('id', customer_id)
.eq('user_id', user.id)
.single()
if (customerError || !customer) {
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
}
// Fetch company settings
const { data: company, error: companyError } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (companyError || !company) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const docType: InvoiceDocumentType = document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
// Build items with line totals
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({
id: `preview-${index}`,
invoice_id: 'preview',
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: Math.round(item.quantity * item.unit_price * 100) / 100,
vat_rate: item.vat_rate ?? vatRules.rate,
vat_amount: 0,
created_at: new Date().toISOString(),
}))
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100
const total = isDeliveryNote ? 0 : subtotal + vatAmount
// Construct a temporary Invoice-like object
const previewInvoice = {
id: 'preview',
user_id: user.id,
customer_id,
invoice_number: 'FÖRHANDSGRANSKNING',
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
due_date: due_date || new Date().toISOString().split('T')[0],
status: 'draft',
currency: currency || 'SEK',
exchange_rate: null,
exchange_rate_date: null,
subtotal: isDeliveryNote ? 0 : subtotal,
subtotal_sek: null,
vat_amount: vatAmount,
vat_amount_sek: null,
total,
total_sek: null,
vat_treatment: vatRules.treatment,
vat_rate: isDeliveryNote ? 0 : vatRules.rate,
moms_ruta: vatRules.momsRuta,
your_reference: your_reference || null,
our_reference: our_reference || null,
notes: notes || null,
reverse_charge_text: vatRules.reverseChargeText || null,
credited_invoice_id: null,
document_type: docType,
converted_from_id: null,
paid_at: null,
paid_amount: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
} as Invoice
try {
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: previewInvoice,
customer: customer as Customer,
items: invoiceItems,
company: company as CompanySettings,
})
)
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'inline; filename="forhandsvisning.pdf"',
},
})
} catch (error) {
console.error('Preview PDF generation error:', error)
return NextResponse.json(
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
{ status: 500 }
)
}
}
+77 -30
View File
@@ -2,8 +2,8 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import type { CreateInvoiceInput, EntityType, Invoice, CreditNote } from '@/types'
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoice/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createCreditNoteJournalEntry,
@@ -67,6 +67,7 @@ export async function POST(request: Request) {
}
const invoiceInput = body as CreateInvoiceInput
const documentType: InvoiceDocumentType = body.document_type || 'invoice'
// Get customer for VAT calculation
const { data: customer, error: customerError } = await supabase
@@ -80,16 +81,37 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
}
// Calculate VAT rules
// Calculate VAT rules (default for customer)
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
const allowedRates = new Set(availableRates.map((r) => r.rate))
// Calculate subtotal from items
// Calculate per-item VAT and subtotals
const subtotal = invoiceInput.items.reduce((sum, item) => {
return sum + item.quantity * item.unit_price
}, 0)
const vatAmount = calculateVat(subtotal, vatRules.rate)
const total = subtotal + vatAmount
// Calculate VAT per item, respecting per-line vat_rate
let vatAmount = 0
if (documentType !== 'delivery_note') {
for (const item of invoiceInput.items) {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
// Validate rate is allowed for this customer
if (!allowedRates.has(itemRate)) {
return NextResponse.json(
{ error: `Momssats ${itemRate}% är inte tillåten för denna kundtyp` },
{ status: 400 }
)
}
const lineTotal = item.quantity * item.unit_price
vatAmount += Math.round(lineTotal * itemRate / 100 * 100) / 100
}
}
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
// Determine if this is a mixed-rate invoice
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
// Handle currency conversion
let exchangeRate: number | null = null
@@ -109,10 +131,15 @@ export async function POST(request: Request) {
}
}
// Generate invoice number
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
// Generate invoice number with appropriate prefix
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
const invoiceNumber = documentType === 'proforma'
? `PF-${baseNumber}`
: documentType === 'delivery_note'
? `FS-${baseNumber}`
: baseNumber
// Create invoice
const { data: invoice, error: invoiceError } = await supabase
@@ -126,19 +153,20 @@ export async function POST(request: Request) {
currency: invoiceInput.currency,
exchange_rate: exchangeRate,
exchange_rate_date: exchangeRateDate,
subtotal,
subtotal_sek: subtotalSek,
subtotal: documentType === 'delivery_note' ? 0 : subtotal,
subtotal_sek: documentType === 'delivery_note' ? null : subtotalSek,
vat_amount: vatAmount,
vat_amount_sek: vatAmountSek,
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
total,
total_sek: totalSek,
total_sek: documentType === 'delivery_note' ? null : totalSek,
vat_treatment: vatRules.treatment,
vat_rate: vatRules.rate,
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
moms_ruta: vatRules.momsRuta,
reverse_charge_text: vatRules.reverseChargeText || null,
your_reference: invoiceInput.your_reference,
our_reference: invoiceInput.our_reference,
notes: invoiceInput.notes,
document_type: documentType,
})
.select()
.single()
@@ -147,16 +175,23 @@ export async function POST(request: Request) {
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
}
// Create invoice items
const items = invoiceInput.items.map((item, index) => ({
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: item.quantity * item.unit_price,
}))
// Create invoice items with per-line VAT
const items = invoiceInput.items.map((item, index) => {
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
return {
invoice_id: invoice.id,
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
}
})
const { error: itemsError } = await supabase
.from('invoice_items')
@@ -175,8 +210,8 @@ export async function POST(request: Request) {
.eq('id', invoice.id)
.single()
// Emit event (no journal entry at draft — booking happens at send/payment based on accounting method)
if (completeInvoice) {
// Emit event only for real invoices (proformas and delivery notes are informational)
if (completeInvoice && documentType === 'invoice') {
await eventBus.emit({
type: 'invoice.created',
payload: { invoice: completeInvoice as Invoice, userId: user.id },
@@ -204,6 +239,14 @@ async function createCreditNote(
return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 })
}
// Credit notes can only be created from real invoices
if (originalInvoice.document_type && originalInvoice.document_type !== 'invoice') {
return NextResponse.json(
{ error: 'Credit notes can only be created from standard invoices' },
{ status: 400 }
)
}
// Check if invoice is already credited
if (originalInvoice.status === 'credited') {
return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 })
@@ -258,8 +301,8 @@ async function createCreditNote(
return NextResponse.json({ error: creditNoteError.message }, { status: 500 })
}
// Create credit note items (negated from original)
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }, index: number) => ({
// Create credit note items (negated from original, preserving per-line VAT)
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number; vat_rate?: number; vat_amount?: number }) => ({
invoice_id: creditNote.id,
sort_order: item.sort_order,
description: item.description,
@@ -267,6 +310,8 @@ async function createCreditNote(
unit: item.unit,
unit_price: item.unit_price,
line_total: -Math.abs(item.line_total),
vat_rate: item.vat_rate ?? 25,
vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0),
}))
const { error: itemsError } = await supabase
@@ -292,17 +337,19 @@ async function createCreditNote(
.eq('id', creditNote.id)
.single()
// Fetch entity type for correct account mapping
// Fetch entity type and accounting method for correct account mapping
const { data: creditNoteSettings } = await supabase
.from('company_settings')
.select('entity_type')
.select('entity_type, accounting_method')
.eq('user_id', userId)
.single()
const entityType = (creditNoteSettings?.entity_type as EntityType) || 'enskild_firma'
const accountingMethod = (creditNoteSettings?.accounting_method as AccountingMethod) || 'accrual'
// Create journal entry for the credit note (non-blocking)
if (completeCreditNote) {
// Cash method: skip — no original invoice entry exists to reverse; deferred until refund
if (completeCreditNote && accountingMethod === 'accrual') {
try {
const journalEntry = await createCreditNoteJournalEntry(
userId,
+33
View File
@@ -0,0 +1,33 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
ensureInitialized()
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { transaction_id, journal_entry_id } = body
if (!transaction_id || !journal_entry_id) {
return NextResponse.json(
{ error: 'transaction_id and journal_entry_id are required' },
{ status: 400 }
)
}
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ data: { success: true } })
}
+45
View File
@@ -0,0 +1,45 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
ensureInitialized()
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { date_from, date_to, dry_run } = body
const result = await runReconciliation(supabase, user.id, {
dateFrom: date_from,
dateTo: date_to,
dryRun: dry_run ?? false,
})
return NextResponse.json({
data: {
matches: result.matches.map((m) => ({
transaction_id: m.transaction.id,
transaction_date: m.transaction.date,
transaction_description: m.transaction.description,
transaction_amount: m.transaction.amount,
journal_entry_id: m.glLine.journal_entry_id,
voucher_number: m.glLine.voucher_number,
voucher_series: m.glLine.voucher_series,
entry_date: m.glLine.entry_date,
entry_description: m.glLine.entry_description,
method: m.method,
confidence: m.confidence,
})),
applied: result.applied,
errors: result.errors,
dry_run: dry_run ?? false,
},
})
}
@@ -0,0 +1,20 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo)
return NextResponse.json({ data: status })
}
@@ -0,0 +1,30 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { transaction_id } = body
if (!transaction_id) {
return NextResponse.json(
{ error: 'transaction_id is required' },
{ status: 400 }
)
}
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ data: { success: true } })
}
@@ -0,0 +1,20 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const dateFrom = searchParams.get('date_from') || undefined
const dateTo = searchParams.get('date_to') || undefined
const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo)
return NextResponse.json({ data: lines })
}
+12 -2
View File
@@ -4,7 +4,7 @@ import {
calculateVatDeclaration,
formatPeriodLabel,
} from '@/lib/reports/vat-declaration'
import type { VatPeriodType } from '@/types'
import type { VatPeriodType, AccountingMethod } from '@/types'
/**
* GET /api/reports/vat-declaration
@@ -90,12 +90,22 @@ export async function GET(request: Request) {
)
}
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
try {
const declaration = await calculateVatDeclaration(
user.id,
periodType,
year,
period
period,
accountingMethod
)
return NextResponse.json({
+10
View File
@@ -42,6 +42,16 @@ export async function PUT(request: Request) {
const body = await request.json()
// Validate: enskild firma must use calendar year (BFL 3 kap.)
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) {
return NextResponse.json(
{ error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' },
{ status: 400 }
)
}
const { data, error } = await supabase
.from('company_settings')
.update(body)
+29 -17
View File
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
export async function POST(
_request: Request,
@@ -88,24 +88,36 @@ export async function POST(
await supabase.from('supplier_invoice_items').insert(creditItems)
// Create credit note journal entry
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
// Create credit note journal entry (accrual only)
// Cash method: skip — no original registration entry exists to reverse; deferred until refund
let journalEntryId: string | null = null
try {
const journalEntry = await createSupplierCreditNoteEntry(
user.id,
creditNote as SupplierInvoice,
creditItems as SupplierInvoiceItem[],
original.supplier?.supplier_type || 'swedish_business'
)
if (journalEntry) {
journalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', creditNote.id)
if (accountingMethod === 'accrual') {
try {
const journalEntry = await createSupplierCreditNoteEntry(
user.id,
creditNote as SupplierInvoice,
creditItems as SupplierInvoiceItem[],
original.supplier?.supplier_type || 'swedish_business'
)
if (journalEntry) {
journalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', creditNote.id)
}
} catch (err) {
console.error('Failed to create credit note journal entry:', err)
}
} catch (err) {
console.error('Failed to create credit note journal entry:', err)
}
// Update original invoice: reduce remaining_amount
+7 -6
View File
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
@@ -69,17 +68,19 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
}
// Calculate totals from items
// Calculate totals from items (supports both amount-based and legacy quantity*price)
const items = body.items.map((item, index) => {
const vatRate = item.vat_rate ?? 0.25
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
const lineTotal = item.amount != null
? Math.round(item.amount * 100) / 100
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
return {
sort_order: index,
description: item.description,
quantity: item.quantity,
unit: item.unit || 'st',
unit_price: item.unit_price,
quantity: item.amount != null ? 1 : (item.quantity ?? 1),
unit: item.amount != null ? 'st' : (item.unit || 'st'),
unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0),
line_total: lineTotal,
account_number: item.account_number,
vat_code: item.vat_code || null,
+22 -118
View File
@@ -1,58 +1,15 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
getRevenueAccount,
getOutputVatAccount,
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import type { Transaction, Invoice, CreateJournalEntryInput, EntityType, VatTreatment } from '@/types'
import type { EntityType, Invoice } from '@/types'
interface MatchInvoiceRequest {
invoice_id: string
}
/**
* Ensure a fiscal period exists for the given date, create one if needed
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<string | null> {
// Check if a fiscal period already covers this date
const existingPeriodId = await findFiscalPeriod(userId, date)
if (existingPeriodId) {
return existingPeriodId
}
// No fiscal period exists - create one for the year of the transaction
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const periodStart = `${year}-01-01`
const periodEnd = `${year}-12-31`
const { data, error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: periodStart,
period_end: periodEnd,
}, {
onConflict: 'user_id,period_start,period_end',
})
.select('id')
.single()
if (error) {
console.error('Failed to create fiscal period:', error)
return null
}
return data?.id || null
}
/**
* POST /api/transactions/[id]/match-invoice
*
@@ -113,10 +70,10 @@ export async function POST(
)
}
// Fetch the invoice (validates ownership)
// Fetch the invoice with items (validates ownership, items needed for per-line VAT)
const { data: invoice, error: fetchInvError } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.select('*, customer:customers(*), items:invoice_items(*)')
.eq('id', invoice_id)
.eq('user_id', user.id)
.single()
@@ -151,76 +108,23 @@ export async function POST(
let journalEntryError: string | null = null
try {
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
if (fiscalPeriodId) {
let journalInput: CreateJournalEntryInput
if (accountingMethod === 'cash') {
// Kontantmetoden: combined revenue entry at payment
// Debit 1930 Företagskonto, Credit 30xx Försäljning, Credit 26xx Utgående moms
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment, entityType)
const lines: CreateJournalEntryInput['lines'] = [
{
account_number: '1930',
debit_amount: invoice.total,
credit_amount: 0,
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
},
{
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
},
]
if (invoice.vat_amount > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment as VatTreatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: invoice.vat_amount,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
})
}
journalInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
source_type: 'invoice_cash_payment',
source_id: invoice.id,
lines,
}
} else {
// Faktureringsmetoden: clear receivable
// Debit 1930 Företagskonto, Credit 1510 Kundfordringar
journalInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: transaction.date,
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
source_type: 'invoice_paid',
source_id: invoice.id,
lines: [
{
account_number: '1930',
debit_amount: paidAmount,
credit_amount: 0,
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
},
{
account_number: '1510',
debit_amount: 0,
credit_amount: paidAmount,
line_description: `Faktura ${invoice.invoice_number} betald`,
},
],
}
}
const journalEntry = await createJournalEntry(user.id, journalInput)
journalEntryId = journalEntry.id
if (accountingMethod === 'cash') {
// Kontantmetoden: combined revenue entry with per-line VAT rates
const journalEntry = await createInvoiceCashEntry(
user.id,
invoice as Invoice,
transaction.date,
entityType
)
journalEntryId = journalEntry?.id ?? null
} else {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
user.id,
invoice as Invoice,
transaction.date
)
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
console.error('Failed to create payment journal entry:', err)
+218
View File
@@ -0,0 +1,218 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import type { BASAccount } from '@/types'
interface AccountComboboxProps {
value: string
accounts: BASAccount[]
onChange: (accountNumber: string) => void
}
const MAX_RESULTS = 50
export default function AccountCombobox({ value, accounts, onChange }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Sync external value changes into the search field
useEffect(() => {
setSearch(value)
}, [value])
// Filter accounts based on search input
const filteredAccounts = useMemo(() => {
if (!search) return accounts.slice(0, MAX_RESULTS)
const trimmed = search.trim()
if (!trimmed) return accounts.slice(0, MAX_RESULTS)
const startsWithDigit = /^\d/.test(trimmed)
if (startsWithDigit) {
return accounts
.filter((a) => a.account_number.startsWith(trimmed))
.slice(0, MAX_RESULTS)
}
const lowerSearch = trimmed.toLowerCase()
return accounts
.filter((a) => a.account_name.toLowerCase().includes(lowerSearch))
.slice(0, MAX_RESULTS)
}, [accounts, search])
// Group filtered accounts by class
const groupedAccounts = useMemo(() => {
const groups: { className: string; accounts: BASAccount[] }[] = []
const groupMap = new Map<string, BASAccount[]>()
for (const account of filteredAccounts) {
const className = getAccountClassName(account.account_class)
if (!groupMap.has(className)) {
groupMap.set(className, [])
}
groupMap.get(className)!.push(account)
}
for (const [className, accts] of groupMap) {
groups.push({ className, accounts: accts })
}
return groups
}, [filteredAccounts])
// Flat list for keyboard navigation
const flatList = useMemo(() => filteredAccounts, [filteredAccounts])
// Reset highlight when filtered results change
useEffect(() => {
setHighlightedIndex(0)
}, [filteredAccounts])
// Scroll highlighted item into view
useEffect(() => {
if (!isOpen || !listRef.current) return
const highlighted = listRef.current.querySelector('[data-highlighted="true"]')
if (highlighted) {
highlighted.scrollIntoView({ block: 'nearest' })
}
}, [highlightedIndex, isOpen])
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const selectAccount = useCallback(
(accountNumber: string) => {
onChange(accountNumber)
setSearch(accountNumber)
setIsOpen(false)
inputRef.current?.blur()
},
[onChange]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
setIsOpen(true)
e.preventDefault()
}
return
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setHighlightedIndex((prev) => Math.min(prev + 1, flatList.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
break
case 'Enter':
e.preventDefault()
if (flatList[highlightedIndex]) {
selectAccount(flatList[highlightedIndex].account_number)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
break
}
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
setSearch(newValue)
onChange(newValue)
if (!isOpen) {
setIsOpen(true)
}
}
const handleFocus = () => {
setIsOpen(true)
}
// Find matching account for helper text
const matchedAccount = useMemo(() => {
if (!value || value.length !== 4) return null
return accounts.find((a) => a.account_number === value) || null
}, [value, accounts])
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
value={search}
onChange={handleInputChange}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder="1930"
className="font-mono h-8"
maxLength={4}
autoComplete="off"
/>
{/* Account name helper text (md+ screens only) */}
{matchedAccount && (
<p className="hidden md:block text-[11px] text-muted-foreground truncate mt-0.5 leading-tight">
{matchedAccount.account_name}
</p>
)}
{/* Dropdown */}
{isOpen && flatList.length > 0 && (
<div
ref={listRef}
className="absolute z-50 top-full left-0 mt-1 w-64 max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
>
{groupedAccounts.map((group) => (
<div key={group.className}>
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
{group.className}
</div>
{group.accounts.map((account) => {
const flatIndex = flatList.indexOf(account)
const isHighlighted = flatIndex === highlightedIndex
return (
<button
key={account.account_number}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
}`}
onMouseDown={(e) => {
e.preventDefault()
selectAccount(account.account_number)
}}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="truncate">{account.account_name}</span>
</button>
)
})}
</div>
))}
</div>
)}
</div>
)
}
+225
View File
@@ -0,0 +1,225 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
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 { Loader2, AlertTriangle } from 'lucide-react'
import { isStandardBASAccount } from '@/lib/bookkeeping/bas-reference'
interface AddAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}
function deriveAccountType(accountNumber: string): { type: string; balance: string } {
const cls = parseInt(accountNumber[0])
switch (cls) {
case 1: return { type: 'asset', balance: 'debit' }
case 2: {
const group = parseInt(accountNumber.substring(0, 2))
if (group <= 20) return { type: 'equity', balance: 'credit' }
return { type: 'liability', balance: 'credit' }
}
case 3: return { type: 'revenue', balance: 'credit' }
case 4: case 5: case 6: case 7: return { type: 'expense', balance: 'debit' }
case 8: {
const group = parseInt(accountNumber.substring(0, 2))
if (group >= 83 && group <= 83) return { type: 'revenue', balance: 'credit' }
if (group >= 84 && group <= 84) return { type: 'expense', balance: 'debit' }
return { type: 'expense', balance: 'debit' }
}
default: return { type: 'expense', balance: 'debit' }
}
}
export function AddAccountDialog({ open, onOpenChange, onCreated }: AddAccountDialogProps) {
const [accountNumber, setAccountNumber] = useState('')
const [accountName, setAccountName] = useState('')
const [description, setDescription] = useState('')
const [defaultVatCode, setDefaultVatCode] = useState('')
const [sruCode, setSruCode] = useState('')
const [normalBalance, setNormalBalance] = useState<'debit' | 'credit'>('debit')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const isBASMatch = accountNumber.length === 4 && isStandardBASAccount(accountNumber)
const derived = accountNumber.length === 4 ? deriveAccountType(accountNumber) : null
async function handleCreate() {
setError('')
if (!/^\d{4}$/.test(accountNumber)) {
setError('Kontonumret måste vara exakt 4 siffror')
return
}
if (!accountName.trim()) {
setError('Kontonamn krävs')
return
}
setIsSaving(true)
try {
const response = await fetch('/api/bookkeeping/accounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_number: accountNumber,
account_name: accountName.trim(),
account_type: derived?.type || 'expense',
normal_balance: normalBalance,
description: description || null,
default_vat_code: defaultVatCode || null,
sru_code: sruCode || null,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte skapa kontot')
}
// Reset form
setAccountNumber('')
setAccountName('')
setDescription('')
setDefaultVatCode('')
setSruCode('')
onCreated()
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Något gick fel')
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Lägg till eget konto</DialogTitle>
<DialogDescription>
Skapa ett eget konto utanför BAS-standarden
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{isBASMatch && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">
Kontonummer {accountNumber} finns i BAS-standarden. Använd &quot;BAS-katalog&quot;-fliken för att aktivera standardkonton istället.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Kontonummer</Label>
<Input
value={accountNumber}
onChange={(e) => {
const v = e.target.value.replace(/\D/g, '').slice(0, 4)
setAccountNumber(v)
if (v.length === 4) {
const d = deriveAccountType(v)
setNormalBalance(d.balance as 'debit' | 'credit')
}
}}
placeholder="T.ex. 1935"
maxLength={4}
className="font-mono"
/>
</div>
<div className="space-y-2">
<Label>Normal saldo</Label>
<Select value={normalBalance} onValueChange={(v) => setNormalBalance(v as 'debit' | 'credit')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{derived && (
<p className="text-xs text-muted-foreground">
Auto-detekterad typ:{' '}
<span className="font-medium">
{derived.type === 'asset' ? 'Tillgång' : derived.type === 'liability' ? 'Skuld' : derived.type === 'equity' ? 'Eget kapital' : derived.type === 'revenue' ? 'Intäkt' : 'Kostnad'}
</span>
</p>
)}
<div className="space-y-2">
<Label>Kontonamn</Label>
<Input
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
placeholder="T.ex. Sparkonto företag"
/>
</div>
<div className="space-y-2">
<Label>Beskrivning <span className="text-muted-foreground">(valfritt)</span></Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kort beskrivning av kontots användning"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Momskod <span className="text-muted-foreground">(valfritt)</span></Label>
<Input
value={defaultVatCode}
onChange={(e) => setDefaultVatCode(e.target.value)}
placeholder="T.ex. MP1"
/>
</div>
<div className="space-y-2">
<Label>SRU-kod <span className="text-muted-foreground">(valfritt)</span></Label>
<Input
value={sruCode}
onChange={(e) => setSruCode(e.target.value)}
placeholder="T.ex. 7201"
/>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isSaving || accountNumber.length !== 4 || !accountName.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa konto
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,564 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { AccountNumber } from '@/components/ui/account-number'
import { AddAccountDialog } from './AddAccountDialog'
import { EditAccountDialog } from './EditAccountDialog'
import {
Search,
ChevronDown,
ChevronRight,
Plus,
Pencil,
Trash2,
Loader2,
CheckCircle2,
BookOpen,
} from 'lucide-react'
import type { BASAccount } from '@/types'
import type { BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ReferenceAccount extends BASReferenceAccount {
is_activated: boolean
is_active: boolean
is_system_account: boolean
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CLASS_LABELS: Record<number, string> = {
1: 'Tillgangar',
2: 'Eget kapital och skulder',
3: 'Rorelseintatker',
4: 'Varuinkop och material',
5: 'Ovriga externa kostnader',
6: 'Ovriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
const TYPE_LABELS: Record<string, string> = {
asset: 'Tillgang',
liability: 'Skuld',
equity: 'EK',
revenue: 'Intakt',
expense: 'Kostnad',
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function ChartOfAccountsManager() {
const { toast } = useToast()
// View state
const [view, setView] = useState<'my-accounts' | 'bas-catalog'>('my-accounts')
const [searchQuery, setSearchQuery] = useState('')
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
// Data state
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [referenceAccounts, setReferenceAccounts] = useState<ReferenceAccount[]>([])
const [loading, setLoading] = useState(true)
// Dialog state
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [editAccount, setEditAccount] = useState<BASAccount | null>(null)
// Action states
const [togglingAccount, setTogglingAccount] = useState<string | null>(null)
const [deletingAccount, setDeletingAccount] = useState<string | null>(null)
const [activatingAccounts, setActivatingAccounts] = useState<Set<string>>(new Set())
// -------------------------------------------
// Data fetching
// -------------------------------------------
const fetchAccounts = useCallback(async () => {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}, [])
const fetchReference = useCallback(async () => {
const res = await fetch('/api/bookkeeping/accounts/reference')
const { data } = await res.json()
setReferenceAccounts(data || [])
}, [])
useEffect(() => {
async function load() {
setLoading(true)
await Promise.all([fetchAccounts(), fetchReference()])
setLoading(false)
}
load()
}, [fetchAccounts, fetchReference])
const refreshAll = useCallback(async () => {
await Promise.all([fetchAccounts(), fetchReference()])
}, [fetchAccounts, fetchReference])
// -------------------------------------------
// Actions
// -------------------------------------------
async function toggleActive(account: BASAccount) {
setTogglingAccount(account.account_number)
try {
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !account.is_active }),
})
if (!res.ok) throw new Error('Kunde inte uppdatera kontot')
await refreshAll()
} catch {
toast({ title: 'Fel', description: 'Kunde inte uppdatera kontot', variant: 'destructive' })
} finally {
setTogglingAccount(null)
}
}
async function deleteAccount(account: BASAccount) {
setDeletingAccount(account.account_number)
try {
const res = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
method: 'DELETE',
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || 'Kunde inte ta bort kontot')
}
toast({ title: 'Konto borttaget', description: `${account.account_number} ${account.account_name}` })
await refreshAll()
} catch (err) {
toast({
title: 'Fel',
description: err instanceof Error ? err.message : 'Kunde inte ta bort kontot',
variant: 'destructive',
})
} finally {
setDeletingAccount(null)
}
}
async function activateBASAccount(accountNumber: string) {
setActivatingAccounts((prev) => new Set(prev).add(accountNumber))
try {
const res = await fetch('/api/bookkeeping/accounts/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_numbers: [accountNumber] }),
})
if (!res.ok) throw new Error('Kunde inte aktivera kontot')
const { activated } = await res.json()
if (activated > 0) {
toast({ title: 'Konto aktiverat', description: `Konto ${accountNumber} har lagts till i din kontoplan` })
}
await refreshAll()
} catch {
toast({ title: 'Fel', description: 'Kunde inte aktivera kontot', variant: 'destructive' })
} finally {
setActivatingAccounts((prev) => {
const next = new Set(prev)
next.delete(accountNumber)
return next
})
}
}
// -------------------------------------------
// Toggle class expansion
// -------------------------------------------
function toggleClass(cls: number) {
setExpandedClasses((prev) => {
const next = new Set(prev)
if (next.has(cls)) {
next.delete(cls)
} else {
next.add(cls)
}
return next
})
}
// -------------------------------------------
// Filtered & grouped data
// -------------------------------------------
const filteredAccounts = useMemo(() => {
if (!searchQuery) return accounts
const q = searchQuery.toLowerCase()
return accounts.filter(
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
)
}, [accounts, searchQuery])
const groupedAccounts = useMemo(() => {
const grouped: Record<number, BASAccount[]> = {}
for (const a of filteredAccounts) {
const cls = a.account_class
if (!grouped[cls]) grouped[cls] = []
grouped[cls].push(a)
}
return grouped
}, [filteredAccounts])
const filteredReference = useMemo(() => {
if (!searchQuery) return referenceAccounts
const q = searchQuery.toLowerCase()
return referenceAccounts.filter(
(a) => a.account_number.includes(q) || a.account_name.toLowerCase().includes(q)
)
}, [referenceAccounts, searchQuery])
const groupedReference = useMemo(() => {
const grouped: Record<number, ReferenceAccount[]> = {}
for (const a of filteredReference) {
const cls = a.account_class
if (!grouped[cls]) grouped[cls] = []
grouped[cls].push(a)
}
return grouped
}, [filteredReference])
// -------------------------------------------
// Render
// -------------------------------------------
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Laddar kontoplan...
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Header controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Tabs
value={view}
onValueChange={(v) => {
setView(v as 'my-accounts' | 'bas-catalog')
setExpandedClasses(new Set())
}}
>
<TabsList>
<TabsTrigger value="my-accounts">
Mina konton
<Badge variant="secondary" className="ml-1.5 text-xs">
{accounts.length}
</Badge>
</TabsTrigger>
<TabsTrigger value="bas-catalog">
<BookOpen className="mr-1.5 h-3.5 w-3.5" />
BAS-katalog
</TabsTrigger>
</TabsList>
</Tabs>
{view === 'my-accounts' && (
<Button size="sm" onClick={() => setAddDialogOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Eget konto
</Button>
)}
</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="Sok konto (nummer eller namn)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* My Accounts view */}
{view === 'my-accounts' && (
<div className="space-y-2">
{Object.entries(groupedAccounts)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
const activeCount = classAccounts.filter((a) => a.is_active).length
return (
<Card key={cls}>
<button
onClick={() => toggleClass(classNum)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
</span>
<Badge variant="secondary" className="text-xs">
{activeCount}/{classAccounts.length}
</Badge>
</div>
</button>
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-16 text-center">Aktiv</th>
<th className="py-2 w-20 text-right"></th>
</tr>
</thead>
<tbody>
{classAccounts.map((account) => (
<tr
key={account.id}
className={`border-b last:border-0 transition-opacity ${
!account.is_active ? 'opacity-50' : ''
}`}
>
<td className="py-2">
<AccountNumber number={account.account_number} name={account.account_name} />
</td>
<td className="py-2">
<span className="flex items-center gap-1.5">
{account.account_name}
{account.is_system_account && (
<Badge variant="outline" className="text-[10px] px-1 py-0">
System
</Badge>
)}
</span>
</td>
<td className="py-2 text-center">
<span className="text-xs font-mono text-muted-foreground">
{account.sru_code || '\u2014'}
</span>
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
</Badge>
</td>
<td className="py-2 text-center">
<Switch
checked={account.is_active}
onCheckedChange={() => toggleActive(account)}
disabled={togglingAccount === account.account_number}
className="scale-75"
/>
</td>
<td className="py-2 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setEditAccount(account)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{!account.is_system_account && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => deleteAccount(account)}
disabled={deletingAccount === account.account_number}
>
{deletingAccount === account.account_number ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)
})}
{filteredAccounts.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
{searchQuery ? 'Inga konton matchar sokningen' : 'Inga konton i kontoplanen'}
</CardContent>
</Card>
)}
</div>
)}
{/* BAS Catalog view */}
{view === 'bas-catalog' && (
<div className="space-y-2">
{Object.entries(groupedReference)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([cls, classAccounts]) => {
const classNum = Number(cls)
const isExpanded = expandedClasses.has(classNum) || !!searchQuery
const activatedCount = classAccounts.filter((a) => a.is_activated).length
return (
<Card key={cls}>
<button
onClick={() => toggleClass(classNum)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 shrink-0" />
)}
<span className="font-semibold text-left">
Klass {cls}: {CLASS_LABELS[classNum] || ''}
</span>
<Badge variant="secondary" className="text-xs">
{activatedCount}/{classAccounts.length} aktiva
</Badge>
</div>
</button>
{isExpanded && (
<CardContent className="pt-0 pb-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-28 text-right">Status</th>
</tr>
</thead>
<tbody>
{classAccounts.map((account) => (
<tr
key={account.account_number}
className={`border-b last:border-0 ${
account.is_activated ? 'bg-muted/30' : ''
}`}
>
<td className="py-2">
<AccountNumber number={account.account_number} name={account.account_name} />
</td>
<td className="py-2">
<div>
<span>{account.account_name}</span>
{account.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">
{account.description}
</p>
)}
</div>
</td>
<td className="py-2 text-center">
<span className="text-xs font-mono text-muted-foreground">
{account.sru_code || '\u2014'}
</span>
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[account.account_type] || account.account_type}
</Badge>
</td>
<td className="py-2 text-right">
{account.is_activated ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
Aktiverat
</span>
) : (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => activateBASAccount(account.account_number)}
disabled={activatingAccounts.has(account.account_number)}
>
{activatingAccounts.has(account.account_number) ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : (
<Plus className="mr-1 h-3 w-3" />
)}
Lagg till
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)
})}
{filteredReference.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga konton matchar sokningen
</CardContent>
</Card>
)}
</div>
)}
{/* Dialogs */}
<AddAccountDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onCreated={refreshAll}
/>
{editAccount && (
<EditAccountDialog
open={!!editAccount}
onOpenChange={(open) => { if (!open) setEditAccount(null) }}
account={editAccount}
onSaved={refreshAll}
/>
)}
</div>
)
}
@@ -222,9 +222,14 @@ export default function DocumentUploadZone({
</Badge>
)}
{file.status === 'error' && (
<Badge variant="destructive" className="text-xs px-1.5 py-0" title={file.error}>
Fel
</Badge>
<>
<Badge variant="destructive" className="text-xs px-1.5 py-0">
Fel
</Badge>
{file.error && (
<span className="text-xs text-destructive">{file.error}</span>
)}
</>
)}
<Button
@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Loader2 } from 'lucide-react'
import type { BASAccount } from '@/types'
interface EditAccountDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
account: BASAccount
onSaved: () => void
}
export function EditAccountDialog({ open, onOpenChange, account, onSaved }: EditAccountDialogProps) {
const [accountName, setAccountName] = useState(account.account_name)
const [description, setDescription] = useState(account.description || '')
const [defaultVatCode, setDefaultVatCode] = useState(account.default_vat_code || '')
const [sruCode, setSruCode] = useState(account.sru_code || '')
const [isActive, setIsActive] = useState(account.is_active)
const [isSaving, setIsSaving] = useState(false)
async function handleSave() {
setIsSaving(true)
try {
const response = await fetch(`/api/bookkeeping/accounts/${account.account_number}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
account_name: accountName,
description: description || null,
default_vat_code: defaultVatCode || null,
sru_code: sruCode || null,
is_active: isActive,
}),
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte uppdatera kontot')
}
onSaved()
onOpenChange(false)
} catch {
// Error handled silently — toast is in parent
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Redigera konto {account.account_number}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Kontonamn</Label>
<Input
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Kort beskrivning av kontots användning"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Standard momskod</Label>
<Input
value={defaultVatCode}
onChange={(e) => setDefaultVatCode(e.target.value)}
placeholder="T.ex. MP1"
/>
</div>
<div className="space-y-2">
<Label>SRU-kod</Label>
<Input
value={sruCode}
onChange={(e) => setSruCode(e.target.value)}
placeholder="T.ex. 7201"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Aktivt konto</p>
<p className="text-xs text-muted-foreground">
Inaktiva konton visas inte i bokföringsformulär
</p>
</div>
<Switch checked={isActive} onCheckedChange={setIsActive} />
</div>
{account.is_system_account && (
<p className="text-xs text-muted-foreground bg-muted rounded p-2">
Detta är ett systemkonto och kan inte tas bort.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={isSaving || !accountName.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Spara
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+21 -7
View File
@@ -10,8 +10,9 @@ import { Plus, Trash2 } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { CreateJournalEntryLineInput, FiscalPeriod } from '@/types'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount } from '@/types'
interface Props {
onCreated?: () => void
@@ -37,11 +38,13 @@ export default function JournalEntryForm({ onCreated }: Props) {
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const isUploading = uploadedFiles.some((f) => f.status === 'uploading')
useEffect(() => {
fetchPeriods()
fetchAccounts()
}, [])
async function fetchPeriods() {
@@ -53,6 +56,12 @@ export default function JournalEntryForm({ onCreated }: Props) {
}
}
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}
const addLine = () => {
setLines([
...lines,
@@ -211,12 +220,10 @@ export default function JournalEntryForm({ onCreated }: Props) {
{lines.map((line, index) => (
<tr key={index} className="border-b">
<td className="py-1">
<Input
<AccountCombobox
value={line.account_number}
onChange={(e) => updateLine(index, 'account_number', e.target.value)}
placeholder="1930"
className="font-mono h-8"
maxLength={4}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
/>
</td>
<td className="py-1 px-1">
@@ -313,13 +320,20 @@ export default function JournalEntryForm({ onCreated }: Props) {
</p>
)}
<div className="flex justify-end">
<div className="flex flex-col items-end gap-1">
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting || isUploading}
>
Granska & skapa
</Button>
{(!description || !selectedPeriod || isUploading) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
{!description && <p>Ange en beskrivning</p>}
{!selectedPeriod && <p>Välj en räkenskapsperiod</p>}
{isUploading && <p>Vänta tills filerna laddats upp</p>}
</div>
)}
</div>
<ConfirmationDialog
+4 -4
View File
@@ -138,7 +138,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} okategoriserade
{summary.uncategorizedCount} obokförda
</p>
</div>
</div>
@@ -184,7 +184,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
{ href: '/receipts/scan', icon: Camera, label: 'Skanna kvitto', desc: 'Fotografera & spara' },
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Kategorisera' },
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' },
]
return (
@@ -220,7 +220,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
}
if (summary.uncategorizedCount > 0) {
todoItems.push({ label: 'okategoriserade', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
todoItems.push({ label: 'obokförda', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
}
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
@@ -465,7 +465,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">
{summary.uncategorizedCount} okategoriserade transaktioner
{summary.uncategorizedCount} obokförda transaktioner
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedIncome > 0 && (
+1 -22
View File
@@ -30,6 +30,7 @@ import {
} from 'lucide-react'
import type { AccountMapping } from '@/lib/import/types'
import type { BASAccount } from '@/types'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
interface AccountMappingStepProps {
mappings: AccountMapping[]
@@ -352,25 +353,3 @@ function ConfidenceBadge({
return <Badge variant="outline">Osäker</Badge>
}
function getAccountClassName(accountClass: number): string {
switch (accountClass) {
case 1:
return '1xxx - Tillgångar'
case 2:
return '2xxx - Eget kapital & Skulder'
case 3:
return '3xxx - Intäkter'
case 4:
return '4xxx - Varuinköp'
case 5:
return '5xxx - Externa kostnader'
case 6:
return '6xxx - Övriga externa kostnader'
case 7:
return '7xxx - Personal'
case 8:
return '8xxx - Finansiella poster'
default:
return `${accountClass}xxx - Övrigt`
}
}
+3 -3
View File
@@ -126,7 +126,7 @@ export default function BankFileConfirmStep({
/>
<div>
<Label htmlFor="auto-categorize" className="text-sm font-medium cursor-pointer">
Auto-kategorisera kända transaktioner
Auto-bokför kända transaktioner
</Label>
<p className="text-xs text-muted-foreground">
Skapar automatiskt bokföringsposter för transaktioner med hög konfidens
@@ -139,8 +139,8 @@ export default function BankFileConfirmStep({
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">
Importerade transaktioner som inte automatiskt kategoriseras visas som
&quot;okategoriserade&quot; transaktionssidan. Du kan kategorisera dem manuellt
Importerade transaktioner som inte automatiskt bokförs visas som
&quot;obokförda&quot; transaktionssidan. Du kan bokföra dem manuellt
efteråt.
</p>
</div>
+17
View File
@@ -200,6 +200,23 @@ export default function BankFilePreviewStep({
</CardContent>
</Card>
{/* Error blocking continuation */}
{hasIssues && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">Filen innehåller fel som förhindrar import</p>
<p className="text-sm text-muted-foreground mt-1">
Kontrollera felet ovan och försök ladda upp en korrigerad fil.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
+4 -4
View File
@@ -79,7 +79,7 @@ export default function BankFileResultStep({
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Sparkles className="h-4 w-4" />
<span className="text-sm">Auto-kategoriserade</span>
<span className="text-sm">Auto-bokförda</span>
</div>
<p className="text-2xl font-bold">{result.auto_categorized}</p>
</CardContent>
@@ -108,11 +108,11 @@ export default function BankFileResultStep({
1
</div>
<div>
<p className="font-medium">Granska okategoriserade transaktioner</p>
<p className="font-medium">Granska obokförda transaktioner</p>
<p className="text-sm text-muted-foreground">
{result.imported - result.auto_categorized > 0
? `${result.imported - result.auto_categorized} transaktioner behöver kategoriseras manuellt.`
: 'Alla transaktioner kategoriserades automatiskt.'}
? `${result.imported - result.auto_categorized} transaktioner behöver bokföras manuellt.`
: 'Alla transaktioner bokfördes automatiskt.'}
</p>
</div>
</div>
+4 -4
View File
@@ -67,13 +67,13 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Räkenskapsår</span>
</div>
<p className="text-2xl font-bold">
<div className="text-2xl font-bold">
{result.fiscalPeriodId ? (
<Badge variant="default" className="bg-success">Skapat</Badge>
) : (
<Badge variant="secondary">Befintligt</Badge>
)}
</p>
</div>
</CardContent>
</Card>
@@ -82,13 +82,13 @@ export default function ImportResultStep({ result, onNewImport }: ImportResultSt
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-sm">Ingående balanser</span>
</div>
<p className="text-2xl font-bold">
<div className="text-2xl font-bold">
{result.openingBalanceEntryId ? (
<Badge variant="default" className="bg-success">Importerade</Badge>
) : (
<Badge variant="secondary">Inga</Badge>
)}
</p>
</div>
</CardContent>
</Card>
</div>
+40 -4
View File
@@ -11,6 +11,7 @@ interface ReviewItem {
quantity: number
unit: string
unit_price: number
vat_rate?: number
}
interface InvoiceReviewContentProps {
@@ -51,6 +52,24 @@ export function InvoiceReviewContent({
non_eu_business: 'Utanför EU',
}
// Check if items have mixed VAT rates
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined)
const uniqueRates = hasPerLineVat
? new Set(items.map((item) => item.vat_rate ?? vatRate))
: new Set([vatRate])
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
// Calculate per-rate VAT breakdown
const vatByRate = new Map<number, number>()
if (hasPerLineVat) {
for (const item of items) {
const rate = item.vat_rate ?? vatRate
const lineTotal = item.quantity * item.unit_price
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat)
}
}
return (
<div className="space-y-4">
{/* Customer info */}
@@ -89,6 +108,7 @@ export function InvoiceReviewContent({
<th className="py-2 w-16 text-right">Antal</th>
<th className="py-2 w-16 text-center">Enhet</th>
<th className="py-2 w-24 text-right">À-pris</th>
{showVatColumn && <th className="py-2 w-16 text-right">Moms</th>}
<th className="py-2 w-28 text-right">Belopp</th>
</tr>
</thead>
@@ -99,6 +119,9 @@ export function InvoiceReviewContent({
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? vatRate}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
</td>
@@ -113,10 +136,23 @@ export function InvoiceReviewContent({
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(subtotal, currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms ({vatRate}%)</span>
<span>{formatCurrency(vatAmount, currency)}</span>
</div>
{vatByRate.size > 1 ? (
// Per-rate breakdown
Array.from(vatByRate.entries())
.filter(([, vat]) => vat > 0)
.sort(([a], [b]) => b - a)
.map(([rate, vat]) => (
<div key={rate} className="flex justify-between">
<span className="text-muted-foreground">Moms {rate}%</span>
<span>{formatCurrency(vat, currency)}</span>
</div>
))
) : (
<div className="flex justify-between">
<span className="text-muted-foreground">Moms ({vatRate}%)</span>
<span>{formatCurrency(vatAmount, currency)}</span>
</div>
)}
<Separator />
<div className="flex justify-between font-bold text-2xl">
<span>Totalt</span>
+398 -44
View File
@@ -1,5 +1,6 @@
'use client'
import { useState, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -10,47 +11,130 @@ import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft } from 'lucide-react'
import type { MomsPeriod } from '@/types'
import { Loader2, ArrowRight, ArrowLeft, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { MomsPeriod, EntityType } from '@/types'
const schema = z.object({
f_skatt: z.boolean(),
fiscal_year_start_month: z.number().min(1).max(12),
is_first_fiscal_year: z.boolean(),
// First year fields (conditional)
first_year_start: z.string().optional(),
first_year_end: z.string().optional(),
// Ongoing year field (conditional)
fiscal_year_end_month: z.number().min(1).max(12).optional(),
// Existing fields
vat_registered: z.boolean(),
vat_number: z.string().optional(),
moms_period: z.enum(['monthly', 'quarterly', 'yearly']).optional(),
accounting_method: z.enum(['accrual', 'cash']),
})
type FormData = z.infer<typeof schema>
// Output type passed to onNext — includes computed fiscal_year_start_month
interface Step3Output {
f_skatt: boolean
fiscal_year_start_month: number
is_first_fiscal_year: boolean
first_year_start?: string
first_year_end?: string
vat_registered: boolean
vat_number?: string
moms_period?: MomsPeriod
accounting_method: 'accrual' | 'cash'
}
interface Step3Props {
initialData: Partial<FormData>
onNext: (data: FormData) => void
initialData: Partial<Step3Output>
entityType?: EntityType
onNext: (data: Step3Output) => void
onBack: () => void
isSaving: boolean
}
const months = [
{ value: 1, label: 'Januari' },
{ value: 2, label: 'Februari' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'April' },
{ value: 5, label: 'Maj' },
{ value: 6, label: 'Juni' },
{ value: 7, label: 'Juli' },
{ value: 8, label: 'Augusti' },
{ value: 9, label: 'September' },
{ value: 10, label: 'Oktober' },
{ value: 11, label: 'November' },
{ value: 12, label: 'December' },
const monthNames = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
/**
* Get the last day of a given month (1-indexed).
*/
function lastDayOfMonth(year: number, month: number): number {
return new Date(year, month, 0).getDate()
}
/**
* Compute valid first-year end dates for enskild firma.
* EF must use calendar year, so end is always Dec 31.
*/
function getEFFirstYearEndDates(startYear: number, startMonth: number): { label: string; value: string }[] {
// EF always ends Dec 31 of either same year or (if start is Jan) same year
// If start is late in the year, only option is Dec 31 same year
// Periods cannot exceed 18 months
const options: { label: string; value: string }[] = []
// Option 1: Dec 31 of same year (if startMonth <= 12)
const months1 = 12 - startMonth + 1
if (months1 >= 1 && months1 <= 18) {
const endDate = `${startYear}-12-31`
options.push({
label: `31 december ${startYear} (${months1} mån)`,
value: endDate,
})
}
// Option 2: Dec 31 of next year (if that gives <= 18 months)
const months2 = months1 + 12
if (months2 >= 1 && months2 <= 18 && startMonth > 6) {
// Only makes sense if start month > June (otherwise > 18 months)
const endDate = `${startYear + 1}-12-31`
options.push({
label: `31 december ${startYear + 1} (${months2} mån)`,
value: endDate,
})
}
return options
}
/**
* Compute valid first-year end dates for aktiebolag given a chosen end month.
* Returns one or two options (ending in the nearest years that give 1-18 months).
*/
function getABFirstYearEndDates(
startYear: number,
startMonth: number,
endMonth: number
): { label: string; value: string }[] {
const options: { label: string; value: string }[] = []
// Try ending in the same year or next year
for (const endYear of [startYear, startYear + 1, startYear + 2]) {
const months = (endYear - startYear) * 12 + (endMonth - startMonth) + 1
if (months >= 1 && months <= 18) {
const day = lastDayOfMonth(endYear, endMonth)
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`
options.push({
label: `${day} ${monthNames[endMonth - 1].toLowerCase()} ${endYear} (${months} mån)`,
value: endDate,
})
}
}
return options
}
export default function Step3TaxRegistration({
initialData,
entityType,
onNext,
onBack,
isSaving,
}: Step3Props) {
const isEF = entityType === 'enskild_firma'
const {
register,
handleSubmit,
@@ -61,14 +145,84 @@ export default function Step3TaxRegistration({
resolver: zodResolver(schema),
defaultValues: {
f_skatt: initialData.f_skatt ?? true,
fiscal_year_start_month: initialData.fiscal_year_start_month ?? 1,
is_first_fiscal_year: initialData.is_first_fiscal_year ?? false,
first_year_start: initialData.first_year_start || '',
first_year_end: initialData.first_year_end || '',
fiscal_year_end_month: initialData.fiscal_year_start_month
? (initialData.fiscal_year_start_month === 1 ? 12 : initialData.fiscal_year_start_month - 1)
: 12,
vat_registered: initialData.vat_registered ?? false,
vat_number: initialData.vat_number || '',
moms_period: initialData.moms_period,
accounting_method: initialData.accounting_method ?? 'accrual',
},
})
const vatRegistered = watch('vat_registered')
const isFirstYear = watch('is_first_fiscal_year')
const firstYearStart = watch('first_year_start')
const firstYearEnd = watch('first_year_end')
const fiscalYearEndMonth = watch('fiscal_year_end_month')
// State for AB first-year end month selector
const [abEndMonth, setAbEndMonth] = useState<number>(
initialData.first_year_end
? new Date(initialData.first_year_end).getMonth() + 1
: 12
)
// Parse first year start for date computations
const parsedStart = useMemo(() => {
if (!firstYearStart) return null
const d = new Date(firstYearStart)
if (isNaN(d.getTime())) return null
return { year: d.getFullYear(), month: d.getMonth() + 1 }
}, [firstYearStart])
// Compute end date options for first year
const firstYearEndOptions = useMemo(() => {
if (!parsedStart) return []
if (isEF) {
return getEFFirstYearEndDates(parsedStart.year, parsedStart.month)
}
return getABFirstYearEndDates(parsedStart.year, parsedStart.month, abEndMonth)
}, [parsedStart, isEF, abEndMonth])
const onSubmit = (data: FormData) => {
let fiscalYearStartMonth: number
let firstStart: string | undefined
let firstEnd: string | undefined
if (data.is_first_fiscal_year && data.first_year_start && data.first_year_end) {
// Derive start month from end date
const endDate = new Date(data.first_year_end)
const endMonth = endDate.getMonth() + 1
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
firstStart = data.first_year_start
firstEnd = data.first_year_end
} else if (isEF) {
// EF must always be calendar year
fiscalYearStartMonth = 1
} else {
// AB ongoing: derive from end month
const endMonth = data.fiscal_year_end_month || 12
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
}
const output: Step3Output = {
f_skatt: data.f_skatt,
fiscal_year_start_month: fiscalYearStartMonth,
is_first_fiscal_year: data.is_first_fiscal_year,
...(firstStart && { first_year_start: firstStart }),
...(firstEnd && { first_year_end: firstEnd }),
vat_registered: data.vat_registered,
vat_number: data.vat_number,
moms_period: data.moms_period,
accounting_method: data.accounting_method,
}
onNext(output)
}
return (
<div className="space-y-6">
@@ -87,7 +241,7 @@ export default function Step3TaxRegistration({
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* F-skatt */}
<div className="flex items-start space-x-3">
<Controller
@@ -122,33 +276,198 @@ export default function Step3TaxRegistration({
</div>
</div>
{/* Fiscal year */}
<div className="space-y-2">
<Label>Räkenskapsår börjar</Label>
{/* Fiscal year section */}
<div className="pt-4 border-t space-y-4">
<InfoTooltip
content={
<div className="space-y-2">
<p className="font-medium">Räkenskapsår</p>
<p>Ditt räkenskapsår bestämmer vilken period du bokför för. De flesta har kalenderår (jan-dec).</p>
{isEF && (
<p className="text-xs text-muted-foreground">Enskild firma måste använda kalenderår enligt BFL 3 kap.</p>
)}
</div>
}
side="right"
>
<Label className="text-base font-medium">Vilket räkenskapsår bokför du för?</Label>
</InfoTooltip>
{/* Toggle: First year vs Ongoing */}
<Controller
name="fiscal_year_start_month"
name="is_first_fiscal_year"
control={control}
render={({ field }) => (
<Select
value={field.value?.toString()}
onValueChange={(value) => field.onChange(parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
</SelectTrigger>
<SelectContent>
{months.map((month) => (
<SelectItem key={month.value} value={month.value.toString()}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => field.onChange(true)}
className="text-left"
>
<Card className={cn(
'p-3 transition-all cursor-pointer hover:border-primary/50',
field.value && 'border-primary ring-2 ring-primary/20'
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Första räkenskapsåret</p>
<p className="text-xs text-muted-foreground mt-0.5">Nystartat företag</p>
</div>
{field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
</Card>
</button>
<button
type="button"
onClick={() => field.onChange(false)}
className="text-left"
>
<Card className={cn(
'p-3 transition-all cursor-pointer hover:border-primary/50',
!field.value && 'border-primary ring-2 ring-primary/20'
)}>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm">Annat räkenskapsår</p>
<p className="text-xs text-muted-foreground mt-0.5">Pågående verksamhet</p>
</div>
{!field.value && (
<div className="flex-shrink-0 p-1 rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
</Card>
</button>
</div>
)}
/>
<p className="text-sm text-muted-foreground">
De flesta har kalenderår (januari). Brutet räkenskapsår är vanligare för aktiebolag.
</p>
{/* First fiscal year options */}
{isFirstYear && (
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
<div className="space-y-2">
<Label htmlFor="first_year_start">Startdatum</Label>
<Controller
name="first_year_start"
control={control}
render={({ field }) => (
<Input
id="first_year_start"
type="date"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value)}
/>
)}
/>
<p className="text-xs text-muted-foreground">
Dagen verksamheten startade (bör vara den 1:a i en månad).
</p>
</div>
{/* AB: end month selector */}
{!isEF && parsedStart && (
<div className="space-y-2">
<Label>Räkenskapsåret slutar (månad)</Label>
<Select
value={abEndMonth.toString()}
onValueChange={(v) => setAbEndMonth(parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* End date selector (options depend on entity type + start) */}
{parsedStart && firstYearEndOptions.length > 0 && (
<div className="space-y-2">
<Label>Slutdatum</Label>
<Controller
name="first_year_end"
control={control}
render={({ field }) => (
<Select
value={field.value || ''}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Välj slutdatum" />
</SelectTrigger>
<SelectContent>
{firstYearEndOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
)}
{parsedStart && firstYearEndOptions.length === 0 && (
<p className="text-sm text-destructive">
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
</p>
)}
</div>
)}
{/* Ongoing fiscal year options */}
{!isFirstYear && (
<div className="space-y-2">
{isEF ? (
<div className="rounded-lg bg-muted/50 p-4">
<p className="text-sm font-medium">Kalenderår (januari-december)</p>
<p className="text-xs text-muted-foreground mt-1">
Enskild firma måste använda kalenderår enligt BFL 3 kap.
</p>
</div>
) : (
<div className="space-y-2">
<Label>När slutar företagets räkenskapsår?</Label>
<Controller
name="fiscal_year_end_month"
control={control}
render={({ field }) => (
<Select
value={field.value?.toString() || '12'}
onValueChange={(value) => field.onChange(parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-sm text-muted-foreground">
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
</p>
</div>
)}
</div>
)}
</div>
{/* VAT section */}
@@ -209,7 +528,7 @@ export default function Step3TaxRegistration({
content={
<div className="space-y-2">
<p className="font-medium">Hur ofta rapporterar du moms?</p>
<p>Osäker? Börja med kvartal - det är vanligast och du kan ändra senare.</p>
<p>Välj den period som anges Verksamt eller i ditt beslut från Skatteverket.</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li>Under 1 miljon/år = Kan välja årsredovisning</li>
<li>1-40 miljoner = Kvartal</li>
@@ -241,13 +560,48 @@ export default function Step3TaxRegistration({
)}
/>
<p className="text-xs text-muted-foreground">
Osäker? Välj kvartal - det passar de flesta och du kan ändra senare.
Välj den period som anges i ditt beslut från Skatteverket. Vanligtvis kvartal eller år.
</p>
</div>
</div>
)}
</div>
{/* Accounting method */}
<div className="pt-4 border-t space-y-4">
<div className="space-y-2">
<InfoTooltip
content="Faktureringsmetoden bokför intäkter och kostnader när fakturan skickas/mottas. Kontantmetoden bokför vid betalning."
side="right"
>
<Label>Bokföringsmetod</Label>
</InfoTooltip>
<Controller
name="accounting_method"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Välj metod" />
</SelectTrigger>
<SelectContent>
<SelectItem value="accrual">Faktureringsmetoden</SelectItem>
<SelectItem value="cash">Kontantmetoden</SelectItem>
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{entityType === 'aktiebolag'
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
: 'Som enskild firma med omsättning under 3 MSEK kan du välja kontantmetoden.'}
</p>
</div>
</div>
<div className="flex justify-between pt-4">
<Button
type="button"
@@ -0,0 +1,598 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const METHOD_LABELS: Record<string, string> = {
auto_exact: 'Exakt matchning',
auto_date_range: 'Datumintervall',
auto_reference: 'Referensmatchning',
auto_fuzzy: 'Ungefärlig matchning',
manual: 'Manuell',
}
// ============================================================
// Types
// ============================================================
interface ReconciliationStatus {
bank_transaction_total: number
gl_1930_balance: number
difference: number
is_reconciled: boolean
matched_count: number
unmatched_transaction_count: number
unmatched_gl_line_count: number
}
interface UnlinkedGLLine {
line_id: string
journal_entry_id: string
debit_amount: number
credit_amount: number
line_description: string | null
entry_date: string
voucher_number: number
voucher_series: string
entry_description: string
source_type: string
}
interface UnmatchedTransaction {
id: string
date: string
description: string
amount: number
reference: string | null
currency: string
}
interface MatchedTransaction {
id: string
date: string
description: string
amount: number
reconciliation_method: string | null
journal_entry_id: string | null
}
interface DryRunMatch {
transaction_id: string
transaction_date: string
transaction_description: string
transaction_amount: number
journal_entry_id: string
voucher_number: number
voucher_series: string
entry_date: string
entry_description: string
method: string
confidence: number
}
// ============================================================
// Component
// ============================================================
export function BankReconciliationView() {
const [status, setStatus] = useState<ReconciliationStatus | null>(null)
const [unmatchedTx, setUnmatchedTx] = useState<UnmatchedTransaction[]>([])
const [glLines, setGlLines] = useState<UnlinkedGLLine[]>([])
const [matchedTx, setMatchedTx] = useState<MatchedTransaction[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [dryRunResults, setDryRunResults] = useState<DryRunMatch[] | null>(null)
const [runLoading, setRunLoading] = useState(false)
const [applyLoading, setApplyLoading] = useState(false)
const [linkLoading, setLinkLoading] = useState<string | null>(null)
const [unlinkLoading, setUnlinkLoading] = useState<string | null>(null)
const [showMatched, setShowMatched] = useState(false)
const [selectedMatch, setSelectedMatch] = useState<Record<string, string>>({})
const fetchAll = useCallback(async () => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
const qs = params.toString() ? `?${params}` : ''
const [statusRes, glRes, unmatchedRes, matchedRes] = await Promise.all([
fetch(`/api/reconciliation/bank/status${qs}`),
fetch(`/api/reconciliation/bank/unmatched-entries${qs}`),
fetch(`/api/transactions?unmatched=true&currency=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
fetch(`/api/transactions?reconciled=true&currency=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
])
const [statusData, glData, unmatchedData, matchedData] = await Promise.all([
statusRes.json(),
glRes.json(),
unmatchedRes.json(),
matchedRes.json(),
])
if (statusData.data) setStatus(statusData.data)
setGlLines(glData.data || [])
setUnmatchedTx(unmatchedData.data || [])
setMatchedTx(matchedData.data || [])
} catch {
setError('Kunde inte hämta avstämningsdata')
} finally {
setLoading(false)
}
}, [dateFrom, dateTo])
useEffect(() => {
fetchAll()
}, [fetchAll])
const handleDryRun = async () => {
setRunLoading(true)
setDryRunResults(null)
try {
const res = await fetch('/api/reconciliation/bank/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
dry_run: true,
}),
})
const result = await res.json()
if (result.data?.matches) {
setDryRunResults(result.data.matches)
}
} catch {
setError('Kunde inte köra förhandsgranskning')
} finally {
setRunLoading(false)
}
}
const handleApply = async () => {
setApplyLoading(true)
try {
await fetch('/api/reconciliation/bank/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
dry_run: false,
}),
})
setDryRunResults(null)
await fetchAll()
} catch {
setError('Kunde inte tillämpa matchningar')
} finally {
setApplyLoading(false)
}
}
const handleManualLink = async (transactionId: string) => {
const journalEntryId = selectedMatch[transactionId]
if (!journalEntryId) return
setLinkLoading(transactionId)
try {
const res = await fetch('/api/reconciliation/bank/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transaction_id: transactionId,
journal_entry_id: journalEntryId,
}),
})
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setSelectedMatch((prev) => {
const next = { ...prev }
delete next[transactionId]
return next
})
await fetchAll()
}
} catch {
setError('Kunde inte matcha transaktion')
} finally {
setLinkLoading(null)
}
}
const handleUnlink = async (transactionId: string) => {
setUnlinkLoading(transactionId)
try {
const res = await fetch('/api/reconciliation/bank/unlink', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId }),
})
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
await fetchAll()
}
} catch {
setError('Kunde inte avmatcha transaktion')
} finally {
setUnlinkLoading(null)
}
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar bankavstämning...
</CardContent>
</Card>
)
}
if (error && !status) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{error && (
<Card>
<CardContent className="py-3 text-center text-destructive text-sm">
<AlertCircle className="h-4 w-4 inline mr-1" />
{error}
<Button variant="ghost" size="sm" className="ml-2" onClick={() => setError(null)}>
Stäng
</Button>
</CardContent>
</Card>
)}
{/* Status Card */}
{status && (
<Card className="border-2">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Avstämning mot <AccountNumber number="1930" /></CardTitle>
{status.is_reconciled ? (
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd</Badge>
)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Banktransaktioner (summa)</span>
<span className="font-mono">{formatAmount(status.bank_transaction_total)} kr</span>
</div>
<div className="flex justify-between">
<span><AccountNumber number="1930" /> saldo (huvudbok)</span>
<span className="font-mono">{formatAmount(status.gl_1930_balance)} kr</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={status.is_reconciled ? 'text-green-600' : 'text-red-600'}>
{formatAmount(status.difference)} kr
</span>
</div>
<div className="flex gap-4 pt-2 text-xs text-muted-foreground">
<span>Matchade: {status.matched_count}</span>
<span>Omatchade transaktioner: {status.unmatched_transaction_count}</span>
<span>Omatchade verifikationer: {status.unmatched_gl_line_count}</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Action Bar */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Datum från</Label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<Label>Datum till</Label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<Button onClick={fetchAll} variant="outline">
Filtrera
</Button>
<div className="flex-1" />
<Button onClick={handleDryRun} disabled={runLoading} variant="outline">
<Eye className="h-4 w-4 mr-2" />
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
</Button>
{dryRunResults && dryRunResults.length > 0 && (
<Button onClick={handleApply} disabled={applyLoading}>
<Play className="h-4 w-4 mr-2" />
{applyLoading ? 'Tillämpar...' : `Tillämpa ${dryRunResults.length} matchningar`}
</Button>
)}
</div>
</CardContent>
</Card>
{/* Dry Run Preview */}
{dryRunResults && dryRunResults.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Förhandsgranskning {dryRunResults.length} matchningar hittade
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Transaktion</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-8 text-center">&harr;</th>
<th className="py-2">Verifikation</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2 w-28">Metod</th>
</tr>
</thead>
<tbody>
{dryRunResults.map((m) => (
<tr key={m.transaction_id} className="border-b last:border-0">
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
<td className="py-2">{m.transaction_date}</td>
<td className="py-2 text-right font-mono">{formatAmount(m.transaction_amount)}</td>
<td className="py-2 text-center text-muted-foreground">&harr;</td>
<td className="py-2">
<span className="font-mono text-xs">{m.voucher_series}{m.voucher_number}</span>
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
</td>
<td className="py-2">{m.entry_date}</td>
<td className="py-2">
<Badge variant="outline" className="text-xs">
{METHOD_LABELS[m.method] || m.method}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{dryRunResults && dryRunResults.length === 0 && (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Inga automatiska matchningar hittades.
</CardContent>
</Card>
)}
{/* Unmatched Transactions */}
{unmatchedTx.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Omatchade transaktioner ({unmatchedTx.length})
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-24">Referens</th>
<th className="py-2 w-64">Föreslå verifikation</th>
<th className="py-2 w-24"></th>
</tr>
</thead>
<tbody>
{unmatchedTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
{formatAmount(tx.amount)} kr
</td>
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
<td className="py-2">
<select
value={selectedMatch[tx.id] || ''}
onChange={(e) =>
setSelectedMatch((prev) => ({ ...prev, [tx.id]: e.target.value }))
}
className="w-full rounded-md border border-input bg-background px-2 py-1 text-xs"
>
<option value="">Välj verifikation...</option>
{glLines.map((line) => {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<option key={line.line_id} value={line.journal_entry_id}>
{line.voucher_series}{line.voucher_number} | {line.entry_date} | {formatAmount(lineAmount)} kr | {line.entry_description}
</option>
)
})}
</select>
</td>
<td className="py-2">
<Button
size="sm"
variant="outline"
disabled={!selectedMatch[tx.id] || linkLoading === tx.id}
onClick={() => handleManualLink(tx.id)}
>
<Link2 className="h-3 w-3 mr-1" />
{linkLoading === tx.id ? '...' : 'Matcha'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)}
{/* Unmatched GL Lines */}
{glLines.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
Omatchade verifikationer <AccountNumber number="1930" /> ({glLines.length})
</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-16">Ver.nr</th>
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-24">Typ</th>
</tr>
</thead>
<tbody>
{glLines.map((line) => {
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
<tr key={line.line_id} className="border-b last:border-0">
<td className="py-2 font-mono text-xs">
{line.voucher_series}{line.voucher_number}
</td>
<td className="py-2">{line.entry_date}</td>
<td className="py-2 truncate max-w-[300px]">
{line.line_description || line.entry_description}
</td>
<td className={`py-2 text-right font-mono ${amount >= 0 ? 'text-green-600' : ''}`}>
{formatAmount(amount)} kr
</td>
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
</tr>
)
})}
</tbody>
</table>
</CardContent>
</Card>
)}
{/* Recently Matched */}
{matchedTx.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer"
onClick={() => setShowMatched(!showMatched)}
>
<div className="flex items-center gap-2">
{showMatched ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<CardTitle className="text-lg">
Matchade transaktioner ({matchedTx.length})
</CardTitle>
</div>
</CardHeader>
{showMatched && (
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Datum</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-32">Metod</th>
<th className="py-2 w-24"></th>
</tr>
</thead>
<tbody>
{matchedTx.map((tx) => (
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
{formatAmount(tx.amount)} kr
</td>
<td className="py-2">
{tx.reconciliation_method && (
<Badge variant="outline" className="text-xs">
{METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method}
</Badge>
)}
</td>
<td className="py-2">
{tx.reconciliation_method && (
<Button
size="sm"
variant="ghost"
disabled={unlinkLoading === tx.id}
onClick={() => handleUnlink(tx.id)}
>
<Unlink className="h-3 w-3 mr-1" />
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
)}
</Card>
)}
{/* Empty state */}
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && !loading && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga transaktioner eller verifikationer att stämma av.
</CardContent>
</Card>
)}
</div>
)
}
@@ -3,13 +3,11 @@
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { AccountNumber } from '@/components/ui/account-number'
import type { Supplier, VatTreatment } from '@/types'
import type { Supplier } from '@/types'
interface ReviewLineItem {
description: string
quantity: number
unit: string
unit_price: number
amount: number
account_number: string
vat_rate: number
}
@@ -22,7 +20,6 @@ interface SupplierInvoiceReviewContentProps {
deliveryDate?: string
currency: string
exchangeRate?: string
vatTreatment: VatTreatment
reverseCharge: boolean
paymentReference?: string
items: ReviewLineItem[]
@@ -35,13 +32,88 @@ function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const VAT_TREATMENT_LABELS: Record<VatTreatment, string> = {
standard_25: '25% moms',
reduced_12: '12% moms',
reduced_6: '6% moms',
reverse_charge: 'Omvänd skattskyldighet',
export: 'Export (0%)',
exempt: 'Momsfritt',
interface JournalPreviewLine {
account_number: string
description: string
debit: number
credit: number
}
function buildJournalPreview(
items: ReviewLineItem[],
subtotal: number,
totalVat: number,
total: number,
reverseCharge: boolean,
): JournalPreviewLine[] {
const lines: JournalPreviewLine[] = []
// Aggregate expense amounts by account number
const expenseByAccount = new Map<string, number>()
for (const item of items) {
const current = expenseByAccount.get(item.account_number) || 0
expenseByAccount.set(item.account_number, current + Math.round(item.amount * 100) / 100)
}
// Debit: Expense accounts
for (const [accountNumber, amount] of expenseByAccount) {
lines.push({
account_number: accountNumber,
description: accountNumber,
debit: Math.round(amount * 100) / 100,
credit: 0,
})
}
if (reverseCharge) {
// EU reverse charge: fiktiv moms
const vatRate = 0.25
const fiktivVat = Math.round(subtotal * vatRate * 100) / 100
lines.push({
account_number: '2645',
description: 'Beraknad ingaende moms',
debit: fiktivVat,
credit: 0,
})
lines.push({
account_number: '2614',
description: 'Utgaende moms omvand',
debit: 0,
credit: fiktivVat,
})
// Credit: 2440 at subtotal (no real VAT for reverse charge)
lines.push({
account_number: '2440',
description: 'Leverantorsskulder',
debit: 0,
credit: Math.round(subtotal * 100) / 100,
})
} else {
if (totalVat > 0) {
lines.push({
account_number: '2641',
description: 'Ingaende moms',
debit: Math.round(totalVat * 100) / 100,
credit: 0,
})
}
// Credit: 2440 at total incl. VAT
lines.push({
account_number: '2440',
description: 'Leverantorsskulder',
debit: 0,
credit: Math.round(total * 100) / 100,
})
}
return lines
}
const ACCOUNT_LABELS: Record<string, string> = {
'2440': 'Leverantörsskulder',
'2641': 'Ingående moms',
'2645': 'Beräknad ingående moms',
'2614': 'Utg. moms omvänd skattskyldighet',
}
export function SupplierInvoiceReviewContent({
@@ -52,7 +124,6 @@ export function SupplierInvoiceReviewContent({
deliveryDate,
currency,
exchangeRate,
vatTreatment,
reverseCharge,
paymentReference,
items,
@@ -60,6 +131,10 @@ export function SupplierInvoiceReviewContent({
totalVat,
total,
}: SupplierInvoiceReviewContentProps) {
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge)
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
return (
<div className="space-y-4">
{/* Supplier info */}
@@ -68,24 +143,19 @@ export function SupplierInvoiceReviewContent({
<p className="font-medium text-base">{supplier.name}</p>
<p className="text-sm text-muted-foreground">Fakturanr: {invoiceNumber}</p>
</div>
{reverseCharge && (
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
Omvänd skattskyldighet
</Badge>
)}
</div>
{/* VAT + currency badges */}
<div className="flex flex-wrap gap-2">
<Badge className="text-sm px-3 py-1">
{VAT_TREATMENT_LABELS[vatTreatment]}
</Badge>
{currency !== 'SEK' && (
<Badge variant="outline" className="text-sm px-3 py-1">
{currency}
{exchangeRate && ` (kurs ${exchangeRate})`}
</Badge>
)}
<div className="flex gap-2">
{reverseCharge && (
<Badge variant="outline" className="border-orange-300 text-orange-700 dark:text-orange-400">
Omvänd skattskyldighet
</Badge>
)}
{currency !== 'SEK' && (
<Badge variant="outline" className="text-sm">
{currency}
{exchangeRate && ` (kurs ${exchangeRate})`}
</Badge>
)}
</div>
</div>
{/* Dates */}
@@ -110,27 +180,25 @@ export function SupplierInvoiceReviewContent({
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2">Beskrivning</th>
<th className="py-2 w-16 text-right">Antal</th>
<th className="py-2 w-24 text-right">À-pris</th>
<th className="py-2 w-20">Konto</th>
<th className="py-2 w-16 text-right">Moms</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-28 text-right">Belopp</th>
<th className="py-2 w-16 text-right">Moms%</th>
<th className="py-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 100) / 100
const vatAmount = Math.round(item.amount * item.vat_rate * 100) / 100
return (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity} {item.unit}</td>
<td className="py-2 text-right">{formatAmount(item.unit_price)}</td>
<td className="py-2">
<AccountNumber number={item.account_number} size="sm" />
</td>
<td className="py-2">{item.description}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.amount)}</td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right">{formatAmount(lineTotal)}</td>
<td className="py-2 text-right font-mono">{formatAmount(vatAmount)}</td>
</tr>
)
})}
@@ -160,6 +228,46 @@ export function SupplierInvoiceReviewContent({
)}
</div>
{/* Verifikation preview */}
<div className="bg-muted/50 border rounded-lg p-4 space-y-2">
<p className="text-sm font-semibold text-muted-foreground">Verifikation som bokförs</p>
<table className="w-full text-sm font-mono">
<thead>
<tr className="text-left text-muted-foreground text-xs">
<th className="pb-1 w-16">Konto</th>
<th className="pb-1">Beskrivning</th>
<th className="pb-1 w-24 text-right">Debet</th>
<th className="pb-1 w-24 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{journalLines.map((line, index) => (
<tr key={index} className="border-b border-dashed border-muted-foreground/20 last:border-0">
<td className="py-1">
<AccountNumber number={line.account_number} size="sm" />
</td>
<td className="py-1 text-xs">
{ACCOUNT_LABELS[line.account_number] || line.description}
</td>
<td className="py-1 text-right">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1 text-right">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t font-semibold">
<td className="pt-1" colSpan={2}>SUMMA</td>
<td className="pt-1 text-right">{formatAmount(totalDebit)}</td>
<td className="pt-1 text-right">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</div>
{/* Payment reference */}
{paymentReference && (
<div className="border-t pt-3 text-sm text-muted-foreground">
@@ -1,6 +1,5 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
@@ -49,12 +48,12 @@ export default function BatchCategorySelector({
<DialogHeader>
<DialogTitle>
{isProcessing
? `Kategoriserar ${progress.done}/${progress.total}...`
: `Kategorisera ${selectedCount} transaktioner`}
? `Bokför ${progress.done}/${progress.total}...`
: `Bokför ${selectedCount} transaktioner`}
</DialogTitle>
<DialogDescription>
{isProcessing
? 'Vänta medan transaktionerna kategoriseras'
? 'Vänta medan transaktionerna bokförs'
: 'Välj en kategori som ska tillämpas på alla valda transaktioner'}
</DialogDescription>
</DialogHeader>
@@ -112,7 +112,7 @@ export default function SwipeCategorizationView({
if (success) {
moveToNext()
} else {
setError('Kunde inte kategorisera. Tryck "Hoppa över" för att gå vidare.')
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
}
} catch {
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
@@ -140,7 +140,7 @@ export default function SwipeCategorizationView({
setShowCategorySelect(false)
moveToNext()
} else {
setError('Kunde inte kategorisera. Tryck "Hoppa över" för att gå vidare.')
setError('Kunde inte bokföra. Tryck "Hoppa över" för att gå vidare.')
}
} catch {
setError('Ett fel uppstod. Tryck "Hoppa över" för att gå vidare.')
@@ -186,7 +186,7 @@ export default function SwipeCategorizationView({
</div>
<h2 className="text-2xl font-bold">Klart!</h2>
<p className="text-muted-foreground mt-2">
Alla transaktioner är nu kategoriserade
Alla transaktioner är nu bokförda
</p>
<Button onClick={onClose} className="mt-6">
Tillbaka till transaktioner
@@ -276,7 +276,7 @@ export default function SwipeCategorizationView({
<span>Hoppa över</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<span>Kategorisera</span>
<span>Bokför</span>
<Building className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</div>
@@ -457,7 +457,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing}
>
<Building className="mr-2 h-4 w-4" />
Kategorisera
Bokför
</Button>
{/* Skip button - always visible */}
+3
View File
@@ -20,6 +20,7 @@ interface ConfirmationDialogProps {
title: string
warningText?: string
confirmLabel?: string
extraActions?: ReactNode
children: ReactNode
}
@@ -31,6 +32,7 @@ export function ConfirmationDialog({
title,
warningText = 'En verifikation skapas och kan inte ändras efteråt.',
confirmLabel = 'Bekräfta & skapa',
extraActions,
children,
}: ConfirmationDialogProps) {
return (
@@ -66,6 +68,7 @@ export function ConfirmationDialog({
>
Tillbaka
</Button>
{extraActions}
<Button onClick={onConfirm} disabled={isSubmitting}>
{isSubmitting ? (
<>
+1 -1
View File
@@ -131,7 +131,7 @@ export function EmptyReceipts() {
<EmptyState
icon={Camera}
title="Inga kvitton"
description="Ta en bild på ett kvitto för automatisk avläsning och kategorisering. Vi sköter resten!"
description="Ta en bild på ett kvitto för automatisk avläsning och bokföring. Vi sköter resten!"
actionLabel="Skanna kvitto"
actionHref="/receipts/scan"
/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+9 -5
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
NEDeclaration,
NEDeclarationRutor,
@@ -194,13 +195,16 @@ export async function generateNEDeclaration(
}
// Fetch chart of accounts for account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
@@ -53,7 +53,7 @@ export default function ReceiptDashboard({
</div>
</div>
<p className="text-xs text-muted-foreground max-w-24 text-right">
Fortsätt kategorisera varje dag
Fortsätt bokföra varje dag
</p>
</div>
</CardContent>
@@ -281,7 +281,7 @@ export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
income_products: 'Varuintäkt',
income_other: 'Övrig intäkt',
private: 'Privat',
uncategorized: 'Ej kategoriserad',
uncategorized: 'Ej bokförd',
}
/**
+24 -26
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { JournalEntry, JournalEntryLine } from '@/types'
/**
@@ -52,20 +53,19 @@ export async function aggregateBalancesBySRU(
}
// Fetch chart of accounts with SRU codes
const { data: accounts, error: accountsError } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code, normal_balance')
.eq('user_id', userId)
.eq('is_active', true)
if (accountsError) {
throw new Error(`Failed to fetch accounts: ${accountsError.message}`)
}
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null; normal_balance: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code, normal_balance')
.eq('user_id', userId)
.eq('is_active', true)
.range(from, to)
)
// Build lookup maps
const accountSRUMap = new Map<string, string>()
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
for (const acc of accounts) {
if (acc.sru_code) {
accountSRUMap.set(acc.account_number, acc.sru_code)
}
@@ -121,27 +121,25 @@ export async function aggregateBalancesBySRU(
export async function getSRUCoverage(userId: string): Promise<SRUCoverageStats> {
const supabase = await createClient()
const { data: accounts, error } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
if (error) {
throw new Error(`Failed to fetch accounts: ${error.message}`)
}
const allAccounts = accounts || []
const withSRU = allAccounts.filter((a) => a.sru_code)
const withoutSRU = allAccounts.filter((a) => !a.sru_code)
const withSRU = accounts.filter((a) => a.sru_code)
const withoutSRU = accounts.filter((a) => !a.sru_code)
return {
totalAccounts: allAccounts.length,
totalAccounts: accounts.length,
accountsWithSRU: withSRU.length,
accountsWithoutSRU: withoutSRU.length,
coveragePercent: allAccounts.length > 0
? Math.round((withSRU.length / allAccounts.length) * 100)
coveragePercent: accounts.length > 0
? Math.round((withSRU.length / accounts.length) * 100)
: 0,
missingAccounts: withoutSRU.map((a) => ({
accountNumber: a.account_number,
@@ -1,5 +1,104 @@
import { describe, it, expect } from 'vitest'
import { getRevenueAccount } from '../invoice-entries'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getRevenueAccount, getOutputVatAccount } from '../invoice-entries'
import type { Invoice, InvoiceItem, CreateJournalEntryInput } from '@/types'
// Mock the engine so we can capture the input passed to createJournalEntry
vi.mock('../engine', () => ({
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
createJournalEntry: vi.fn().mockImplementation(
async (_userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
})
),
}))
// Mock vat-entries to avoid indirect dependency issues
vi.mock('../vat-entries', () => ({
generateSalesVatLines: vi.fn().mockImplementation(({ vatTreatment, baseAmount }: { vatTreatment: string; baseAmount: number }) => {
const rate = vatTreatment === 'standard_25' ? 0.25
: vatTreatment === 'reduced_12' ? 0.12
: vatTreatment === 'reduced_6' ? 0.06 : 0
if (rate === 0) return []
const account = vatTreatment === 'standard_25' ? '2611'
: vatTreatment === 'reduced_12' ? '2621' : '2631'
return [{
account_number: account,
debit_amount: 0,
credit_amount: Math.round(baseAmount * rate * 100) / 100,
line_description: `Utgående moms`,
}]
}),
generateReverseChargeLines: vi.fn().mockReturnValue([]),
}))
const { createJournalEntry } = await import('../engine')
const mockedCreateEntry = vi.mocked(createJournalEntry)
// Import functions under test AFTER mocks are set up
const {
createInvoiceJournalEntry,
createCreditNoteJournalEntry,
createInvoiceCashEntry,
} = await import('../invoice-entries')
// Helper to build a minimal Invoice with items
function makeInvoice(overrides: Partial<Invoice> & { items?: InvoiceItem[] }): Invoice {
return {
id: 'inv-1',
user_id: 'user-1',
customer_id: 'cust-1',
invoice_number: '1001',
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
exchange_rate: null,
exchange_rate_date: null,
subtotal: 1000,
subtotal_sek: null,
vat_amount: 250,
vat_amount_sek: null,
total: 1250,
total_sek: null,
vat_treatment: 'standard_25',
vat_rate: 25,
moms_ruta: '05',
reverse_charge_text: null,
your_reference: null,
our_reference: null,
notes: null,
status: 'sent',
sent_at: null,
paid_at: null,
payment_date: null,
credited_invoice_id: null,
journal_entry_id: null,
payment_journal_entry_id: null,
document_type: 'invoice',
created_at: '2024-06-15T00:00:00Z',
updated_at: '2024-06-15T00:00:00Z',
items: [],
...overrides,
} as Invoice
}
function makeItem(overrides: Partial<InvoiceItem> = {}): InvoiceItem {
return {
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Service',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2024-06-15T00:00:00Z',
...overrides,
}
}
describe('getRevenueAccount', () => {
it('standard_25 returns 3001', () => {
@@ -37,3 +136,255 @@ describe('getRevenueAccount', () => {
expect(getRevenueAccount('export', 'aktiebolag')).toBe('3305')
})
})
describe('getOutputVatAccount', () => {
it('standard_25 returns 2611', () => {
expect(getOutputVatAccount('standard_25')).toBe('2611')
})
it('reduced_12 returns 2621', () => {
expect(getOutputVatAccount('reduced_12')).toBe('2621')
})
it('reduced_6 returns 2631', () => {
expect(getOutputVatAccount('reduced_6')).toBe('2631')
})
})
describe('createInvoiceJournalEntry — per-line VAT', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('single-rate invoice creates one revenue + one VAT line', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 250,
total: 1250,
vat_treatment: 'standard_25',
items: [
makeItem({ description: 'A', quantity: 2, unit_price: 300, line_total: 600, vat_rate: 25, vat_amount: 150 }),
makeItem({ id: 'item-2', description: 'B', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100 }),
],
})
await createInvoiceJournalEntry('user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
// Should have 3 lines: 1510 debit, 3001 credit, 2611 credit
expect(input.lines).toHaveLength(3)
// Debit 1510 = total
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(1250)
expect(debit1510?.credit_amount).toBe(0)
// Credit 3001 = subtotal
const credit3001 = input.lines.find((l) => l.account_number === '3001')
expect(credit3001?.debit_amount).toBe(0)
expect(credit3001?.credit_amount).toBe(1000)
// Credit 2611 = VAT
const credit2611 = input.lines.find((l) => l.account_number === '2611')
expect(credit2611?.debit_amount).toBe(0)
expect(credit2611?.credit_amount).toBe(250)
})
it('mixed 25%/12% creates two revenue + two VAT lines', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 184, // 600*0.25 + 400*0.12 = 150 + 48 = 198... let's recalc
total: 1198,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'Consulting', quantity: 1, unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }),
makeItem({ id: 'item-2', description: 'Food service', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 12, vat_amount: 48 }),
],
})
invoice.vat_amount = 198
invoice.total = 1198
await createInvoiceJournalEntry('user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
// Should have 5 lines: 1510, 3001(25%), 2611(25%), 3002(12%), 2621(12%)
expect(input.lines).toHaveLength(5)
// Debit 1510 = total
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(1198)
// Revenue 3001 (25% group)
const credit3001 = input.lines.find((l) => l.account_number === '3001')
expect(credit3001?.credit_amount).toBe(600)
// VAT 2611 (25% group)
const credit2611 = input.lines.find((l) => l.account_number === '2611')
expect(credit2611?.credit_amount).toBe(150)
// Revenue 3002 (12% group)
const credit3002 = input.lines.find((l) => l.account_number === '3002')
expect(credit3002?.credit_amount).toBe(400)
// VAT 2621 (12% group)
const credit2621 = input.lines.find((l) => l.account_number === '2621')
expect(credit2621?.credit_amount).toBe(48)
})
it('reverse charge creates single 3308, no VAT lines', async () => {
const invoice = makeInvoice({
subtotal: 5000,
vat_amount: 0,
total: 5000,
vat_treatment: 'reverse_charge',
vat_rate: 0,
items: [
makeItem({ quantity: 1, unit_price: 5000, line_total: 5000, vat_rate: 0, vat_amount: 0 }),
],
})
await createInvoiceJournalEntry('user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
// Should have 2 lines: 1510 debit, 3308 credit (no VAT)
expect(input.lines).toHaveLength(2)
const debit1510 = input.lines.find((l) => l.account_number === '1510')
expect(debit1510?.debit_amount).toBe(5000)
const credit3308 = input.lines.find((l) => l.account_number === '3308')
expect(credit3308?.credit_amount).toBe(5000)
// No VAT lines
const vatLines = input.lines.filter((l) =>
l.account_number.startsWith('26')
)
expect(vatLines).toHaveLength(0)
})
it('balance: debit(1510) = sum(revenue + VAT credits)', async () => {
const invoice = makeInvoice({
subtotal: 2000,
vat_amount: 380, // 1200*0.25 + 500*0.12 + 300*0.06 = 300 + 60 + 18 = 378
total: 2378,
vat_treatment: 'standard_25',
vat_rate: null as unknown as number,
items: [
makeItem({ description: 'A', quantity: 1, unit_price: 1200, line_total: 1200, vat_rate: 25, vat_amount: 300 }),
makeItem({ id: 'item-2', description: 'B', quantity: 1, unit_price: 500, line_total: 500, vat_rate: 12, vat_amount: 60 }),
makeItem({ id: 'item-3', description: 'C', quantity: 1, unit_price: 300, line_total: 300, vat_rate: 6, vat_amount: 18 }),
],
})
invoice.vat_amount = 378
invoice.total = 2378
await createInvoiceJournalEntry('user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][1]
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
expect(totalDebit).toBe(2378)
})
})
describe('createCreditNoteJournalEntry — per-line VAT', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('reverses per-rate lines correctly for mixed rates', async () => {
const creditNote = makeInvoice({
invoice_number: 'KR-1001',
subtotal: -1000,
vat_amount: -198,
total: -1198,
vat_treatment: 'standard_25',
items: [
makeItem({ quantity: -1, unit_price: 600, line_total: -600, vat_rate: 25, vat_amount: -150 }),
makeItem({ id: 'item-2', quantity: -1, unit_price: 400, line_total: -400, vat_rate: 12, vat_amount: -48 }),
],
})
await createCreditNoteJournalEntry('user-1', creditNote)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
// Revenue and VAT lines should be debits (reversed)
const debit3001 = input.lines.find((l) => l.account_number === '3001')
expect(debit3001?.debit_amount).toBe(600)
expect(debit3001?.credit_amount).toBe(0)
const debit2611 = input.lines.find((l) => l.account_number === '2611')
expect(debit2611?.debit_amount).toBe(150)
const debit3002 = input.lines.find((l) => l.account_number === '3002')
expect(debit3002?.debit_amount).toBe(400)
const debit2621 = input.lines.find((l) => l.account_number === '2621')
expect(debit2621?.debit_amount).toBe(48)
// 1510 should be credit
const credit1510 = input.lines.find((l) => l.account_number === '1510')
expect(credit1510?.credit_amount).toBe(1198)
expect(credit1510?.debit_amount).toBe(0)
// Balance check
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
})
describe('createInvoiceCashEntry — per-line VAT', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('cash method with mixed rates creates per-rate revenue + VAT', async () => {
const invoice = makeInvoice({
subtotal: 1000,
vat_amount: 198,
total: 1198,
vat_treatment: 'standard_25',
items: [
makeItem({ quantity: 1, unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 }),
makeItem({ id: 'item-2', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 12, vat_amount: 48 }),
],
})
await createInvoiceCashEntry('user-1', invoice, '2024-07-01')
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
// Debit 1930 (bank account) instead of 1510
const debit1930 = input.lines.find((l) => l.account_number === '1930')
expect(debit1930?.debit_amount).toBe(1198)
// Same per-rate credits as accrual
const credit3001 = input.lines.find((l) => l.account_number === '3001')
expect(credit3001?.credit_amount).toBe(600)
const credit2611 = input.lines.find((l) => l.account_number === '2611')
expect(credit2611?.credit_amount).toBe(150)
const credit3002 = input.lines.find((l) => l.account_number === '3002')
expect(credit3002?.credit_amount).toBe(400)
// Balance
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
})
})
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { validatePeriodDuration, monthsBetween } from '../validate-period-duration'
describe('monthsBetween', () => {
it('returns 12 for a standard calendar year', () => {
expect(monthsBetween('2025-01-01', '2025-12-31')).toBe(12)
})
it('returns 18 for an 18-month period', () => {
expect(monthsBetween('2025-07-01', '2026-12-31')).toBe(18)
})
it('returns 1 for a single month', () => {
expect(monthsBetween('2025-03-01', '2025-03-31')).toBe(1)
})
it('returns 3 for a quarter', () => {
expect(monthsBetween('2025-10-01', '2025-12-31')).toBe(3)
})
})
describe('validatePeriodDuration', () => {
it('returns null for a valid 12-month period', () => {
expect(validatePeriodDuration('2025-01-01', '2025-12-31')).toBeNull()
})
it('returns null for exactly 18 months (max allowed)', () => {
expect(validatePeriodDuration('2025-07-01', '2026-12-31')).toBeNull()
})
it('returns null for a short first year (1 month)', () => {
expect(validatePeriodDuration('2025-12-01', '2025-12-31')).toBeNull()
})
it('returns null for a short first year (3 months)', () => {
expect(validatePeriodDuration('2025-10-01', '2025-12-31')).toBeNull()
})
it('returns error for 19 months (exceeds max)', () => {
const result = validatePeriodDuration('2025-06-01', '2026-12-31')
expect(result).toContain('19 months')
expect(result).toContain('18 months')
})
it('returns error when end is before start', () => {
expect(validatePeriodDuration('2025-06-01', '2025-01-31')).toBe(
'Period end must be after period start'
)
})
it('returns error when start is not 1st of month', () => {
expect(validatePeriodDuration('2025-01-15', '2025-12-31')).toBe(
'Period start must be the 1st of a month'
)
})
it('returns error when end is not last day of month', () => {
expect(validatePeriodDuration('2025-01-01', '2025-12-15')).toBe(
'Period end must be the last day of a month'
)
})
it('handles February end correctly (non-leap year)', () => {
expect(validatePeriodDuration('2025-01-01', '2025-02-28')).toBeNull()
})
it('handles February end correctly (leap year)', () => {
expect(validatePeriodDuration('2024-01-01', '2024-02-29')).toBeNull()
})
it('rejects Feb 28 in a leap year (not last day)', () => {
expect(validatePeriodDuration('2024-01-01', '2024-02-28')).toBe(
'Period end must be the last day of a month'
)
})
it('returns null for a broken fiscal year (May-Apr)', () => {
expect(validatePeriodDuration('2025-05-01', '2026-04-30')).toBeNull()
})
})
+49 -1
View File
@@ -326,7 +326,55 @@ const ACCOUNT_DESCRIPTIONS: Record<string, AccountDescription> = {
}
export function getAccountDescription(accountNumber: string): AccountDescription | undefined {
return ACCOUNT_DESCRIPTIONS[accountNumber]
// Check hardcoded descriptions first (most detailed explanations)
const hardcoded = ACCOUNT_DESCRIPTIONS[accountNumber]
if (hardcoded) return hardcoded
// Fall back to BAS reference data for accounts not in the hardcoded list
try {
// Dynamic import avoided — use lazy require pattern
const { getBASReference, ACCOUNT_CLASS_LABELS } = require('./bas-reference')
const ref = getBASReference(accountNumber)
if (ref) {
const classLabel = ACCOUNT_CLASS_LABELS[ref.account_class] || ''
return {
name: ref.account_name,
classLabel,
type: ref.account_type,
explanation: ref.description,
}
}
} catch {
// BAS reference not available — that's fine
}
return undefined
}
/**
* Get a human-readable account class name for BAS account classes.
*/
export function getAccountClassName(accountClass: number): string {
switch (accountClass) {
case 1:
return '1xxx - Tillgångar'
case 2:
return '2xxx - Eget kapital & Skulder'
case 3:
return '3xxx - Intäkter'
case 4:
return '4xxx - Varuinköp'
case 5:
return '5xxx - Externa kostnader'
case 6:
return '6xxx - Övriga externa kostnader'
case 7:
return '7xxx - Personal'
case 8:
return '8xxx - Finansiella poster'
default:
return `${accountClass}xxx - Övrigt`
}
}
export { ACCOUNT_DESCRIPTIONS }
File diff suppressed because it is too large Load Diff
+160 -57
View File
@@ -1,21 +1,104 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoice/vat-rules'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
EntityType,
Invoice,
InvoiceItem,
JournalEntry,
VatTreatment,
} from '@/types'
/**
* Group invoice items by VAT rate and generate per-rate revenue + VAT lines.
* Returns credit lines only (revenue + VAT). The caller adds the debit side.
*/
function generatePerRateLines(
items: InvoiceItem[],
invoiceVatTreatment: VatTreatment,
entityType: EntityType,
invoiceNumber: string
): CreateJournalEntryLineInput[] {
const lines: CreateJournalEntryLineInput[] = []
// Check if items have per-line vat_rate set (new invoices)
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
if (!hasPerLineVat) {
// Legacy fallback: single rate from invoice level
const revenueAccount = getRevenueAccount(invoiceVatTreatment, entityType)
const subtotal = items.reduce((sum, item) => sum + item.line_total, 0)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: subtotal,
line_description: `Försäljning faktura ${invoiceNumber}`,
})
const totalVat = items.reduce((sum, item) => sum + (item.vat_amount || 0), 0)
if (totalVat > 0) {
const vatLines = generateSalesVatLines({
vatTreatment: invoiceVatTreatment,
baseAmount: subtotal,
direction: 'sales',
})
lines.push(...vatLines)
}
return lines
}
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of items) {
const rate = item.vat_rate ?? 25
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
group.vatAmount += item.vat_amount || 0
rateGroups.set(rate, group)
}
// Generate revenue + VAT lines per rate group
for (const [rate, group] of rateGroups) {
const treatment = rate === 0 && (invoiceVatTreatment === 'reverse_charge' || invoiceVatTreatment === 'export')
? invoiceVatTreatment
: getVatTreatmentForRate(rate)
const revenueAccount = getRevenueAccount(treatment, entityType)
const roundedSubtotal = Math.round(group.subtotal * 100) / 100
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: roundedSubtotal,
line_description: `Försäljning faktura ${invoiceNumber}`,
})
const roundedVat = Math.round(group.vatAmount * 100) / 100
if (roundedVat !== 0) {
const vatAccount = getOutputVatAccount(treatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: roundedVat,
line_description: `Utgående moms ${rate}%`,
})
}
}
return lines
}
/**
* Create journal entry when an invoice is created (status != draft)
*
* Supports mixed VAT rates per line item. Groups items by vat_rate
* and creates separate revenue + VAT lines per rate.
*
* Standard domestic invoice (25% VAT):
* Debit 1510 Kundfordringar [total incl VAT]
* Credit 30xx Försäljning [subtotal]
* Credit 2611 Utgående moms 25% [vat_amount]
* Credit 30xx Försäljning [subtotal per rate]
* Credit 26xx Utgående moms [vat per rate]
*
* EU reverse charge:
* Debit 1510 Kundfordringar [subtotal]
@@ -38,9 +121,6 @@ export async function createInvoiceJournalEntry(
const lines: CreateJournalEntryLineInput[] = []
// Determine revenue account based on VAT treatment
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
// Debit: Kundfordringar (total including VAT)
lines.push({
account_number: '1510',
@@ -52,22 +132,27 @@ export async function createInvoiceJournalEntry(
exchange_rate: invoice.exchange_rate || undefined,
})
// Credit: Revenue account (subtotal, excl VAT)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
// VAT lines (if applicable)
if (invoice.vat_amount > 0) {
const vatLines = generateSalesVatLines({
vatTreatment: invoice.vat_treatment,
baseAmount: invoice.subtotal,
direction: 'sales',
// Credit lines: revenue + VAT per rate group
if (invoice.items && invoice.items.length > 0) {
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
} else {
// Fallback: no items available, use invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
lines.push(...vatLines)
if (invoice.vat_amount > 0) {
const vatLines = generateSalesVatLines({
vatTreatment: invoice.vat_treatment,
baseAmount: invoice.subtotal,
direction: 'sales',
})
lines.push(...vatLines)
}
}
const input: CreateJournalEntryInput = {
@@ -128,9 +213,10 @@ export async function createInvoicePaymentJournalEntry(
/**
* Create journal entry for a credit note (reversed version of original invoice entry)
* Supports per-item VAT rates with reversed debit/credit sides.
*
* Debit 30xx Försäljning [subtotal]
* Debit 26xx Utgående moms [vat_amount]
* Debit 30xx Försäljning [subtotal per rate]
* Debit 26xx Utgående moms [vat per rate]
* Credit 1510 Kundfordringar [total]
*/
export async function createCreditNoteJournalEntry(
@@ -144,30 +230,43 @@ export async function createCreditNoteJournalEntry(
return null
}
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
const absSubtotal = Math.abs(creditNote.subtotal)
const absVat = Math.abs(creditNote.vat_amount)
const absTotal = Math.abs(creditNote.total)
const lines: CreateJournalEntryLineInput[] = []
// Debit: Revenue account (reverse the credit)
lines.push({
account_number: revenueAccount,
debit_amount: absSubtotal,
credit_amount: 0,
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
})
// Generate reversed revenue + VAT lines per rate group
if (creditNote.items && creditNote.items.length > 0) {
const creditLines = generatePerRateLines(creditNote.items, creditNote.vat_treatment, entityType, creditNote.invoice_number)
// Swap debit/credit for credit note reversal (make amounts absolute first)
for (const line of creditLines) {
lines.push({
...line,
debit_amount: Math.abs(line.credit_amount),
credit_amount: Math.abs(line.debit_amount),
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
})
}
} else {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(creditNote.vat_treatment, entityType)
const absSubtotal = Math.abs(creditNote.subtotal)
const absVat = Math.abs(creditNote.vat_amount)
// Debit: VAT account (reverse the credit, if applicable)
if (absVat > 0) {
const vatAccount = getOutputVatAccount(creditNote.vat_treatment)
lines.push({
account_number: vatAccount,
debit_amount: absVat,
account_number: revenueAccount,
debit_amount: absSubtotal,
credit_amount: 0,
line_description: `Moms kreditfaktura ${creditNote.invoice_number}`,
line_description: `Kreditfaktura ${creditNote.invoice_number}`,
})
if (absVat > 0) {
const vatAccount = getOutputVatAccount(creditNote.vat_treatment)
lines.push({
account_number: vatAccount,
debit_amount: absVat,
credit_amount: 0,
line_description: `Moms kreditfaktura ${creditNote.invoice_number}`,
})
}
}
// Credit: Kundfordringar (reverse the debit)
@@ -192,11 +291,11 @@ export async function createCreditNoteJournalEntry(
/**
* Create journal entry for kontantmetoden (cash method) when payment is received.
* Combined entry: revenue + VAT recognised at payment.
* Supports per-item VAT rates. Revenue + VAT recognised at payment.
*
* Debit 1930 Företagskonto [total]
* Credit 30xx Försäljning [subtotal]
* Credit 26xx Utgående moms [vat_amount] (if applicable)
* Credit 30xx Försäljning [subtotal per rate]
* Credit 26xx Utgående moms [vat per rate] (if applicable)
*/
export async function createInvoiceCashEntry(
userId: string,
@@ -210,7 +309,6 @@ export async function createInvoiceCashEntry(
return null
}
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
const lines: CreateJournalEntryLineInput[] = []
// Debit: Företagskonto (total received)
@@ -221,23 +319,28 @@ export async function createInvoiceCashEntry(
line_description: `Betalning faktura ${invoice.invoice_number}`,
})
// Credit: Revenue account (subtotal excl VAT)
lines.push({
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
// Credit: Output VAT (if applicable)
if (invoice.vat_amount > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
// Credit lines: revenue + VAT per rate group
if (invoice.items && invoice.items.length > 0) {
lines.push(...generatePerRateLines(invoice.items, invoice.vat_treatment, entityType, invoice.invoice_number))
} else {
// Fallback: invoice-level amounts
const revenueAccount = getRevenueAccount(invoice.vat_treatment, entityType)
lines.push({
account_number: vatAccount,
account_number: revenueAccount,
debit_amount: 0,
credit_amount: invoice.vat_amount,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
credit_amount: invoice.subtotal,
line_description: `Försäljning faktura ${invoice.invoice_number}`,
})
if (invoice.vat_amount > 0) {
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
lines.push({
account_number: vatAccount,
debit_amount: 0,
credit_amount: invoice.vat_amount,
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
})
}
}
const input: CreateJournalEntryInput = {
+1 -1
View File
@@ -190,7 +190,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
requires_review: true,
default_private: false,
vat_lines: [],
description: 'Okategoriserad transaktion',
description: 'Obokförd transaktion',
}
}
@@ -0,0 +1,48 @@
/**
* Validates fiscal period duration per BFL 3 kap.
* Maximum 18 months for any fiscal period (first year may be extended).
* Normal ongoing periods are 12 months.
*/
/**
* Calculate the number of months between two dates (inclusive of partial months).
* Assumes start is 1st of month and end is last of month.
*/
export function monthsBetween(start: string, end: string): number {
const s = new Date(start)
const e = new Date(end)
return (e.getFullYear() - s.getFullYear()) * 12 + (e.getMonth() - s.getMonth()) + 1
}
/**
* Validate a fiscal period's duration and date constraints.
* Returns null if valid, or an error message string if invalid.
*/
export function validatePeriodDuration(start: string, end: string): string | null {
const startDate = new Date(start)
const endDate = new Date(end)
// end must be after start
if (endDate <= startDate) {
return 'Period end must be after period start'
}
// start must be 1st of month
if (startDate.getDate() !== 1) {
return 'Period start must be the 1st of a month'
}
// end must be last day of month
const lastDayOfEndMonth = new Date(endDate.getFullYear(), endDate.getMonth() + 1, 0).getDate()
if (endDate.getDate() !== lastDayOfEndMonth) {
return 'Period end must be the last day of a month'
}
// Max 18 months per BFL 3 kap.
const months = monthsBetween(start, end)
if (months > 18) {
return `Period duration ${months} months exceeds maximum 18 months (BFL 3 kap.)`
}
return null
}
+7
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { FiscalPeriod, PeriodStatus } from '@/types'
/**
@@ -161,6 +162,12 @@ export async function createNextPeriod(
const nextStartStr = nextStart.toISOString().split('T')[0]
const nextEndStr = nextEnd.toISOString().split('T')[0]
// Validate period duration (max 18 months per BFL 3 kap.)
const durationError = validatePeriodDuration(nextStartStr, nextEndStr)
if (durationError) {
throw new Error(durationError)
}
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
const startYear = nextStart.getFullYear()
const endYear = nextEnd.getFullYear()
+22 -7
View File
@@ -1,6 +1,14 @@
import type { Invoice, Customer, CompanySettings } from '@/types'
import type { Invoice, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
import { formatCurrency, formatDate } from '@/lib/utils'
function getDocumentLabel(invoice: Invoice): string {
if (invoice.credited_invoice_id) return 'Kreditfaktura'
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return 'Proformafaktura'
if (docType === 'delivery_note') return 'Följesedel'
return 'Faktura'
}
export interface InvoiceEmailData {
invoice: Invoice
customer: Customer
@@ -13,8 +21,12 @@ export interface InvoiceEmailData {
export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const documentType = getDocumentLabel(invoice)
const isCreditNote = !!invoice.credited_invoice_id
const documentType = isCreditNote ? 'Kreditfaktura' : 'Faktura'
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
return `
<!DOCTYPE html>
@@ -79,7 +91,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
</div>
<!-- Payment Details -->
${!isCreditNote ? `
${!hidePayment ? `
<div style="margin-bottom: 30px;">
<h2 style="margin: 0 0 15px 0; font-size: 16px; font-weight: 600; color: #111;">
Betalningsinformation
@@ -146,8 +158,12 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
export function generateInvoiceEmailText(data: InvoiceEmailData): string {
const { invoice, customer, company } = data
const documentType = getDocumentLabel(invoice)
const isCreditNote = !!invoice.credited_invoice_id
const documentType = isCreditNote ? 'Kreditfaktura' : 'Faktura'
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
const hidePayment = isCreditNote || isDeliveryNote || isProforma
let text = `${documentType} från ${company.company_name}\n`
text += `${documentType}nummer: ${invoice.invoice_number}\n\n`
@@ -168,7 +184,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
text += `Att betala: ${formatCurrency(invoice.total, invoice.currency)}\n`
text += `---\n\n`
if (!isCreditNote) {
if (!hidePayment) {
text += `Betalningsinformation:\n`
if (company.bank_name) text += `Bank: ${company.bank_name}\n`
if (company.clearing_number && company.account_number) {
@@ -198,8 +214,7 @@ export function generateInvoiceEmailText(data: InvoiceEmailData): string {
*/
export function generateInvoiceEmailSubject(data: InvoiceEmailData): string {
const { invoice, company } = data
const isCreditNote = !!invoice.credited_invoice_id
const documentType = isCreditNote ? 'Kreditfaktura' : 'Faktura'
const documentType = getDocumentLabel(invoice)
return `${documentType} ${invoice.invoice_number} från ${company.company_name}`
}
+2
View File
@@ -10,6 +10,7 @@ import type {
CAMT053Statement,
CAMT054Notification,
AuditSecurityEvent,
ReconciliationMethod,
} from '@/types'
// ============================================================
@@ -32,6 +33,7 @@ export type CoreEvent =
// Banking
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string } }
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string } }
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string } }
| { type: 'bank.statement_received'; payload: { statement: CAMT053Statement; userId: string } }
| { type: 'bank.payment_notification'; payload: { notification: CAMT054Notification; userId: string } }
// Periods
+351
View File
@@ -0,0 +1,351 @@
import { describe, it, expect } from 'vitest'
import type { BASAccount } from '@/types'
import type { SIEAccount, SIEAccountMappingRecord } from '../types'
import {
suggestMappings,
validateMappings,
getMappingStats,
applyMappingOverride,
mappingsToMap,
} from '../account-mapper'
// --- Helpers ---
function makeBASAccount(number: string, name: string): BASAccount {
const classNum = parseInt(number.charAt(0), 10)
const accountType =
classNum <= 1
? 'asset'
: classNum === 2
? 'liability'
: classNum === 3
? 'revenue'
: 'expense'
return {
id: `bas-${number}`,
user_id: 'user-1',
account_number: number,
account_name: name,
account_class: classNum,
account_group: number.substring(0, 2),
account_type: accountType,
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
plan_type: 'k1',
is_active: true,
is_system_account: false,
default_vat_code: null,
description: null,
sru_code: null,
sort_order: parseInt(number, 10),
created_at: '2024-01-01',
updated_at: '2024-01-01',
}
}
function makeSIEAccount(number: string, name: string): SIEAccount {
return { number, name }
}
// --- Fixtures ---
const basAccounts: BASAccount[] = [
makeBASAccount('1510', 'Kundfordringar'),
makeBASAccount('1930', 'Företagskonto'),
makeBASAccount('2440', 'Leverantörsskulder'),
makeBASAccount('3001', 'Försäljning varor 25%'),
makeBASAccount('3002', 'Försäljning varor 12%'),
makeBASAccount('5010', 'Lokalhyra'),
makeBASAccount('6211', 'Telekommunikation'),
]
// --- Tests ---
describe('suggestMappings', () => {
it('returns exact match with confidence 1.0', () => {
const source = [makeSIEAccount('1510', 'Kundfordringar')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('1510')
expect(result[0].targetName).toBe('Kundfordringar')
expect(result[0].confidence).toBe(1.0)
expect(result[0].matchType).toBe('exact')
expect(result[0].isOverride).toBe(false)
})
it('returns unmapped entry when no match exists', () => {
const source = [makeSIEAccount('9999', 'Okänt konto')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('')
expect(result[0].targetName).toBe('')
expect(result[0].confidence).toBe(0)
expect(result[0].matchType).toBe('manual')
})
it('does not fuzzy match accounts with similar names', () => {
// 3400 should NOT match 3001 or 3002 despite being in same class
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('')
expect(result[0].confidence).toBe(0)
})
it('does not fuzzy match accounts with similar numbers', () => {
// 2510 should NOT match 2440 despite being in same class
const source = [makeSIEAccount('2510', 'Skatteskulder')]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('')
expect(result[0].confidence).toBe(0)
})
it('preserves user overrides from existing mappings', () => {
const source = [makeSIEAccount('3400', 'Försäljning tjänster')]
const existingMappings: SIEAccountMappingRecord[] = [
{
id: 'map-1',
user_id: 'user-1',
source_account: '3400',
source_name: 'Försäljning tjänster',
target_account: '3001',
confidence: 1.0,
match_type: 'manual',
created_at: '2024-01-01',
updated_at: '2024-01-01',
},
]
const result = suggestMappings(source, basAccounts, existingMappings)
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('3001')
expect(result[0].isOverride).toBe(true)
expect(result[0].matchType).toBe('manual')
})
it('sorts unmapped accounts first (lowest confidence)', () => {
const source = [
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('9999', 'Okänt konto'),
makeSIEAccount('1930', 'Företagskonto'),
]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(3)
// Unmapped (confidence 0) should come first
expect(result[0].sourceAccount).toBe('9999')
expect(result[0].confidence).toBe(0)
// Exact matches (confidence 1.0) come after
expect(result[1].confidence).toBe(1.0)
expect(result[2].confidence).toBe(1.0)
})
it('handles multiple accounts with mixed results', () => {
const source = [
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('3400', 'Försäljning tjänster'),
makeSIEAccount('5010', 'Lokalhyra'),
]
const result = suggestMappings(source, basAccounts)
expect(result).toHaveLength(3)
const mapped = result.filter((m) => m.targetAccount)
const unmapped = result.filter((m) => !m.targetAccount)
expect(mapped).toHaveLength(2)
expect(unmapped).toHaveLength(1)
})
it('handles empty source accounts', () => {
const result = suggestMappings([], basAccounts)
expect(result).toHaveLength(0)
})
it('handles empty BAS accounts — everything unmapped', () => {
const source = [makeSIEAccount('1510', 'Kundfordringar')]
const result = suggestMappings(source, [])
expect(result).toHaveLength(1)
expect(result[0].targetAccount).toBe('')
expect(result[0].confidence).toBe(0)
})
})
describe('validateMappings', () => {
it('returns valid when all accounts are mapped', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1930', 'Företagskonto')],
basAccounts
)
const validation = validateMappings(mappings)
expect(validation.valid).toBe(true)
expect(validation.unmappedAccounts).toHaveLength(0)
})
it('returns invalid when accounts are unmapped', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
basAccounts
)
const validation = validateMappings(mappings)
expect(validation.valid).toBe(false)
expect(validation.unmappedAccounts).toContain('9999')
expect(validation.unmappedAccounts).toHaveLength(1)
})
it('detects low confidence accounts', () => {
// With exact-match-only mapper, low confidence only comes from existing overrides
const mappings = [
{
sourceAccount: '3400',
sourceName: 'Försäljning tjänster',
targetAccount: '3001',
targetName: 'Försäljning varor 25%',
confidence: 0.3,
matchType: 'class' as const,
isOverride: false,
},
]
const validation = validateMappings(mappings)
expect(validation.lowConfidenceAccounts).toContain('3400')
})
})
describe('getMappingStats', () => {
it('counts total, mapped, unmapped correctly', () => {
const mappings = suggestMappings(
[
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('9999', 'Okänt konto'),
makeSIEAccount('5010', 'Lokalhyra'),
],
basAccounts
)
const stats = getMappingStats(mappings)
expect(stats.total).toBe(3)
expect(stats.mapped).toBe(2)
expect(stats.unmapped).toBe(1)
})
it('counts match types correctly', () => {
const mappings = suggestMappings(
[
makeSIEAccount('1510', 'Kundfordringar'),
makeSIEAccount('9999', 'Okänt konto'),
],
basAccounts
)
const stats = getMappingStats(mappings)
expect(stats.exact).toBe(1)
expect(stats.manual).toBe(1) // unmapped gets matchType 'manual'
expect(stats.name).toBe(0)
expect(stats.class).toBe(0)
})
it('calculates average confidence for mapped accounts only', () => {
const mappings = suggestMappings(
[
makeSIEAccount('1510', 'Kundfordringar'), // exact, confidence 1.0
makeSIEAccount('1930', 'Företagskonto'), // exact, confidence 1.0
makeSIEAccount('9999', 'Okänt konto'), // unmapped, confidence 0
],
basAccounts
)
const stats = getMappingStats(mappings)
// Average of mapped only: (1.0 + 1.0) / 2 = 1.0
expect(stats.averageConfidence).toBe(1.0)
})
it('returns 0 average confidence when nothing is mapped', () => {
const mappings = suggestMappings(
[makeSIEAccount('9999', 'Okänt konto')],
basAccounts
)
const stats = getMappingStats(mappings)
expect(stats.averageConfidence).toBe(0)
})
})
describe('applyMappingOverride', () => {
it('sets target, confidence 1.0, matchType manual, and isOverride', () => {
const mappings = suggestMappings(
[makeSIEAccount('3400', 'Försäljning tjänster')],
basAccounts
)
const updated = applyMappingOverride(mappings, '3400', '3001', 'Försäljning varor 25%')
expect(updated).toHaveLength(1)
expect(updated[0].targetAccount).toBe('3001')
expect(updated[0].targetName).toBe('Försäljning varor 25%')
expect(updated[0].confidence).toBe(1.0)
expect(updated[0].matchType).toBe('manual')
expect(updated[0].isOverride).toBe(true)
})
it('does not mutate the original array', () => {
const mappings = suggestMappings(
[makeSIEAccount('3400', 'Försäljning tjänster')],
basAccounts
)
const original = [...mappings]
applyMappingOverride(mappings, '3400', '3001', 'Försäljning varor 25%')
expect(mappings[0].targetAccount).toBe(original[0].targetAccount)
expect(mappings[0].confidence).toBe(original[0].confidence)
})
it('only affects the specified source account', () => {
const mappings = suggestMappings(
[
makeSIEAccount('3400', 'Försäljning tjänster'),
makeSIEAccount('9998', 'Annat okänt konto'),
],
basAccounts
)
const updated = applyMappingOverride(mappings, '3400', '3001', 'Försäljning varor 25%')
const unchanged = updated.find((m) => m.sourceAccount === '9998')
expect(unchanged?.targetAccount).toBe('')
expect(unchanged?.confidence).toBe(0)
})
})
describe('mappingsToMap', () => {
it('creates a Map from source to target account', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('1930', 'Företagskonto')],
basAccounts
)
const map = mappingsToMap(mappings)
expect(map.get('1510')).toBe('1510')
expect(map.get('1930')).toBe('1930')
expect(map.size).toBe(2)
})
it('skips unmapped accounts', () => {
const mappings = suggestMappings(
[makeSIEAccount('1510', 'Kundfordringar'), makeSIEAccount('9999', 'Okänt konto')],
basAccounts
)
const map = mappingsToMap(mappings)
expect(map.get('1510')).toBe('1510')
expect(map.has('9999')).toBe(false)
expect(map.size).toBe(1)
})
})
+223
View File
@@ -0,0 +1,223 @@
import { describe, it, expect } from 'vitest'
import { generateImportPreview } from '../sie-import'
import type { ParsedSIEFile, AccountMapping } from '../types'
// --- Helpers ---
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
return {
header: {
sieType: 4,
program: 'TestProg',
programVersion: '1.0',
generatedDate: new Date(2024, 0, 1),
format: 'PC8',
companyName: 'Test AB',
orgNumber: '5566778899',
address: null,
fiscalYears: [{ yearIndex: 0, start: new Date(2024, 0, 1), end: new Date(2024, 11, 31) }],
currency: 'SEK',
},
accounts: [
{ number: '1510', name: 'Kundfordringar' },
{ number: '1930', name: 'Företagskonto' },
{ number: '2440', name: 'Leverantörsskulder' },
],
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '1930', amount: 100000 },
{ yearIndex: 0, account: '2440', amount: -150000 },
],
closingBalances: [],
resultBalances: [],
vouchers: [
{
series: 'A',
number: 1,
date: new Date(2024, 0, 15),
description: 'Faktura 1001',
lines: [
{ account: '1510', amount: 12500 },
{ account: '3001', amount: -10000 },
{ account: '2611', amount: -2500 },
],
},
],
issues: [],
stats: {
totalAccounts: 3,
totalVouchers: 1,
totalTransactionLines: 3,
fiscalYearStart: new Date(2024, 0, 1),
fiscalYearEnd: new Date(2024, 11, 31),
},
...overrides,
}
}
function makeMapping(source: string, target: string, confidence: number = 1.0): AccountMapping {
return {
sourceAccount: source,
sourceName: `Account ${source}`,
targetAccount: target,
targetName: `Target ${target}`,
confidence,
matchType: target ? 'exact' : 'manual',
isOverride: false,
}
}
// --- Tests ---
describe('generateImportPreview', () => {
describe('trial balance from IB', () => {
it('calculates debit totals from positive IB amounts', () => {
const parsed = makeParsedFile()
const mappings = [
makeMapping('1510', '1510'),
makeMapping('1930', '1930'),
makeMapping('2440', '2440'),
]
const preview = generateImportPreview(parsed, mappings)
// Positive amounts: 50000 + 100000 = 150000
expect(preview.trialBalance.totalDebit).toBe(150000)
})
it('calculates credit totals from negative IB amounts', () => {
const parsed = makeParsedFile()
const mappings = [
makeMapping('1510', '1510'),
makeMapping('1930', '1930'),
makeMapping('2440', '2440'),
]
const preview = generateImportPreview(parsed, mappings)
// Negative amounts: |-150000| = 150000
expect(preview.trialBalance.totalCredit).toBe(150000)
})
it('detects balanced trial balance', () => {
const parsed = makeParsedFile()
const mappings = [makeMapping('1510', '1510')]
const preview = generateImportPreview(parsed, mappings)
// 150000 debit = 150000 credit
expect(preview.trialBalance.isBalanced).toBe(true)
})
it('detects unbalanced trial balance', () => {
const parsed = makeParsedFile({
openingBalances: [
{ yearIndex: 0, account: '1510', amount: 50000 },
{ yearIndex: 0, account: '1930', amount: 100000 },
// Missing credit side — only 150000 debit, 0 credit
],
})
const mappings = [makeMapping('1510', '1510')]
const preview = generateImportPreview(parsed, mappings)
expect(preview.trialBalance.isBalanced).toBe(false)
})
it('handles zero opening balances', () => {
const parsed = makeParsedFile({ openingBalances: [] })
const mappings: AccountMapping[] = []
const preview = generateImportPreview(parsed, mappings)
expect(preview.trialBalance.totalDebit).toBe(0)
expect(preview.trialBalance.totalCredit).toBe(0)
expect(preview.trialBalance.isBalanced).toBe(true)
})
})
describe('company info passthrough', () => {
it('passes company name', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.companyName).toBe('Test AB')
})
it('passes org number', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.orgNumber).toBe('5566778899')
})
it('handles null company info', () => {
const parsed = makeParsedFile({
header: {
...makeParsedFile().header,
companyName: null,
orgNumber: null,
},
})
const preview = generateImportPreview(parsed, [])
expect(preview.companyName).toBeNull()
expect(preview.orgNumber).toBeNull()
})
})
describe('mapping status', () => {
it('reflects mapper output counts', () => {
const parsed = makeParsedFile()
const mappings = [
makeMapping('1510', '1510'), // mapped
makeMapping('1930', '1930'), // mapped
makeMapping('2440', '', 0), // unmapped
]
const preview = generateImportPreview(parsed, mappings)
expect(preview.mappingStatus.total).toBe(3)
expect(preview.mappingStatus.mapped).toBe(2)
expect(preview.mappingStatus.unmapped).toBe(1)
})
it('reports low confidence mappings', () => {
const mappings = [
makeMapping('1510', '1510', 1.0),
makeMapping('3400', '3001', 0.3), // low confidence
]
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, mappings)
expect(preview.mappingStatus.lowConfidence).toBe(1)
})
})
describe('statistics', () => {
it('passes account count', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.accountCount).toBe(3)
})
it('passes voucher count', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.voucherCount).toBe(1)
})
it('passes transaction line count', () => {
const parsed = makeParsedFile()
const preview = generateImportPreview(parsed, [])
expect(preview.transactionLineCount).toBe(3)
})
})
describe('issues passthrough', () => {
it('passes parse issues to preview', () => {
const parsed = makeParsedFile({
issues: [
{ severity: 'warning', line: 5, message: 'Unknown tag: #FOO', tag: 'FOO' },
{ severity: 'error', line: 10, message: 'Invalid voucher', tag: 'VER' },
],
})
const preview = generateImportPreview(parsed, [])
expect(preview.issues).toHaveLength(2)
expect(preview.issues[0].severity).toBe('warning')
expect(preview.issues[1].severity).toBe('error')
})
})
})
+345
View File
@@ -0,0 +1,345 @@
import { describe, it, expect } from 'vitest'
import { parseSIEFile, validateSIEFile } from '../sie-parser'
// --- SIE content fixtures ---
const MINIMAL_SIE = [
'#FLAGGA 0',
'#SIETYP 4',
'#PROGRAM "TestProg" "1.0"',
'#FORMAT PC8',
'#GEN 20240101',
'#FNAMN "Test AB"',
'#ORGNR 5566778899',
'#VALUTA SEK',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3001 "Försäljning varor 25%"',
].join('\n')
const SIE_WITH_BALANCES = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Balans AB"',
'#ORGNR 1234567890',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 1930 "Företagskonto"',
'#KONTO 2440 "Leverantörsskulder"',
'#IB 0 1510 50000.00',
'#IB 0 1930 100000.00',
'#IB 0 2440 -150000.00',
'#UB 0 1510 75000.00',
'#UB 0 1930 125000.00',
'#UB 0 2440 -200000.00',
].join('\n')
const SIE_WITH_VOUCHERS = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Voucher AB"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 1930 "Företagskonto"',
'#KONTO 3001 "Försäljning"',
'#KONTO 2611 "Utgående moms 25%"',
'#VER A 1 20240115 "Faktura 1001"',
'{',
'#TRANS 1510 {} 12500.00',
'#TRANS 3001 {} -10000.00',
'#TRANS 2611 {} -2500.00',
'}',
'#VER A 2 20240220 "Inbetalning faktura 1001"',
'{',
'#TRANS 1930 {} 12500.00',
'#TRANS 1510 {} -12500.00',
'}',
].join('\n')
const SIE_TYPE_1 = [
'#FLAGGA 0',
'#SIETYP 1',
'#FNAMN "SIE1 AB"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#IB 0 1510 50000.00',
'#UB 0 1510 75000.00',
].join('\n')
const SIE_WITH_SRU = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "SRU AB"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#SRU 1510 7251',
'#KONTO 3001 "Försäljning"',
'#SRU 3001 7410',
].join('\n')
const SIE_UNBALANCED_VOUCHER = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Obalanserad AB"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#KONTO 3001 "Försäljning"',
'#VER A 1 20240115 "Obalanserad verifikation"',
'{',
'#TRANS 1510 {} 10000.00',
'#TRANS 3001 {} -5000.00',
'}',
].join('\n')
const SIE_WITH_OBJECT_LIST = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Objects AB"',
'#RAR 0 20240101 20241231',
'#KONTO 5010 "Lokalhyra"',
'#KONTO 1930 "Företagskonto"',
'#VER A 1 20240115 "Hyra januari"',
'{',
'#TRANS 5010 {1 "Kontor"} 15000.00',
'#TRANS 1930 {} -15000.00',
'}',
].join('\n')
// --- parseSIEFile tests ---
describe('parseSIEFile', () => {
describe('header parsing', () => {
it('parses SIE type', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.sieType).toBe(4)
})
it('parses company name from #FNAMN', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.companyName).toBe('Test AB')
})
it('parses org number from #ORGNR', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.orgNumber).toBe('5566778899')
})
it('parses fiscal year from #RAR', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.fiscalYears).toHaveLength(1)
expect(result.header.fiscalYears[0].yearIndex).toBe(0)
expect(result.header.fiscalYears[0].start).toEqual(new Date(2024, 0, 1))
expect(result.header.fiscalYears[0].end).toEqual(new Date(2024, 11, 31))
})
it('parses currency from #VALUTA', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.currency).toBe('SEK')
})
it('defaults currency to SEK when not specified', () => {
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#RAR 0 20240101 20241231'
const result = parseSIEFile(content)
expect(result.header.currency).toBe('SEK')
})
it('parses program info', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.program).toBe('TestProg')
expect(result.header.programVersion).toBe('1.0')
})
it('parses generated date', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.header.generatedDate).toEqual(new Date(2024, 0, 1))
})
it('parses SIE type 1', () => {
const result = parseSIEFile(SIE_TYPE_1)
expect(result.header.sieType).toBe(1)
})
})
describe('account parsing', () => {
it('parses #KONTO with number and name', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.accounts).toHaveLength(3)
expect(result.accounts[0]).toEqual({ number: '1510', name: 'Kundfordringar' })
expect(result.accounts[1]).toEqual({ number: '1930', name: 'Företagskonto' })
})
it('parses #SRU codes onto accounts', () => {
const result = parseSIEFile(SIE_WITH_SRU)
const account1510 = result.accounts.find((a) => a.number === '1510')
expect(account1510?.sruCode).toBe('7251')
const account3001 = result.accounts.find((a) => a.number === '3001')
expect(account3001?.sruCode).toBe('7410')
})
})
describe('balance parsing', () => {
it('parses opening balances (#IB) with positive amounts', () => {
const result = parseSIEFile(SIE_WITH_BALANCES)
const ib1510 = result.openingBalances.find((b) => b.account === '1510')
expect(ib1510?.amount).toBe(50000)
expect(ib1510?.yearIndex).toBe(0)
})
it('parses opening balances (#IB) with negative amounts', () => {
const result = parseSIEFile(SIE_WITH_BALANCES)
const ib2440 = result.openingBalances.find((b) => b.account === '2440')
expect(ib2440?.amount).toBe(-150000)
})
it('parses closing balances (#UB)', () => {
const result = parseSIEFile(SIE_WITH_BALANCES)
expect(result.closingBalances).toHaveLength(3)
const ub1930 = result.closingBalances.find((b) => b.account === '1930')
expect(ub1930?.amount).toBe(125000)
})
})
describe('voucher parsing', () => {
it('parses #VER with series, number, date, description', () => {
const result = parseSIEFile(SIE_WITH_VOUCHERS)
expect(result.vouchers).toHaveLength(2)
const v1 = result.vouchers[0]
expect(v1.series).toBe('A')
expect(v1.number).toBe(1)
expect(v1.date).toEqual(new Date(2024, 0, 15))
expect(v1.description).toBe('Faktura 1001')
})
it('parses #TRANS lines within a voucher', () => {
const result = parseSIEFile(SIE_WITH_VOUCHERS)
const v1 = result.vouchers[0]
expect(v1.lines).toHaveLength(3)
expect(v1.lines[0]).toMatchObject({ account: '1510', amount: 12500 })
expect(v1.lines[1]).toMatchObject({ account: '3001', amount: -10000 })
expect(v1.lines[2]).toMatchObject({ account: '2611', amount: -2500 })
})
it('handles object lists in braces', () => {
const result = parseSIEFile(SIE_WITH_OBJECT_LIST)
expect(result.vouchers).toHaveLength(1)
const v = result.vouchers[0]
expect(v.lines).toHaveLength(2)
expect(v.lines[0]).toMatchObject({ account: '5010', amount: 15000 })
expect(v.lines[1]).toMatchObject({ account: '1930', amount: -15000 })
})
it('detects unbalanced vouchers as errors', () => {
const result = parseSIEFile(SIE_UNBALANCED_VOUCHER)
expect(result.vouchers).toHaveLength(1)
const errors = result.issues.filter((i) => i.severity === 'error')
expect(errors.length).toBeGreaterThanOrEqual(1)
expect(errors.some((e) => e.message.includes('not balanced'))).toBe(true)
})
})
describe('statistics', () => {
it('calculates account count', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.stats.totalAccounts).toBe(3)
})
it('calculates voucher count', () => {
const result = parseSIEFile(SIE_WITH_VOUCHERS)
expect(result.stats.totalVouchers).toBe(2)
})
it('calculates transaction line count', () => {
const result = parseSIEFile(SIE_WITH_VOUCHERS)
// Voucher 1: 3 lines, Voucher 2: 2 lines
expect(result.stats.totalTransactionLines).toBe(5)
})
it('sets fiscal year start/end from RAR 0', () => {
const result = parseSIEFile(MINIMAL_SIE)
expect(result.stats.fiscalYearStart).toEqual(new Date(2024, 0, 1))
expect(result.stats.fiscalYearEnd).toEqual(new Date(2024, 11, 31))
})
it('returns null fiscal year dates when no RAR', () => {
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"'
const result = parseSIEFile(content)
expect(result.stats.fiscalYearStart).toBeNull()
expect(result.stats.fiscalYearEnd).toBeNull()
})
})
})
// --- validateSIEFile tests ---
describe('validateSIEFile', () => {
it('returns valid for a complete SIE file', () => {
const parsed = parseSIEFile(SIE_WITH_VOUCHERS)
const validation = validateSIEFile(parsed)
expect(validation.valid).toBe(true)
expect(validation.errors).toHaveLength(0)
})
it('adds error for unbalanced vouchers', () => {
const parsed = parseSIEFile(SIE_UNBALANCED_VOUCHER)
const validation = validateSIEFile(parsed)
expect(validation.valid).toBe(false)
expect(validation.errors.some((e) => e.includes('not balanced'))).toBe(true)
})
it('adds warning for undefined account references', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#IB 0 9999 50000.00',
].join('\n')
const parsed = parseSIEFile(content)
const validation = validateSIEFile(parsed)
expect(validation.warnings.some((w) => w.includes('9999') && w.includes('not defined'))).toBe(true)
})
it('adds error for missing #RAR', () => {
const content = '#FLAGGA 0\n#SIETYP 4\n#FNAMN "Test"\n#KONTO 1510 "Kund"'
const parsed = parseSIEFile(content)
const validation = validateSIEFile(parsed)
expect(validation.valid).toBe(false)
expect(validation.errors.some((e) => e.includes('fiscal year') || e.includes('#RAR'))).toBe(true)
})
it('adds warning for unbalanced opening balances', () => {
const content = [
'#FLAGGA 0',
'#SIETYP 4',
'#FNAMN "Test"',
'#RAR 0 20240101 20241231',
'#KONTO 1510 "Kundfordringar"',
'#IB 0 1510 50000.00',
].join('\n')
const parsed = parseSIEFile(content)
const validation = validateSIEFile(parsed)
expect(validation.warnings.some((w) => w.includes('Opening balances not balanced'))).toBe(true)
})
it('passes with balanced opening balances', () => {
const parsed = parseSIEFile(SIE_WITH_BALANCES)
const validation = validateSIEFile(parsed)
// IB: 50000 + 100000 + (-150000) = 0 → balanced
const ibWarning = validation.warnings.find((w) => w.includes('Opening balances not balanced'))
expect(ibWarning).toBeUndefined()
})
})
+21 -181
View File
@@ -1,12 +1,10 @@
/**
* Account Mapping Engine
*
* Intelligently maps accounts from an imported SIE file to
* the user's BAS chart of accounts. Uses multiple strategies:
* 1. Exact account number match
* 2. Account name similarity (Levenshtein distance)
* 3. Account class consistency (5xxx expense, etc.)
* 4. User-defined overrides
* Maps accounts from an imported SIE file to the user's BAS chart of accounts.
* Uses exact account number matching only no fuzzy/heuristic matching.
* This aligns with Swedish industry standard (e.g. Fortnox): exact match,
* create new, or let the user map manually.
*/
import type { BASAccount } from '@/types'
@@ -18,113 +16,8 @@ import type {
} from './types'
/**
* Calculate Levenshtein distance between two strings
* Used for name similarity scoring
*/
function levenshteinDistance(str1: string, str2: string): number {
const s1 = str1.toLowerCase()
const s2 = str2.toLowerCase()
if (s1.length === 0) return s2.length
if (s2.length === 0) return s1.length
const matrix: number[][] = []
// Initialize first column
for (let i = 0; i <= s1.length; i++) {
matrix[i] = [i]
}
// Initialize first row
for (let j = 0; j <= s2.length; j++) {
matrix[0][j] = j
}
// Fill in the rest of the matrix
for (let i = 1; i <= s1.length; i++) {
for (let j = 1; j <= s2.length; j++) {
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // deletion
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j - 1] + cost // substitution
)
}
}
return matrix[s1.length][s2.length]
}
/**
* Calculate similarity score between two strings (0-1)
*/
function nameSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
const distance = levenshteinDistance(name1, name2)
const maxLength = Math.max(name1.length, name2.length)
if (maxLength === 0) return 1
return 1 - distance / maxLength
}
/**
* Normalize Swedish account names for better matching
*/
function normalizeAccountName(name: string): string {
return name
.toLowerCase()
.replace(/[^\wåäö\s]/gi, '') // Remove special chars except Swedish
.replace(/\s+/g, ' ') // Normalize whitespace
.trim()
}
/**
* Get the account class (first digit) from an account number
*/
function getAccountClass(accountNumber: string): number {
const firstDigit = accountNumber.charAt(0)
return parseInt(firstDigit, 10) || 0
}
/**
* Get the account group (first two digits) from an account number
*/
function getAccountGroup(accountNumber: string): string {
return accountNumber.substring(0, 2)
}
/**
* Check if two accounts are in compatible classes
*/
function areClassesCompatible(sourceNumber: string, targetNumber: string): boolean {
const sourceClass = getAccountClass(sourceNumber)
const targetClass = getAccountClass(targetNumber)
// Same class is always compatible
if (sourceClass === targetClass) return true
// Allow some flexibility for related classes
// 1xxx (assets) can map to 1xxx
// 2xxx (equity/liabilities) can map to 2xxx
// 3xxx (revenue) can map to 3xxx
// 4xxx (cost of goods) can map to 4xxx or 5xxx
// 5xxx (external expenses) can map to 5xxx or 6xxx
// 6xxx (other external) can map to 6xxx or 5xxx
// 7xxx (personnel) can map to 7xxx
// 8xxx (financial) can map to 8xxx
if (sourceClass === 4 && targetClass === 5) return true
if (sourceClass === 5 && targetClass === 6) return true
if (sourceClass === 6 && targetClass === 5) return true
return false
}
/**
* Find the best matching BAS account for a source account
* Find the best matching BAS account for a source account.
* Only matches on exact account number no fuzzy matching.
*/
function findBestMatch(
source: SIEAccount,
@@ -139,78 +32,25 @@ function findBestMatch(
}
}
let bestMatch: BASAccount | null = null
let bestScore = 0
let matchType: AccountMatchType = 'class'
// Exact account number match
const exactMatch = basAccounts.find(
(target) => source.number === target.account_number
)
for (const target of basAccounts) {
// Strategy 1: Exact account number match
if (source.number === target.account_number) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: target.account_number,
targetName: target.account_name,
confidence: 1.0,
matchType: 'exact',
isOverride: false,
}
}
// Strategy 2: Name similarity
const nameSim = nameSimilarity(
normalizeAccountName(source.name),
normalizeAccountName(target.account_name)
)
// Strategy 3: Account class compatibility
const classCompatible = areClassesCompatible(source.number, target.account_number)
const sameGroup = getAccountGroup(source.number) === getAccountGroup(target.account_number)
// Calculate combined score
let score = 0
// Name similarity is most important
if (nameSim >= 0.9) {
score = 0.9 + (nameSim - 0.9) // 0.9 - 1.0
} else if (nameSim >= 0.7) {
score = 0.7 + (nameSim - 0.7) * 0.5 // 0.7 - 0.85
} else if (nameSim >= 0.5) {
score = 0.5 + (nameSim - 0.5) * 0.25 // 0.5 - 0.575
}
// Boost for same account group (first 2 digits)
if (sameGroup) {
score += 0.2
} else if (classCompatible) {
score += 0.1
}
// Penalize cross-class mappings
if (!classCompatible) {
score *= 0.5
}
if (score > bestScore) {
bestScore = score
bestMatch = target
matchType = nameSim >= 0.7 ? 'name' : 'class'
if (exactMatch) {
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: exactMatch.account_number,
targetName: exactMatch.account_name,
confidence: 1.0,
matchType: 'exact',
isOverride: false,
}
}
if (!bestMatch || bestScore < 0.3) {
return null
}
return {
sourceAccount: source.number,
sourceName: source.name,
targetAccount: bestMatch.account_number,
targetName: bestMatch.account_name,
confidence: Math.min(bestScore, 1.0),
matchType,
isOverride: false,
}
// No match found
return null
}
/**
+71 -25
View File
@@ -2,20 +2,47 @@
* Swedbank CSV format parser
*
* Format: Comma-delimited, PERIOD decimal separator (exception among Swedish banks!)
* Columns: Clearingnummer, Kontonummer, Datum, Text, Belopp, Saldo, and more (12 columns)
* Columns (real export): Radnr, Clnr, Kontonr, Produkt, Valuta, Bokfdag, Transdag,
* Valutadag, Referens, Text, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - First line is metadata (account info), SKIP it
* - First line is metadata (e.g. "* Transaktionsrapport Period ..."), SKIP it
* - Second line is the actual header
* - Uses period as decimal separator (unlike Nordea/SEB/Handelsbanken)
* - Headers may be abbreviated (Clnr vs Clearingnummer, Bokfdag vs Bokföringsdatum)
* - Referens column contains counterparty/payee name
* - Text column contains transaction type (e.g. "Bg-bet. via internet")
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
/**
* Check if a header value matches any of the given patterns (case-insensitive)
*/
function matchesHeader(header: string, patterns: string[]): boolean {
const h = header.toLowerCase()
return patterns.some((p) => h === p || h.includes(p))
}
/**
* Check if a line looks like a Swedbank header row
*/
function isSwedbankHeader(line: string): boolean {
const lower = line.toLowerCase()
return (
// Full names (legacy or alternative exports)
lower.includes('clearingnummer') || lower.includes('radnummer') ||
// Abbreviated names (current export format)
/\bradnr\b/.test(lower) || /\bclnr\b/.test(lower) ||
// Combination of typical Swedbank columns
(lower.includes('bokfdag') && lower.includes('belopp'))
)
}
export const swedbankFormat: BankFileFormat = {
id: 'swedbank',
name: 'Swedbank',
@@ -25,14 +52,10 @@ export const swedbankFormat: BankFileFormat = {
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n')
// Check first two lines — Swedbank has metadata line, then header
const line1 = lines[0]?.toLowerCase() || ''
const line2 = lines[1]?.toLowerCase() || ''
const line1 = lines[0] || ''
const line2 = lines[1] || ''
return (
(line1.includes('clearingnummer') || line2.includes('clearingnummer') ||
line1.includes('radnummer') || line2.includes('radnummer'))
)
return isSwedbankHeader(line1) || isSwedbankHeader(line2)
},
parse(content: string): BankFileParseResult {
@@ -43,16 +66,30 @@ export const swedbankFormat: BankFileFormat = {
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Determine where the header is
// Line 0 might be metadata, line 1 might be header
let headerLineIdx = 0
const line0Lower = lines[0]?.toLowerCase() || ''
const line1Lower = lines[1]?.toLowerCase() || ''
// Find the header row — may be line 0 or line 1 (if line 0 is metadata)
let headerLineIdx = -1
for (let i = 0; i < Math.min(lines.length, 3); i++) {
if (isSwedbankHeader(lines[i])) {
headerLineIdx = i
break
}
}
if (line1Lower.includes('clearingnummer') || line1Lower.includes('radnummer')) {
headerLineIdx = 1
} else if (line0Lower.includes('clearingnummer') || line0Lower.includes('radnummer')) {
headerLineIdx = 0
if (headerLineIdx === -1) {
issues.push({
row: 1,
message: 'Could not find Swedbank header row',
severity: 'error',
})
return {
format: 'swedbank',
format_name: 'Swedbank',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
const headerLine = lines[headerLineIdx] || ''
@@ -60,16 +97,19 @@ export const swedbankFormat: BankFileFormat = {
h.trim().toLowerCase().replace(/"/g, '')
)
// Find column indices
const dateIdx = headers.findIndex((h) => h === 'datum' || h.includes('bokföringsdatum'))
// Find column indices — support both abbreviated and full header names
const dateIdx = headers.findIndex((h) =>
matchesHeader(h, ['bokfdag', 'bokföringsdatum', 'datum'])
)
const descIdx = headers.findIndex((h) => h === 'text' || h.includes('beskrivning'))
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
const referenceIdx = headers.findIndex((h) => h === 'referens')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (datum, belopp)',
row: headerLineIdx + 1,
message: `Could not identify required columns (datum, belopp). Found headers: ${headers.join(', ')}`,
severity: 'error',
})
return {
@@ -91,7 +131,8 @@ export const swedbankFormat: BankFileFormat = {
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const reference = referenceIdx >= 0 ? fields[referenceIdx]?.trim() : null
const textDesc = descIdx >= 0 ? fields[descIdx]?.trim() : null
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
@@ -116,14 +157,19 @@ export const swedbankFormat: BankFileFormat = {
const balance = balanceStr ? parseFloat(balanceStr.replace(/\s/g, '')) : null
// Build description: use reference (counterparty) as primary, text as secondary
const description = reference && textDesc
? `${reference}${textDesc}`
: reference || textDesc || 'Unknown'
transactions.push({
date,
description: (description || 'Unknown').trim(),
description,
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
counterparty: reference || null,
raw_line: line,
})
}
+1
View File
@@ -357,6 +357,7 @@ async function importVouchers(
description: v.description,
source_type: 'import',
status: 'posted',
committed_at: new Date().toISOString(),
}))
// Insert headers
+100 -29
View File
@@ -5,7 +5,7 @@ import {
View,
StyleSheet,
} from '@react-pdf/renderer'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
// Create styles
const styles = StyleSheet.create({
@@ -82,7 +82,7 @@ const styles = StyleSheet.create({
borderBottomColor: '#eee',
},
colDescription: {
flex: 4,
flex: 3.5,
},
colQty: {
flex: 1,
@@ -96,6 +96,10 @@ const styles = StyleSheet.create({
flex: 1.5,
textAlign: 'right',
},
colVat: {
flex: 1,
textAlign: 'right',
},
colTotal: {
flex: 1.5,
textAlign: 'right',
@@ -255,6 +259,14 @@ function formatOrgNumber(orgNumber: string): string {
return orgNumber
}
function getDocumentTitle(invoice: Invoice): string {
if (invoice.credited_invoice_id) return 'KREDITFAKTURA'
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
if (docType === 'proforma') return 'PROFORMAFAKTURA'
if (docType === 'delivery_note') return 'FÖLJESEDEL'
return 'FAKTURA'
}
interface InvoicePDFProps {
invoice: Invoice
customer: Customer
@@ -266,6 +278,28 @@ interface InvoicePDFProps {
export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber }: InvoicePDFProps) {
const isCreditNote = !!invoice.credited_invoice_id
// Check if items have mixed VAT rates
const hasPerLineVat = items.some((item) => item.vat_rate !== undefined && item.vat_rate !== null)
const uniqueRates = hasPerLineVat
? new Set(items.map((item) => item.vat_rate))
: new Set<number>()
const showVatColumn = hasPerLineVat && uniqueRates.size > 1
// Calculate per-rate VAT breakdown for totals
const vatByRate = new Map<number, { base: number; vat: number }>()
if (hasPerLineVat) {
for (const item of items) {
const rate = item.vat_rate ?? 25
const group = vatByRate.get(rate) || { base: 0, vat: 0 }
group.base += Math.abs(item.line_total)
group.vat += Math.abs(item.vat_amount || 0)
vatByRate.set(rate, group)
}
}
const docType = (invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
return (
<Document>
<Page size="A4" style={styles.page}>
@@ -273,7 +307,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<View style={styles.header}>
<View>
<Text style={[styles.title, isCreditNote ? styles.creditNoteTitle : {}]}>
{isCreditNote ? 'KREDITFAKTURA' : 'FAKTURA'}
{getDocumentTitle(invoice)}
</Text>
<Text style={{ marginTop: 5, color: '#666' }}>{invoice.invoice_number}</Text>
</View>
@@ -356,8 +390,15 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text style={[styles.colDescription, styles.tableHeaderText]}>Beskrivning</Text>
<Text style={[styles.colQty, styles.tableHeaderText]}>Antal</Text>
<Text style={[styles.colUnit, styles.tableHeaderText]}>Enhet</Text>
<Text style={[styles.colPrice, styles.tableHeaderText]}>à-pris</Text>
<Text style={[styles.colTotal, styles.tableHeaderText]}>Summa</Text>
{!isDeliveryNote && (
<Text style={[styles.colPrice, styles.tableHeaderText]}>à-pris</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={[styles.colVat, styles.tableHeaderText]}>Moms</Text>
)}
{!isDeliveryNote && (
<Text style={[styles.colTotal, styles.tableHeaderText]}>Summa</Text>
)}
</View>
{/* Table rows */}
@@ -366,37 +407,67 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
<Text style={styles.colDescription}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity}</Text>
<Text style={styles.colUnit}>{item.unit}</Text>
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
{!isDeliveryNote && (
<Text style={styles.colPrice}>{formatCurrency(item.unit_price, invoice.currency)}</Text>
)}
{!isDeliveryNote && showVatColumn && (
<Text style={styles.colVat}>{item.vat_rate ?? 25}%</Text>
)}
{!isDeliveryNote && (
<Text style={styles.colTotal}>{formatCurrency(item.line_total, invoice.currency)}</Text>
)}
</View>
))}
</View>
</View>
{/* Totals */}
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Delsumma:</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate}%):</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
</View>
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(invoice.total, invoice.currency)}</Text>
</View>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<View style={[styles.totalRow, { marginTop: 8 }]}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>I SEK (kurs {invoice.exchange_rate}):</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK')}</Text>
{/* Totals - hidden for delivery notes */}
{!isDeliveryNote && (
<View style={styles.totalsSection}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Delsumma:</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.subtotal, invoice.currency)}</Text>
</View>
)}
</View>
{vatByRate.size > 1 ? (
Array.from(vatByRate.entries())
.filter(([, group]) => group.vat > 0)
.sort(([a], [b]) => b - a)
.map(([rate, group]) => (
<View key={rate} style={styles.totalRow}>
<Text style={styles.totalLabel}>Moms {rate}%:</Text>
<Text style={styles.totalValue}>{formatCurrency(group.vat, invoice.currency)}</Text>
</View>
))
) : (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Moms ({invoice.vat_rate ?? 25}%):</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency)}</Text>
</View>
)}
<View style={styles.grandTotal}>
<Text style={styles.grandTotalLabel}>{isCreditNote ? 'Att kreditera:' : 'Att betala:'}</Text>
<Text style={styles.grandTotalValue}>{formatCurrency(invoice.total, invoice.currency)}</Text>
</View>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<View style={[styles.totalRow, { marginTop: 8 }]}>
<Text style={[styles.totalLabel, { fontSize: 9 }]}>I SEK (kurs {invoice.exchange_rate}):</Text>
<Text style={[styles.totalValue, { fontSize: 9 }]}>{formatCurrency(invoice.total_sek, 'SEK')}</Text>
</View>
)}
</View>
)}
{/* Payment information - not shown for credit notes */}
{!isCreditNote && (
{/* Proforma notice */}
{isProforma && (
<View style={[styles.reverseChargeBox, { backgroundColor: '#e8f4fd', borderColor: '#90cdf4' }]}>
<Text style={[styles.reverseChargeText, { color: '#2b6cb0' }]}>
Detta är en proformafaktura och utgör ingen betalningsanmodan.
</Text>
</View>
)}
{/* Payment information - not shown for credit notes, proformas, or delivery notes */}
{!isCreditNote && !isProforma && !isDeliveryNote && (
<View style={styles.paymentSection}>
<Text style={styles.paymentTitle}>Betalningsinformation</Text>
{company.bank_name && (
+53
View File
@@ -1,5 +1,58 @@
import type { CustomerType, VatTreatment } from '@/types'
export interface VatRateOption {
rate: number
label: string
treatment: VatTreatment
}
/**
* Get available VAT rates for invoice line items based on customer type.
*
* Swedish/EU-unvalidated customers can choose between 25%, 12%, 6%, and 0% (exempt).
* Reverse charge and export customers are locked to 0%.
*/
export function getAvailableVatRates(
customerType: CustomerType,
vatNumberValidated: boolean = false
): VatRateOption[] {
// EU business with validated VAT → reverse charge, locked to 0%
if (customerType === 'eu_business' && vatNumberValidated) {
return [{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }]
}
// Non-EU → export, locked to 0%
if (customerType === 'non_eu_business') {
return [{ rate: 0, label: '0% (export)', treatment: 'export' }]
}
// Swedish customers (or EU without validated VAT) can choose any rate
return [
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfritt)', treatment: 'exempt' },
]
}
/**
* Map a numeric VAT rate to a VatTreatment.
*/
export function getVatTreatmentForRate(rate: number): VatTreatment {
switch (rate) {
case 25:
return 'standard_25'
case 12:
return 'reduced_12'
case 6:
return 'reduced_6'
case 0:
return 'exempt'
default:
return 'standard_25'
}
}
export interface VatRule {
treatment: VatTreatment
rate: number
@@ -0,0 +1,515 @@
/**
* Tests for the bank reconciliation engine.
*
* Covers: matching algorithm (4 passes), direction compatibility,
* greedy assignment, dry run, manual link/unlink, status calculation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
tryReconcileTransaction,
runReconciliation,
manualLink,
unlinkReconciliation,
} from '../bank-reconciliation'
import type { UnlinkedGLLine } from '../bank-reconciliation'
import { makeTransaction } from '@/tests/helpers'
import { eventBus } from '@/lib/events/bus'
vi.mock('@/lib/supabase/server')
// ============================================================
// Helpers
// ============================================================
function makeGLLine(overrides: Partial<UnlinkedGLLine> = {}): UnlinkedGLLine {
return {
line_id: `line-${Math.random().toString(36).slice(2, 8)}`,
journal_entry_id: `je-${Math.random().toString(36).slice(2, 8)}`,
debit_amount: 0,
credit_amount: 0,
line_description: null,
entry_date: '2024-06-15',
voucher_number: 1,
voucher_series: 'A',
entry_description: 'Test entry',
source_type: 'import',
...overrides,
}
}
// ============================================================
// tryReconcileTransaction — in-memory matching
// ============================================================
describe('tryReconcileTransaction', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
// ------------------------------------------------------------------
// Pass 1: Exact amount + exact date
// ------------------------------------------------------------------
it('matches income transaction with exact amount and date (debit on 1930)', () => {
const tx = makeTransaction({ amount: 5000, date: '2024-06-15', currency: 'SEK' })
const line = makeGLLine({ debit_amount: 5000, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).not.toBeNull()
expect(result!.method).toBe('auto_exact')
expect(result!.confidence).toBe(0.95)
})
it('matches expense transaction with exact amount and date (credit on 1930)', () => {
const tx = makeTransaction({ amount: -1200, date: '2024-06-15', currency: 'SEK' })
const line = makeGLLine({ credit_amount: 1200, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).not.toBeNull()
expect(result!.method).toBe('auto_exact')
expect(result!.confidence).toBe(0.95)
})
// ------------------------------------------------------------------
// Pass 2: Exact amount + reference match
// ------------------------------------------------------------------
it('matches on exact amount with reference match', () => {
const tx = makeTransaction({
amount: 3500,
date: '2024-06-20',
currency: 'SEK',
reference: '12345678',
})
const line = makeGLLine({
debit_amount: 3500,
entry_date: '2024-06-10',
entry_description: 'Payment ref 12345678',
})
const result = tryReconcileTransaction(tx, [line])
expect(result).not.toBeNull()
expect(result!.method).toBe('auto_reference')
expect(result!.confidence).toBe(0.90)
})
// ------------------------------------------------------------------
// Pass 3: Exact amount + date within ±3 days
// ------------------------------------------------------------------
it('matches on exact amount within 3 day date range', () => {
const tx = makeTransaction({ amount: 750, date: '2024-06-17', currency: 'SEK' })
const line = makeGLLine({ debit_amount: 750, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).not.toBeNull()
expect(result!.method).toBe('auto_date_range')
expect(result!.confidence).toBe(0.85)
})
it('does not match when date difference exceeds 3 days', () => {
const tx = makeTransaction({ amount: 750, date: '2024-06-20', currency: 'SEK' })
const line = makeGLLine({ debit_amount: 750, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
// 5 days apart, no reference, different dates — no match
expect(result).toBeNull()
})
// ------------------------------------------------------------------
// Pass 4: Fuzzy amount (±0.01) + exact date
// ------------------------------------------------------------------
it('matches on fuzzy amount with exact date', () => {
const tx = makeTransaction({ amount: -999.99, date: '2024-06-15', currency: 'SEK' })
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).not.toBeNull()
expect(result!.method).toBe('auto_fuzzy')
expect(result!.confidence).toBe(0.75)
})
it('does not match when fuzzy amount exceeds 0.01 tolerance', () => {
const tx = makeTransaction({ amount: -999.98, date: '2024-06-15', currency: 'SEK' })
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).toBeNull()
})
// ------------------------------------------------------------------
// Direction mismatch rejection
// ------------------------------------------------------------------
it('rejects income transaction against credit line (direction mismatch)', () => {
const tx = makeTransaction({ amount: 1000, date: '2024-06-15', currency: 'SEK' })
const line = makeGLLine({ credit_amount: 1000, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).toBeNull()
})
it('rejects expense transaction against debit line (direction mismatch)', () => {
const tx = makeTransaction({ amount: -500, date: '2024-06-15', currency: 'SEK' })
const line = makeGLLine({ debit_amount: 500, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).toBeNull()
})
// ------------------------------------------------------------------
// Non-SEK transactions
// ------------------------------------------------------------------
it('skips non-SEK transactions', () => {
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'EUR' })
const line = makeGLLine({ debit_amount: 100, entry_date: '2024-06-15' })
const result = tryReconcileTransaction(tx, [line])
expect(result).toBeNull()
})
// ------------------------------------------------------------------
// Empty pool
// ------------------------------------------------------------------
it('returns null for empty GL line pool', () => {
const tx = makeTransaction({ amount: 100, date: '2024-06-15', currency: 'SEK' })
const result = tryReconcileTransaction(tx, [])
expect(result).toBeNull()
})
// ------------------------------------------------------------------
// Priority: highest confidence wins
// ------------------------------------------------------------------
it('prefers exact match over date range match', () => {
const tx = makeTransaction({ amount: 1000, date: '2024-06-15', currency: 'SEK' })
const exactLine = makeGLLine({
line_id: 'exact',
debit_amount: 1000,
entry_date: '2024-06-15',
})
const rangeLine = makeGLLine({
line_id: 'range',
debit_amount: 1000,
entry_date: '2024-06-14',
})
const result = tryReconcileTransaction(tx, [rangeLine, exactLine])
expect(result).not.toBeNull()
expect(result!.glLine.line_id).toBe('exact')
expect(result!.method).toBe('auto_exact')
})
// ------------------------------------------------------------------
// No double-matching when using greedy algorithm
// ------------------------------------------------------------------
it('each GL line can only match once in a pool', () => {
const tx1 = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
const tx2 = makeTransaction({ id: 'tx-2', amount: 1000, date: '2024-06-15', currency: 'SEK' })
const line = makeGLLine({ debit_amount: 1000, entry_date: '2024-06-15' })
// First transaction matches
const result1 = tryReconcileTransaction(tx1, [line])
expect(result1).not.toBeNull()
// Second transaction against the same single line also matches individually
const result2 = tryReconcileTransaction(tx2, [line])
expect(result2).not.toBeNull()
// But in the batch reconciliation (greedyMatch), only one would be assigned
// This is tested in runReconciliation tests
})
})
// ============================================================
// runReconciliation — batch matching with DB calls
// ============================================================
describe('runReconciliation', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
function createQueueMockSupabase() {
const resultQueue: { data: unknown; error: unknown }[] = []
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
for (const r of results) {
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
}
}
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
return (..._args: unknown[]) => buildChain()
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
return { supabase, enqueue }
}
it('returns empty matches when no unmatched transactions exist', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// RPC: get_unlinked_1930_lines returns empty
enqueue({ data: [] })
// from('transactions').select — unmatched
enqueue({ data: [] })
const result = await runReconciliation(supabase as never, 'user-1')
expect(result.matches).toEqual([])
expect(result.applied).toBe(0)
})
it('dry run returns matches without applying', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', amount: 1000, date: '2024-06-15', currency: 'SEK' })
const glLine: UnlinkedGLLine = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
debit_amount: 1000,
entry_date: '2024-06-15',
})
// RPC returns GL lines
enqueue({ data: [glLine] })
// from('transactions') returns unmatched transactions
enqueue({ data: [tx] })
const result = await runReconciliation(supabase as never, 'user-1', { dryRun: true })
expect(result.matches).toHaveLength(1)
expect(result.matches[0].method).toBe('auto_exact')
expect(result.applied).toBe(0)
})
it('applies matches when not dry run', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', amount: -500, date: '2024-06-15', currency: 'SEK' })
const glLine: UnlinkedGLLine = makeGLLine({
line_id: 'line-1',
journal_entry_id: 'je-1',
credit_amount: 500,
entry_date: '2024-06-15',
})
// RPC returns GL lines
enqueue({ data: [glLine] })
// from('transactions') returns unmatched transactions
enqueue({ data: [tx] })
// Update transaction with link
enqueue({ data: null, error: null })
const result = await runReconciliation(supabase as never, 'user-1', { dryRun: false })
expect(result.matches).toHaveLength(1)
expect(result.applied).toBe(1)
expect(result.errors).toBe(0)
})
})
// ============================================================
// manualLink
// ============================================================
describe('manualLink', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
function createQueueMockSupabase() {
const resultQueue: { data: unknown; error: unknown }[] = []
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
for (const r of results) {
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
}
}
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
return (..._args: unknown[]) => buildChain()
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
return { supabase, enqueue }
}
it('rejects when transaction not found', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Transaction query returns null
enqueue({ data: null, error: { message: 'Not found' } })
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
expect(result.success).toBe(false)
expect(result.error).toBe('Transaction not found')
})
it('rejects when transaction is already linked', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: 'je-existing' })
// Transaction found but already linked
enqueue({ data: tx })
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
expect(result.success).toBe(false)
expect(result.error).toBe('Transaction is already linked to a journal entry')
})
it('rejects when journal entry has no 1930 line', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
// Transaction found
enqueue({ data: tx })
// Journal entry found
enqueue({ data: { id: 'je-1', user_id: 'user-1', status: 'posted' } })
// No 1930 lines
enqueue({ data: [] })
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
expect(result.success).toBe(false)
expect(result.error).toBe('Journal entry has no line on account 1930')
})
it('succeeds when all validations pass', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null })
// Transaction found
enqueue({ data: tx })
// Journal entry found
enqueue({ data: { id: 'je-1', user_id: 'user-1', status: 'posted' } })
// 1930 line exists
enqueue({ data: [{ debit_amount: 1000, credit_amount: 0 }] })
// No existing link
enqueue({ data: null, error: null })
// Update succeeds
enqueue({ data: null, error: null })
const result = await manualLink(supabase as never, 'user-1', 'tx-1', 'je-1')
expect(result.success).toBe(true)
})
})
// ============================================================
// unlinkReconciliation
// ============================================================
describe('unlinkReconciliation', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
function createQueueMockSupabase() {
const resultQueue: { data: unknown; error: unknown }[] = []
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
for (const r of results) {
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
}
}
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
return (..._args: unknown[]) => buildChain()
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
return { supabase, enqueue }
}
it('rejects when transaction has no reconciliation_method (categorization entry)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Transaction found with journal_entry_id but no reconciliation_method
enqueue({
data: {
id: 'tx-1',
journal_entry_id: 'je-1',
reconciliation_method: null,
},
})
const result = await unlinkReconciliation(supabase as never, 'user-1', 'tx-1')
expect(result.success).toBe(false)
expect(result.error).toContain('Cannot unlink')
})
it('succeeds when reconciliation_method is set', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Transaction found with reconciliation_method
enqueue({
data: {
id: 'tx-1',
journal_entry_id: 'je-1',
reconciliation_method: 'auto_exact',
},
})
// Update succeeds
enqueue({ data: null, error: null })
const result = await unlinkReconciliation(supabase as never, 'user-1', 'tx-1')
expect(result.success).toBe(true)
})
})
+548
View File
@@ -0,0 +1,548 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Transaction, ReconciliationMethod } from '@/types'
import { eventBus } from '@/lib/events/bus'
// ============================================================
// Types
// ============================================================
/** A posted journal entry line on account 1930 not yet linked to any transaction */
export interface UnlinkedGLLine {
line_id: string
journal_entry_id: string
debit_amount: number
credit_amount: number
line_description: string | null
entry_date: string
voucher_number: number
voucher_series: string
entry_description: string
source_type: string
}
export interface ReconciliationMatch {
transaction: Transaction
glLine: UnlinkedGLLine
method: ReconciliationMethod
confidence: number
}
export interface ReconciliationRunResult {
matches: ReconciliationMatch[]
applied: number
errors: number
}
export interface ReconciliationStatus {
bank_transaction_total: number
gl_1930_balance: number
difference: number
is_reconciled: boolean
matched_count: number
unmatched_transaction_count: number
unmatched_gl_line_count: number
}
export interface ReconciliationOptions {
dateFrom?: string
dateTo?: string
dryRun?: boolean
}
// ============================================================
// In-memory matching: single transaction against GL line pool
// ============================================================
/**
* Try to reconcile a single transaction against a pool of unlinked GL lines.
* Returns the best match or null. Purely in-memory, no DB calls.
*
* Only reconciles SEK transactions.
*/
export function tryReconcileTransaction(
transaction: Transaction,
glLines: UnlinkedGLLine[]
): ReconciliationMatch | null {
if (transaction.currency !== 'SEK') return null
if (glLines.length === 0) return null
const txAmount = transaction.amount
const txDate = transaction.date
const txDescription = (transaction.description || '').toLowerCase()
const txReference = (transaction.reference || '').toLowerCase()
let bestMatch: ReconciliationMatch | null = null
for (const line of glLines) {
const lineAmount = getDirectionalAmount(line)
if (!isDirectionCompatible(txAmount, line)) continue
const amountMatches = Math.abs(Math.abs(txAmount) - Math.abs(lineAmount)) < 0.005
const fuzzyAmountMatches = Math.abs(Math.abs(txAmount) - Math.abs(lineAmount)) <= 0.01
const exactDateMatch = txDate === line.entry_date
const dateWithinRange = isDateWithinRange(txDate, line.entry_date, 3)
const referenceMatch = hasReferenceMatch(txDescription, txReference, line)
let method: ReconciliationMethod | null = null
let confidence = 0
// Pass 1: Exact amount + exact date
if (amountMatches && exactDateMatch) {
method = 'auto_exact'
confidence = 0.95
}
// Pass 2: Exact amount + reference match
else if (amountMatches && referenceMatch) {
method = 'auto_reference'
confidence = 0.90
}
// Pass 3: Exact amount + date within ±3 days
else if (amountMatches && dateWithinRange) {
method = 'auto_date_range'
confidence = 0.85
}
// Pass 4: Fuzzy amount (±0.01) + exact date
else if (fuzzyAmountMatches && exactDateMatch) {
method = 'auto_fuzzy'
confidence = 0.75
}
if (method && confidence > (bestMatch?.confidence ?? 0)) {
bestMatch = { transaction, glLine: line, method, confidence }
}
}
return bestMatch
}
// ============================================================
// Batch reconciliation
// ============================================================
/**
* Run auto-reconciliation for all unmatched transactions.
* Fetches data, runs 4-pass matching, optionally applies matches.
*/
export async function runReconciliation(
supabase: SupabaseClient,
userId: string,
options: ReconciliationOptions = {}
): Promise<ReconciliationRunResult> {
const { dateFrom, dateTo, dryRun = false } = options
// Fetch unlinked GL lines via RPC
const glLines = await fetchUnlinkedGLLines(supabase, userId, dateFrom, dateTo)
// Fetch unmatched transactions
let query = supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.is('journal_entry_id', null)
.eq('currency', 'SEK')
if (dateFrom) query = query.gte('date', dateFrom)
if (dateTo) query = query.lte('date', dateTo)
const { data: transactions } = await query
if (!transactions || transactions.length === 0 || glLines.length === 0) {
return { matches: [], applied: 0, errors: 0 }
}
// Run greedy matching, highest confidence first
const matches = greedyMatch(transactions as Transaction[], glLines)
if (dryRun) {
return { matches, applied: 0, errors: 0 }
}
// Apply matches
let applied = 0
let errors = 0
for (const match of matches) {
try {
const { error } = await supabase
.from('transactions')
.update({
journal_entry_id: match.glLine.journal_entry_id,
reconciliation_method: match.method,
is_business: true,
})
.eq('id', match.transaction.id)
.eq('user_id', userId)
if (error) {
errors++
} else {
applied++
try {
eventBus.emit({
type: 'transaction.reconciled',
payload: {
transaction: match.transaction,
journalEntryId: match.glLine.journal_entry_id,
method: match.method,
userId,
},
})
} catch {
// Event emission is non-critical
}
}
} catch {
errors++
}
}
return { matches, applied, errors }
}
// ============================================================
// Reconciliation status
// ============================================================
/**
* Compare bank transaction totals vs GL 1930 balance.
*/
export async function getReconciliationStatus(
supabase: SupabaseClient,
userId: string,
dateFrom?: string,
dateTo?: string
): Promise<ReconciliationStatus> {
// Get all transactions in range
let txQuery = supabase
.from('transactions')
.select('amount, journal_entry_id, reconciliation_method')
.eq('user_id', userId)
.eq('currency', 'SEK')
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
if (dateTo) txQuery = txQuery.lte('date', dateTo)
const { data: transactions } = await txQuery
// Get GL 1930 lines (all, not just unlinked)
let glQuery = supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entries!inner(user_id, entry_date, status)')
.eq('account_number', '1930')
.eq('journal_entries.user_id', userId)
.eq('journal_entries.status', 'posted')
if (dateFrom) glQuery = glQuery.gte('journal_entries.entry_date', dateFrom)
if (dateTo) glQuery = glQuery.lte('journal_entries.entry_date', dateTo)
const { data: glLines } = await glQuery
// Calculate totals
const bankTotal = (transactions || []).reduce(
(sum, tx) => sum + (Number(tx.amount) || 0),
0
)
const glBalance = (glLines || []).reduce(
(sum, line) => sum + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0),
0
)
const matchedCount = (transactions || []).filter(
(tx) => tx.journal_entry_id !== null
).length
const unmatchedTransactionCount = (transactions || []).filter(
(tx) => tx.journal_entry_id === null
).length
// Unlinked GL lines count
const unlinkedLines = await fetchUnlinkedGLLines(supabase, userId, dateFrom, dateTo)
const difference = Math.round((bankTotal - glBalance) * 100) / 100
return {
bank_transaction_total: Math.round(bankTotal * 100) / 100,
gl_1930_balance: Math.round(glBalance * 100) / 100,
difference,
is_reconciled: Math.abs(difference) < 0.01,
matched_count: matchedCount,
unmatched_transaction_count: unmatchedTransactionCount,
unmatched_gl_line_count: unlinkedLines.length,
}
}
// ============================================================
// Manual link/unlink
// ============================================================
/**
* Manually link a transaction to an existing journal entry.
* Validates that the journal entry has a 1930 line and amounts are directionally compatible.
*/
export async function manualLink(
supabase: SupabaseClient,
userId: string,
transactionId: string,
journalEntryId: string
): Promise<{ success: boolean; error?: string }> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('id', transactionId)
.eq('user_id', userId)
.single()
if (txError || !tx) {
return { success: false, error: 'Transaction not found' }
}
if (tx.journal_entry_id) {
return { success: false, error: 'Transaction is already linked to a journal entry' }
}
// Fetch journal entry + verify it has a 1930 line
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, user_id, status')
.eq('id', journalEntryId)
.eq('user_id', userId)
.single()
if (entryError || !entry) {
return { success: false, error: 'Journal entry not found' }
}
if (entry.status !== 'posted') {
return { success: false, error: 'Journal entry is not posted' }
}
// Check for 1930 line
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount')
.eq('journal_entry_id', journalEntryId)
.eq('account_number', '1930')
if (!lines || lines.length === 0) {
return { success: false, error: 'Journal entry has no line on account 1930' }
}
// Check that no other transaction is already linked to this entry
const { data: existingLink } = await supabase
.from('transactions')
.select('id')
.eq('journal_entry_id', journalEntryId)
.eq('user_id', userId)
.single()
if (existingLink) {
return { success: false, error: 'Another transaction is already linked to this journal entry' }
}
// Apply link
const { error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: journalEntryId,
reconciliation_method: 'manual' as ReconciliationMethod,
is_business: true,
})
.eq('id', transactionId)
.eq('user_id', userId)
if (updateError) {
return { success: false, error: 'Failed to link transaction' }
}
try {
eventBus.emit({
type: 'transaction.reconciled',
payload: {
transaction: tx as Transaction,
journalEntryId,
method: 'manual' as ReconciliationMethod,
userId,
},
})
} catch {
// Non-critical
}
return { success: true }
}
/**
* Remove a reconciliation link.
* Only allowed when reconciliation_method IS NOT NULL (prevents unlinking categorization-created entries).
*/
export async function unlinkReconciliation(
supabase: SupabaseClient,
userId: string,
transactionId: string
): Promise<{ success: boolean; error?: string }> {
// Fetch transaction
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('id, journal_entry_id, reconciliation_method')
.eq('id', transactionId)
.eq('user_id', userId)
.single()
if (txError || !tx) {
return { success: false, error: 'Transaction not found' }
}
if (!tx.journal_entry_id) {
return { success: false, error: 'Transaction is not linked to any journal entry' }
}
if (!tx.reconciliation_method) {
return { success: false, error: 'Cannot unlink a categorization-created entry. Use storno to reverse it instead.' }
}
const { error: updateError } = await supabase
.from('transactions')
.update({
journal_entry_id: null,
reconciliation_method: null,
is_business: null,
})
.eq('id', transactionId)
.eq('user_id', userId)
if (updateError) {
return { success: false, error: 'Failed to unlink transaction' }
}
return { success: true }
}
// ============================================================
// Helpers
// ============================================================
/** Fetch unlinked 1930 GL lines via the RPC function */
export async function fetchUnlinkedGLLines(
supabase: SupabaseClient,
userId: string,
dateFrom?: string,
dateTo?: string
): Promise<UnlinkedGLLine[]> {
const { data, error } = await supabase.rpc('get_unlinked_1930_lines', {
p_user_id: userId,
p_date_from: dateFrom || null,
p_date_to: dateTo || null,
})
if (error || !data) return []
return data as UnlinkedGLLine[]
}
/** Get the net amount from a GL line (positive for debit, negative for credit) */
function getDirectionalAmount(line: UnlinkedGLLine): number {
if (line.debit_amount > 0) return line.debit_amount
if (line.credit_amount > 0) return -line.credit_amount
return 0
}
/**
* Check direction compatibility:
* - Income (tx.amount > 0) matches debit on 1930 (money coming in to bank)
* - Expense (tx.amount < 0) matches credit on 1930 (money going out of bank)
*/
function isDirectionCompatible(txAmount: number, line: UnlinkedGLLine): boolean {
if (txAmount > 0 && line.debit_amount > 0) return true
if (txAmount < 0 && line.credit_amount > 0) return true
return false
}
/** Check if two dates are within ±dayRange of each other */
function isDateWithinRange(date1: string, date2: string, dayRange: number): boolean {
const d1 = new Date(date1)
const d2 = new Date(date2)
const diffMs = Math.abs(d1.getTime() - d2.getTime())
const diffDays = diffMs / (1000 * 60 * 60 * 24)
return diffDays <= dayRange
}
/** Check if transaction description/reference matches the GL line description */
function hasReferenceMatch(
txDescription: string,
txReference: string,
line: UnlinkedGLLine
): boolean {
const lineDesc = (line.line_description || '').toLowerCase()
const entryDesc = (line.entry_description || '').toLowerCase()
if (!txReference && !txDescription) return false
// Check OCR/reference number match
if (txReference && txReference.length >= 4) {
if (lineDesc.includes(txReference) || entryDesc.includes(txReference)) return true
}
// Check description overlap (at least 8 chars matching substring)
if (txDescription && txDescription.length >= 8) {
if (lineDesc.includes(txDescription) || entryDesc.includes(txDescription)) return true
if (txDescription.includes(lineDesc) && lineDesc.length >= 8) return true
if (txDescription.includes(entryDesc) && entryDesc.length >= 8) return true
}
return false
}
/**
* Greedy matching: run 4-pass matching, each pass at a specific confidence level.
* Track used GL lines and transactions to prevent double-matching.
*/
function greedyMatch(
transactions: Transaction[],
glLines: UnlinkedGLLine[]
): ReconciliationMatch[] {
const usedTransactions = new Set<string>()
const usedGLLines = new Set<string>()
const allMatches: ReconciliationMatch[] = []
// Collect all candidate matches with confidence
const candidates: ReconciliationMatch[] = []
for (const tx of transactions) {
if (tx.currency !== 'SEK') continue
for (const line of glLines) {
const match = tryReconcileTransaction(tx, [line])
if (match) {
candidates.push(match)
}
}
}
// Sort by confidence descending, then by date proximity
candidates.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence
// Prefer closer dates
const dateDistA = Math.abs(
new Date(a.transaction.date).getTime() - new Date(a.glLine.entry_date).getTime()
)
const dateDistB = Math.abs(
new Date(b.transaction.date).getTime() - new Date(b.glLine.entry_date).getTime()
)
return dateDistA - dateDistB
})
// Greedily assign matches
for (const candidate of candidates) {
const txId = candidate.transaction.id
const lineId = candidate.glLine.line_id
if (usedTransactions.has(txId) || usedGLLines.has(lineId)) continue
usedTransactions.add(txId)
usedGLLines.add(lineId)
allMatches.push(candidate)
}
return allMatches
}
+7 -7
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'order']) {
for (const m of ['select', 'eq', 'in', 'lt', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -51,7 +51,7 @@ describe('generateGeneralLedger', () => {
it('returns empty report when no entries in period', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries (empty)
{ data: [], error: null },
]
@@ -64,7 +64,7 @@ describe('generateGeneralLedger', () => {
it('groups lines by account with correct totals and running balance', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries for this period
{
data: [
@@ -122,7 +122,7 @@ describe('generateGeneralLedger', () => {
it('computes opening balance from prior period entries', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2025-01-01', end_date: '2025-12-31' }, error: null },
{ data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null },
// 1: journal_entries for this period
{
data: [
@@ -168,7 +168,7 @@ describe('generateGeneralLedger', () => {
it('filters accounts by account_from and account_to', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries
{
data: [
@@ -200,7 +200,7 @@ describe('generateGeneralLedger', () => {
it('sorts lines within account by date then voucher number', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: entries out of order
{
data: [
@@ -236,7 +236,7 @@ describe('generateGeneralLedger', () => {
it('uses Math.round for monetary precision', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' },
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -49,7 +49,7 @@ describe('generateJournalRegister', () => {
it('returns empty report when no entries in period', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
]
@@ -62,7 +62,7 @@ describe('generateJournalRegister', () => {
it('produces entries in registration order with correct totals', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries (already ordered by series/number)
{
data: [
@@ -117,7 +117,7 @@ describe('generateJournalRegister', () => {
it('includes reversed entries with correct status', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' },
@@ -146,7 +146,7 @@ describe('generateJournalRegister', () => {
it('resolves account names from chart_of_accounts', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' },
@@ -180,7 +180,7 @@ describe('generateJournalRegister', () => {
it('defaults voucher_series to A when null', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' },
@@ -24,7 +24,7 @@ describe('generateMonthlyBreakdown', () => {
it('returns empty months when no journal entries exist', async () => {
// First call: fiscal period
mockResult({
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
error: null,
})
@@ -45,7 +45,7 @@ describe('generateMonthlyBreakdown', () => {
eq: () => ({
single: () =>
Promise.resolve({
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
error: null,
}),
}),
@@ -88,7 +88,7 @@ describe('generateMonthlyBreakdown', () => {
eq: () => ({
single: () =>
Promise.resolve({
data: { start_date: '2024-01-01', end_date: '2024-03-31' },
data: { period_start: '2024-01-01', period_end: '2024-03-31' },
error: null,
}),
}),
@@ -167,7 +167,7 @@ describe('generateMonthlyBreakdown', () => {
eq: () => ({
single: () =>
Promise.resolve({
data: { start_date: '2024-01-01', end_date: '2024-01-31' },
data: { period_start: '2024-01-01', period_end: '2024-01-31' },
error: null,
}),
}),
+1 -1
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+1 -1
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
for (const m of ['select', 'eq', 'in', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+131 -193
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not']) {
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -32,7 +32,6 @@ import {
formatPeriodLabel,
getVatDeclarationSummary,
calculateVatDeclaration,
calculateVatDeclarationFromTaxCodes,
} from '../vat-declaration'
import type { VatDeclaration } from '@/types'
@@ -112,8 +111,8 @@ describe('getVatDeclarationSummary', () => {
transactionCount: 10,
breakdown: {
invoices: { ruta05: 2500, ruta06: 0, ruta07: 0, ruta10: 10000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
transactions: { ruta48: 800 },
receipts: { ruta48: 200 },
transactions: { ruta48: 1000 },
receipts: { ruta48: 0 },
},
}
@@ -155,17 +154,17 @@ describe('getVatDeclarationSummary', () => {
})
// ============================================================
// Async tests — require Supabase mocks
// Ledger-based VAT declaration tests
//
// Mock queue order per call:
// [0] fetchAllRows: journal_entry_lines (VAT-relevant accounts)
// [1] entry counts: journal_entries source_type
// ============================================================
describe('calculateVatDeclaration', () => {
it('returns all zeros when no data exists', async () => {
it('returns all zeros when no ledger lines exist', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
]
@@ -180,23 +179,20 @@ describe('calculateVatDeclaration', () => {
expect(result.transactionCount).toBe(0)
})
it('maps invoice VAT to correct rutor by moms_ruta', async () => {
it('sums output VAT from 2611/2621/2631 credit balances', async () => {
results = [
// 0: invoices — various moms_ruta values
{
data: [
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 5000, vat_amount: 600, moms_ruta: '06', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 3000, vat_amount: 180, moms_ruta: '07', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 8000, vat_amount: 0, moms_ruta: '39', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 12000, vat_amount: 0, moms_ruta: '40', subtotal_sek: null, vat_amount_sek: null },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
],
error: null,
},
// 1: transactions (none)
{ data: [], error: null },
// 2: receipts (none)
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
@@ -207,224 +203,166 @@ describe('calculateVatDeclaration', () => {
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
expect(result.invoiceCount).toBe(2)
})
it('sums input VAT from 2641 debit balance', async () => {
results = [
{
data: [
{ account_number: '2641', debit_amount: 250, credit_amount: 0 },
{ account_number: '2641', debit_amount: 120, credit_amount: 0 },
],
error: null,
},
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(370)
expect(result.transactionCount).toBe(2)
})
it('includes calculated input VAT (2645) from EU reverse charge in ruta48', async () => {
results = [
{
data: [
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
{ account_number: '2641', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Both 2641 and 2645 debit balances sum into ruta48
expect(result.rutor.ruta48).toBe(700)
})
it('maps EU/export revenue to ruta39/ruta40', async () => {
results = [
{
data: [
{ account_number: '3308', debit_amount: 0, credit_amount: 8000 },
{ account_number: '3305', debit_amount: 0, credit_amount: 12000 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta39).toBe(8000)
expect(result.rutor.ruta40).toBe(12000)
})
it('prefers subtotal_sek/vat_amount_sek for foreign currency invoices', async () => {
it('handles credit notes as net reduction on revenue/VAT accounts', async () => {
results = [
// 0: invoices — foreign currency with SEK conversion
{
data: [
{ subtotal: 1000, vat_amount: 250, moms_ruta: '05', subtotal_sek: 11000, vat_amount_sek: 2750 },
// Invoice: C2611 2500, C3001 10000
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
// Credit note reversal: D2611 625, D3001 2500
{ account_number: '2611', debit_amount: 625, credit_amount: 0 },
{ account_number: '3001', debit_amount: 2500, credit_amount: 0 },
],
error: null,
},
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Should use _sek values
expect(result.rutor.ruta05).toBe(2750)
expect(result.rutor.ruta10).toBe(11000)
})
it('defaults to ruta05 when moms_ruta is null but VAT > 0', async () => {
results = [
// 0: invoices — null moms_ruta with VAT
{
data: [
{ subtotal: 4000, vat_amount: 1000, moms_ruta: null, subtotal_sek: null, vat_amount_sek: null },
],
error: null,
},
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(1000)
expect(result.rutor.ruta10).toBe(4000)
})
it('calculates input VAT from transaction categories', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions — business expenses with categories
{
data: [
// 25% category: expense_software, amount -1250 → VAT = 1250 * 0.25/1.25 = 250
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
// 12% category: expense_travel, amount -1120 → VAT = 1120 * 0.12/1.12 = 120
{ amount: -1120, amount_sek: null, is_business: true, category: 'expense_travel' },
],
error: null,
},
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// 250 + 120 = 370
expect(result.rutor.ruta48).toBe(370)
})
it('sums VAT from confirmed receipts', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions
{ data: [], error: null },
// 2: receipts — confirmed with vat_amount
{
data: [
{ status: 'confirmed', vat_amount: 59.8 },
{ status: 'confirmed', vat_amount: 125 },
],
error: null,
},
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(184.8)
// Net: 2500 - 625 = 1875 output VAT, 10000 - 2500 = 7500 revenue
expect(result.rutor.ruta05).toBe(1875)
expect(result.rutor.ruta10).toBe(7500)
expect(result.invoiceCount).toBe(2)
})
it('calculates ruta49 as output minus input VAT', async () => {
results = [
// 0: invoices — 25% VAT
{
data: [
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
],
error: null,
},
// 1: transactions — 25% expense
{
data: [
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
],
error: null,
},
// 2: receipts
{
data: [
{ status: 'confirmed', vat_amount: 100 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2641', debit_amount: 350, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Output: 2500, Input: 250 + 100 = 350
expect(result.rutor.ruta49).toBe(2150)
})
})
describe('calculateVatDeclarationFromTaxCodes', () => {
it('maps journal lines to boxes via tax codes', async () => {
results = [
// 0: tax_codes
{
data: [
{
code: 'MP1',
user_id: null,
moms_basis_boxes: ['10'],
moms_tax_boxes: ['05'],
moms_input_boxes: [],
},
],
error: null,
},
// 1: journal_entry_lines with tax_code
{
data: [
{
tax_code: 'MP1',
debit_amount: 0,
credit_amount: 2500,
journal_entry_id: 'e1',
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
},
],
error: null,
},
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta48).toBe(350)
expect(result.rutor.ruta49).toBe(2150) // 2500 - 350
})
it('user tax codes override system codes', async () => {
it('detects refund when input VAT exceeds output VAT', async () => {
results = [
// 0: tax_codes — system and user with same code
{
data: [
{
code: 'MP1',
user_id: null,
moms_basis_boxes: ['10'],
moms_tax_boxes: ['05'],
moms_input_boxes: [],
},
{
code: 'MP1',
user_id: 'user-1',
moms_basis_boxes: ['11'],
moms_tax_boxes: ['06'],
moms_input_boxes: [],
},
],
error: null,
},
// 1: journal_entry_lines
{
data: [
{
tax_code: 'MP1',
debit_amount: 0,
credit_amount: 600,
journal_entry_id: 'e1',
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
},
{ account_number: '2611', debit_amount: 0, credit_amount: 500 },
{ account_number: '2641', debit_amount: 3000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// User override maps to ruta06/ruta11 instead of ruta05/ruta10
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
})
it('returns all zeros when no lines have tax codes', async () => {
it('accepts accountingMethod parameter for backward compatibility', async () => {
results = [
// 0: tax_codes
{ data: [], error: null },
// 1: journal_entry_lines — empty
{ data: [], error: null },
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta48).toBe(0)
// Should not throw — parameter accepted but not used
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1, 'cash')
expect(result.rutor.ruta49).toBe(0)
})
it('handles all three VAT rates in a single period', async () => {
results = [
{
data: [
// 25% rate: 10,000 revenue, 2,500 VAT
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
// 12% rate: 5,000 revenue, 600 VAT
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
// 6% rate: 3,000 revenue, 180 VAT
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
// Input VAT from purchases
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'quarterly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta07).toBe(180)
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
expect(result.rutor.ruta48).toBe(1000)
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
expect(result.rutor.ruta49).toBe(2280)
})
})
+14 -10
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface GeneralLedgerLine {
date: string
@@ -41,7 +42,7 @@ export async function generateGeneralLedger(
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.single()
@@ -59,7 +60,7 @@ export async function generateGeneralLedger(
.in('status', ['posted', 'reversed'])
if (!entries || entries.length === 0) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
}
const entryIds = entries.map((e) => e.id)
@@ -72,17 +73,20 @@ export async function generateGeneralLedger(
.in('journal_entry_id', entryIds)
if (!lines) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
}
// Fetch account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
@@ -92,7 +96,7 @@ export async function generateGeneralLedger(
.select('id')
.eq('user_id', userId)
.in('status', ['posted', 'reversed'])
.lt('entry_date', period.start_date)
.lt('entry_date', period.period_start)
const openingBalances = new Map<string, number>()
@@ -178,6 +182,6 @@ export async function generateGeneralLedger(
return {
accounts: result,
period: { start: period.start_date, end: period.end_date },
period: { start: period.period_start, end: period.period_end },
}
}
+12 -8
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface JournalRegisterLine {
account_number: string
@@ -40,7 +41,7 @@ export async function generateJournalRegister(
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.single()
@@ -60,7 +61,7 @@ export async function generateJournalRegister(
.order('voucher_number', { ascending: true })
if (!entries || entries.length === 0) {
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.start_date, end: period.end_date } }
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.period_start, end: period.period_end } }
}
const entryIds = entries.map((e) => e.id)
@@ -72,13 +73,16 @@ export async function generateJournalRegister(
.in('journal_entry_id', entryIds)
// Fetch account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
@@ -126,6 +130,6 @@ export async function generateJournalRegister(
total_entries: result.length,
total_debit: Math.round(grandTotalDebit * 100) / 100,
total_credit: Math.round(grandTotalCredit * 100) / 100,
period: { start: period.start_date, end: period.end_date },
period: { start: period.period_start, end: period.period_end },
}
}
+3 -3
View File
@@ -32,7 +32,7 @@ export async function generateMonthlyBreakdown(
// Get the fiscal period date range
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.select('period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
@@ -67,8 +67,8 @@ export async function generateMonthlyBreakdown(
const monthMap = new Map<number, { income: number; expenses: number }>()
// Initialize all months in the period range
const startDate = new Date(period.start_date)
const endDate = new Date(period.end_date)
const startDate = new Date(period.period_start)
const endDate = new Date(period.period_end)
const startMonth = startDate.getMonth()
const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12
+10 -6
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
/**
@@ -29,12 +30,15 @@ export async function generateSIEExport(
}
// Fetch all accounts
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
const accounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
// Fetch all posted journal entries with lines
const { data: entries } = await supabase
+9 -5
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { TrialBalanceRow } from '@/types'
/**
@@ -83,13 +84,16 @@ async function generateTrialBalanceManual(
}
// Get account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('user_id', userId)
const accounts = await fetchAllRows<{ account_number: string; account_name: string; account_class: number }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('user_id', userId)
.range(from, to)
)
const accountMap = new Map<string, { name: string; class: number }>()
for (const acc of accounts || []) {
for (const acc of accounts) {
accountMap.set(acc.account_number, {
name: acc.account_name,
class: acc.account_class,
+120 -400
View File
@@ -1,25 +1,46 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
VatDeclaration,
VatDeclarationRutor,
VatPeriodType,
Invoice,
Transaction,
Receipt,
TaxCode,
AccountingMethod,
} from '@/types'
/**
* Calculate VAT declaration (Momsdeklaration) for a given period
* Calculate VAT declaration (Momsdeklaration) for a given period.
*
* Aggregates data from:
* - Invoices: Utgående moms (output VAT) based on moms_ruta
* - Transactions: Ingående moms (input VAT) from categorized expenses
* - Receipts: Ingående moms from confirmed receipts
* Reads directly from the general ledger sums posted journal entry lines
* on 26xx (VAT) and 3xxx (revenue) accounts for the period. This makes the
* momsdeklaration a pure projection from the double-entry bookkeeping ledger.
*
* Returns VAT rutor according to Swedish tax authority format.
* The accounting method (accrual vs cash) is already reflected in when
* journal entries were created by the entry generators, so no separate
* filtering logic is needed here.
*/
/**
* Account-to-ruta mapping for the Swedish momsdeklaration.
*
* Output VAT (26xx): net credit balance feeds output VAT boxes.
* Input VAT (2641/2645): net debit balance feeds ruta 48.
* Revenue (3xxx): net credit balance feeds underlag (basis) boxes.
*/
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
'2611': { box: 'ruta05', side: 'credit' },
'2621': { box: 'ruta06', side: 'credit' },
'2631': { box: 'ruta07', side: 'credit' },
'2641': { box: 'ruta48', side: 'debit' },
'2645': { box: 'ruta48', side: 'debit' },
'3001': { box: 'ruta10', side: 'credit' },
'3002': { box: 'ruta11', side: 'credit' },
'3003': { box: 'ruta12', side: 'credit' },
'3305': { box: 'ruta40', side: 'credit' },
'3308': { box: 'ruta39', side: 'credit' },
}
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
/**
* Calculate period start and end dates
*/
@@ -79,298 +100,119 @@ function round(value: number): number {
}
/**
* Main function to calculate VAT declaration
* Calculate VAT declaration from the general ledger.
*
* Sums posted journal entry lines on 26xx and 3xxx accounts:
* - 2611/2621/2631 credit balance -> ruta 05/06/07 (output VAT)
* - 2641/2645 debit balance -> ruta 48 (input VAT)
* - 3001/3002/3003 credit balance -> ruta 10/11/12 (revenue basis)
* - 3308/3305 credit balance -> ruta 39/40 (EU/export)
* - ruta 49 = (05 + 06 + 07) - 48
*
* The accounting method parameter is accepted for backward compatibility
* but not used the method is already baked into journal entry timing.
*/
export async function calculateVatDeclaration(
userId: string,
periodType: VatPeriodType,
year: number,
period: number
period: number,
_accountingMethod: AccountingMethod = 'accrual'
): Promise<VatDeclaration> {
const supabase = await createClient()
const { start, end } = calculatePeriodDates(periodType, year, period)
// Fetch invoices for the period
const { data: invoices, error: invoicesError } = await supabase
.from('invoices')
.select('*')
.eq('user_id', userId)
.gte('invoice_date', start)
.lte('invoice_date', end)
.in('status', ['sent', 'paid', 'overdue'])
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
const lines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
account_number,
debit_amount,
credit_amount,
journal_entries!inner (user_id, entry_date, status)
`)
.in('account_number', VAT_ACCOUNTS)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.range(from, to)
)
if (invoicesError) {
console.error('Error fetching invoices:', invoicesError)
// Aggregate debit/credit totals per account
const totals = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
t.debit += Number(line.debit_amount) || 0
t.credit += Number(line.credit_amount) || 0
totals.set(line.account_number, t)
}
// Fetch transactions with business expenses for the period
const { data: transactions, error: transactionsError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.gte('date', start)
.lte('date', end)
.eq('is_business', true)
.lt('amount', 0) // Expenses are negative
if (transactionsError) {
console.error('Error fetching transactions:', transactionsError)
}
// Fetch confirmed receipts for the period
const { data: receipts, error: receiptsError } = await supabase
.from('receipts')
.select('*')
.eq('user_id', userId)
.gte('receipt_date', start)
.lte('receipt_date', end)
.eq('status', 'confirmed')
if (receiptsError) {
console.error('Error fetching receipts:', receiptsError)
}
// Calculate invoice-based VAT (utgående moms)
const invoiceVat = calculateInvoiceVat(invoices as Invoice[] || [])
// Calculate input VAT from transactions
const transactionVat = calculateTransactionInputVat(transactions as Transaction[] || [])
// Calculate input VAT from receipts
const receiptVat = calculateReceiptInputVat(receipts as Receipt[] || [])
// Total ingående moms (input VAT to deduct)
const totalInputVat = round(transactionVat + receiptVat)
// Total utgående moms (output VAT to pay)
const totalOutputVat = round(invoiceVat.ruta05 + invoiceVat.ruta06 + invoiceVat.ruta07)
// Moms att betala/återfå (VAT to pay or receive back)
const vatToPay = round(totalOutputVat - totalInputVat)
// Map account balances to momsdeklaration boxes
const rutor: VatDeclarationRutor = {
ruta05: invoiceVat.ruta05,
ruta06: invoiceVat.ruta06,
ruta07: invoiceVat.ruta07,
ruta10: invoiceVat.ruta10,
ruta11: invoiceVat.ruta11,
ruta12: invoiceVat.ruta12,
ruta39: invoiceVat.ruta39,
ruta40: invoiceVat.ruta40,
ruta48: totalInputVat,
ruta49: vatToPay,
ruta05: 0, ruta06: 0, ruta07: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta39: 0, ruta40: 0,
ruta48: 0, ruta49: 0,
}
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
const t = totals.get(account)
if (!t) continue
const balance = mapping.side === 'credit'
? t.credit - t.debit
: t.debit - t.credit
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
rutor.ruta49 = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07 - rutor.ruta48)
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
.from('journal_entries')
.select('source_type')
.eq('user_id', userId)
.eq('status', 'posted')
.gte('entry_date', start)
.lte('entry_date', end)
const invoiceSources = new Set([
'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note',
])
let invoiceCount = 0
let transactionCount = 0
for (const e of entryCounts || []) {
if (invoiceSources.has(e.source_type)) invoiceCount++
else if (e.source_type === 'bank_transaction') transactionCount++
}
return {
period: {
type: periodType,
year,
period,
start,
end,
},
period: { type: periodType, year, period, start, end },
rutor,
invoiceCount: (invoices || []).length,
transactionCount: (transactions || []).length,
invoiceCount,
transactionCount,
breakdown: {
invoices: {
ruta05: invoiceVat.ruta05,
ruta06: invoiceVat.ruta06,
ruta07: invoiceVat.ruta07,
ruta10: invoiceVat.ruta10,
ruta11: invoiceVat.ruta11,
ruta12: invoiceVat.ruta12,
ruta39: invoiceVat.ruta39,
ruta40: invoiceVat.ruta40,
},
transactions: {
ruta48: round(transactionVat),
},
receipts: {
ruta48: round(receiptVat),
ruta05: rutor.ruta05,
ruta06: rutor.ruta06,
ruta07: rutor.ruta07,
ruta10: rutor.ruta10,
ruta11: rutor.ruta11,
ruta12: rutor.ruta12,
ruta39: rutor.ruta39,
ruta40: rutor.ruta40,
},
transactions: { ruta48: rutor.ruta48 },
receipts: { ruta48: 0 },
},
}
}
/**
* Calculate VAT from invoices
*/
function calculateInvoiceVat(invoices: Invoice[]): {
ruta05: number
ruta06: number
ruta07: number
ruta10: number
ruta11: number
ruta12: number
ruta39: number
ruta40: number
} {
let ruta05 = 0 // Utgående moms 25%
let ruta06 = 0 // Utgående moms 12%
let ruta07 = 0 // Utgående moms 6%
let ruta10 = 0 // Underlag 25%
let ruta11 = 0 // Underlag 12%
let ruta12 = 0 // Underlag 6%
let ruta39 = 0 // EU tjänster
let ruta40 = 0 // Export
for (const invoice of invoices) {
// Use subtotal_sek if available (for foreign currency invoices), otherwise subtotal
const subtotal = invoice.subtotal_sek ?? invoice.subtotal
const vatAmount = invoice.vat_amount_sek ?? invoice.vat_amount
switch (invoice.moms_ruta) {
case '05':
// Standard 25% VAT
ruta05 += vatAmount
ruta10 += subtotal
break
case '06':
// Reduced 12% VAT
ruta06 += vatAmount
ruta11 += subtotal
break
case '07':
// Reduced 6% VAT
ruta07 += vatAmount
ruta12 += subtotal
break
case '39':
// EU reverse charge - no VAT charged, but report the value
ruta39 += subtotal
break
case '40':
// Export outside EU - no VAT charged, but report the value
ruta40 += subtotal
break
default:
// Default to 25% if moms_ruta is not set but there's VAT
if (vatAmount > 0) {
ruta05 += vatAmount
ruta10 += subtotal
}
}
}
return {
ruta05: round(ruta05),
ruta06: round(ruta06),
ruta07: round(ruta07),
ruta10: round(ruta10),
ruta11: round(ruta11),
ruta12: round(ruta12),
ruta39: round(ruta39),
ruta40: round(ruta40),
}
}
/**
* Calculate input VAT from business expense transactions
*
* For Swedish business expenses with 25% VAT, we can deduct the VAT.
* This is a simplified calculation - in reality, the journal entry
* would have the exact VAT amounts.
*/
function calculateTransactionInputVat(transactions: Transaction[]): number {
let inputVat = 0
for (const transaction of transactions) {
// Only process business expenses (amount is negative)
if (!transaction.is_business || transaction.amount >= 0) continue
// Use amount_sek if available, otherwise amount
const expenseAmount = Math.abs(transaction.amount_sek ?? transaction.amount)
// Estimate VAT based on category
// Most Swedish business expenses have 25% VAT
// Some categories might have reduced rates or no VAT
const vatRate = getVatRateForCategory(transaction.category)
if (vatRate > 0) {
// Extract VAT from total (VAT-inclusive) amount
// VAT = total * rate / (1 + rate)
const vat = (expenseAmount * vatRate) / (1 + vatRate)
inputVat += vat
}
}
return inputVat
}
/**
* Get VAT rate for expense category
*/
function getVatRateForCategory(category: string | null): number {
// Categories that typically have 25% VAT
const standard25Categories = [
'expense_equipment',
'expense_software',
'expense_office',
'expense_marketing',
'expense_professional_services',
'expense_education',
'expense_other',
]
// Categories with 12% VAT (e.g., food/restaurants, but only 50% deductible for representation)
const reduced12Categories = [
'expense_travel', // Hotels, some transport
]
// Categories with 6% VAT
const reduced6Categories: string[] = [
// Books, newspapers, etc.
]
// No VAT deduction
const noVatCategories = [
'private',
'uncategorized',
'income_services',
'income_products',
'income_other',
]
if (!category || noVatCategories.includes(category)) {
return 0
}
if (standard25Categories.includes(category)) {
return 0.25
}
if (reduced12Categories.includes(category)) {
return 0.12
}
if (reduced6Categories.includes(category)) {
return 0.06
}
// Default to 25% for unrecognized expense categories
return 0.25
}
/**
* Calculate input VAT from confirmed receipts
*/
function calculateReceiptInputVat(receipts: Receipt[]): number {
let inputVat = 0
for (const receipt of receipts) {
// Only confirmed receipts
if (receipt.status !== 'confirmed') continue
// Use the extracted VAT amount if available
if (receipt.vat_amount && receipt.vat_amount > 0) {
inputVat += receipt.vat_amount
}
}
return inputVat
}
/**
* Get a summary of the VAT declaration for display
*/
@@ -420,125 +262,3 @@ export function formatPeriodLabel(
return `${year}`
}
}
// ============================================================
// Tax-code-driven VAT declaration (new approach)
// ============================================================
/**
* Calculate VAT declaration using tax codes from journal entry lines.
*
* This is the new, tax-code-driven approach that sums journal_entry_lines
* grouped by tax_code, then maps via the tax_codes table to moms boxes.
* Falls back to the legacy invoice/transaction/receipt approach for
* lines without tax codes.
*/
export async function calculateVatDeclarationFromTaxCodes(
userId: string,
periodType: VatPeriodType,
year: number,
period: number
): Promise<VatDeclaration> {
const supabase = await createClient()
const { start, end } = calculatePeriodDates(periodType, year, period)
// Fetch tax codes for this user (including system codes)
const { data: taxCodesData } = await supabase
.from('tax_codes')
.select('*')
.or(`user_id.eq.${userId},user_id.is.null`)
const taxCodes = (taxCodesData as TaxCode[]) || []
const taxCodeMap = new Map<string, TaxCode>()
for (const tc of taxCodes) {
if (!taxCodeMap.has(tc.code) || tc.user_id) {
taxCodeMap.set(tc.code, tc)
}
}
// Fetch posted journal entry lines with tax_code in the period
const { data: lines } = await supabase
.from('journal_entry_lines')
.select(`
tax_code,
debit_amount,
credit_amount,
journal_entry_id,
journal_entries!inner (
user_id,
entry_date,
status
)
`)
.not('tax_code', 'is', null)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
// Aggregate amounts by moms box
const boxTotals = new Map<string, number>()
for (const line of lines || []) {
if (!line.tax_code) continue
const taxCode = taxCodeMap.get(line.tax_code)
if (!taxCode) continue
const amount = Math.abs(Number(line.debit_amount || 0) - Number(line.credit_amount || 0))
// Map to all relevant boxes
for (const box of [...taxCode.moms_basis_boxes, ...taxCode.moms_tax_boxes, ...taxCode.moms_input_boxes]) {
const current = boxTotals.get(box) || 0
boxTotals.set(box, current + amount)
}
}
// Build rutor from box totals
const rutor: VatDeclarationRutor = {
ruta05: round(boxTotals.get('05') || 0),
ruta06: round(boxTotals.get('06') || 0),
ruta07: round(boxTotals.get('07') || 0),
ruta10: round(boxTotals.get('10') || 0),
ruta11: round(boxTotals.get('11') || 0),
ruta12: round(boxTotals.get('12') || 0),
ruta39: round(boxTotals.get('39') || 0),
ruta40: round(boxTotals.get('40') || 0),
ruta48: round(boxTotals.get('48') || 0),
ruta49: 0,
}
const totalOutputVat = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07)
rutor.ruta49 = round(totalOutputVat - rutor.ruta48)
return {
period: {
type: periodType,
year,
period,
start,
end,
},
rutor,
invoiceCount: 0,
transactionCount: (lines || []).length,
breakdown: {
invoices: {
ruta05: 0,
ruta06: 0,
ruta07: 0,
ruta10: 0,
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
},
transactions: {
ruta48: 0,
},
receipts: {
ruta48: 0,
},
},
}
}
+39
View File
@@ -0,0 +1,39 @@
const PAGE_SIZE = 1000
/**
* Fetches all rows from a Supabase query by paginating through results.
* Overcomes PostgREST's default 1000-row limit.
*
* The callback receives `{ from, to }` range values append `.range(from, to)`
* to your query builder:
*
* ```ts
* const accounts = await fetchAllRows(({ from, to }) =>
* supabase
* .from('chart_of_accounts')
* .select('account_number, account_name')
* .eq('user_id', userId)
* .range(from, to)
* )
* ```
*/
export async function fetchAllRows<T>(
queryFn: (range: { from: number; to: number }) => PromiseLike<{
data: T[] | null
error: { message: string } | null
}>
): Promise<T[]> {
const allRows: T[] = []
let from = 0
while (true) {
const { data, error } = await queryFn({ from, to: from + PAGE_SIZE - 1 })
if (error) throw new Error(error.message)
if (!data || data.length === 0) break
allRows.push(...data)
if (data.length < PAGE_SIZE) break
from += PAGE_SIZE
}
return allRows
}
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest'
import { TAX_DEADLINE_CONFIGS } from '../deadline-config'
import type { CompanySettingsForDeadlines } from '../deadline-config'
function getConfig(type: string) {
return TAX_DEADLINE_CONFIGS.find((c) => c.type === type)!
}
function makeSettings(overrides: Partial<CompanySettingsForDeadlines> = {}): CompanySettingsForDeadlines {
return {
entity_type: 'aktiebolag',
moms_period: 'quarterly',
f_skatt: true,
vat_registered: true,
pays_salaries: false,
fiscal_year_start_month: 1,
...overrides,
}
}
describe('inkomstdeklaration_ab — digital filing deadlines', () => {
const config = getConfig('inkomstdeklaration_ab')
it('FY end Dec (calendar year) → Aug 1 next year', () => {
// FY ends Dec 2024, deadline Aug 1, 2025
const settings = makeSettings({ fiscal_year_start_month: 1 }) // end month = 12
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 1, month: 7, year: 2025 }) // Aug (0-indexed)
})
it('FY end Sep → Aug 1 next year', () => {
// FY start Oct, end Sep. FY ending Sep 2024 → deadline Aug 1, 2025
const settings = makeSettings({ fiscal_year_start_month: 10 }) // end month = 9
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 1, month: 7, year: 2025 }) // Aug
})
it('FY end Oct → Aug 1 next year', () => {
const settings = makeSettings({ fiscal_year_start_month: 11 }) // end month = 10
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 1, month: 7, year: 2025 }) // Aug
})
it('FY end Jan → Dec 1 same year', () => {
// FY start Feb, end Jan. FY ending Jan 2025 → deadline Dec 1, 2025
const settings = makeSettings({ fiscal_year_start_month: 2 }) // end month = 1
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 1, month: 11, year: 2025 }) // Dec
})
it('FY end Apr → Dec 1 same year', () => {
const settings = makeSettings({ fiscal_year_start_month: 5 }) // end month = 4
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 1, month: 11, year: 2025 }) // Dec
})
it('FY end May → Jan 15 next year', () => {
// FY ending May 2025 → deadline Jan 15, 2026. So for year=2026:
const settings = makeSettings({ fiscal_year_start_month: 6 }) // end month = 5
const dates = config.generateDates(2026, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 15, month: 0, year: 2026 }) // Jan
})
it('FY end Jun → Jan 15 next year', () => {
const settings = makeSettings({ fiscal_year_start_month: 7 }) // end month = 6
const dates = config.generateDates(2026, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 15, month: 0, year: 2026 }) // Jan
})
it('FY end Jul → Apr 1 next year', () => {
// FY ending Jul 2025 → deadline Apr 1, 2026. So for year=2026:
const settings = makeSettings({ fiscal_year_start_month: 8 }) // end month = 7
const dates = config.generateDates(2026, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 1, month: 3, year: 2026 }) // Apr
})
it('FY end Aug → Apr 1 next year', () => {
const settings = makeSettings({ fiscal_year_start_month: 9 }) // end month = 8
const dates = config.generateDates(2026, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 1, month: 3, year: 2026 }) // Apr
})
it('period labels are correct for calendar year', () => {
const settings = makeSettings({ fiscal_year_start_month: 1 })
const dates = config.generateDates(2025, settings)
expect(dates[0].periodLabel).toBe('2024')
})
it('period labels are correct for broken fiscal year', () => {
const settings = makeSettings({ fiscal_year_start_month: 5 }) // end month = 4
const dates = config.generateDates(2025, settings)
expect(dates[0].periodLabel).toMatch(/2024\/2025|2025/)
})
})
describe('arsredovisning — 7 months after FY end (ÅRL 8:3)', () => {
const config = getConfig('arsredovisning')
it('FY end Dec (calendar year) → Jul 31 next year', () => {
const settings = makeSettings({ fiscal_year_start_month: 1 })
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
// Dec + 7 months = July (month index 6)
expect(dates[0]).toMatchObject({ day: 31, month: 6, year: 2025 })
})
it('FY end Jun → Jan 31 next year', () => {
// FY end Jun 2024 → +7 months = Jan 2025
const settings = makeSettings({ fiscal_year_start_month: 7 }) // end month = 6
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 31, month: 0, year: 2025 }) // Jan 31
})
it('FY end Apr → Nov 30 same year', () => {
// FY end Apr 2025 → +7 months = Nov 2025
const settings = makeSettings({ fiscal_year_start_month: 5 }) // end month = 4
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 30, month: 10, year: 2025 }) // Nov 30
})
it('FY end Mar → Oct 31 same year', () => {
// FY end Mar 2025 → +7 months = Oct 2025
const settings = makeSettings({ fiscal_year_start_month: 4 }) // end month = 3
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 31, month: 9, year: 2025 }) // Oct 31
})
it('FY end Aug → Mar 31 next year', () => {
// FY end Aug 2024 → +7 months = Mar 2025
const settings = makeSettings({ fiscal_year_start_month: 9 }) // end month = 8
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0]).toMatchObject({ day: 31, month: 2, year: 2025 }) // Mar 31
})
it('uses last day of deadline month (handles Feb)', () => {
// FY end Jul 2024 → +7 months = Feb 2025
const settings = makeSettings({ fiscal_year_start_month: 8 }) // end month = 7
const dates = config.generateDates(2025, settings)
expect(dates.length).toBe(1)
expect(dates[0].month).toBe(1) // Feb
expect(dates[0].day).toBe(28) // 2025 is not a leap year
})
})
+84 -26
View File
@@ -178,7 +178,7 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
},
},
// Inkomstdeklaration (AB) - 1 juli (for calendar year fiscal)
// Inkomstdeklaration (AB) — digital filing deadlines per Skatteverket lookup table
{
type: 'inkomstdeklaration_ab',
titleTemplate: 'Inkomstdeklaration AB {periodLabel}',
@@ -187,23 +187,55 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
priority: 'critical',
linkedReportType: null,
generateDates: (year, settings) => {
// For calendar year fiscal (start month = 1), due July 1st
// For other fiscal years, this would need adjustment
if (settings.fiscal_year_start_month === 1) {
return [
{ day: 1, month: 6, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
]
// FY end month (1-indexed): e.g. start=1 → end=12, start=5 → end=4
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
// Skatteverket digital filing deadline lookup:
// FY end JanApr → Dec 1 same year as FY end
// FY end MayJun → Jan 15 year after FY end
// FY end JulAug → Apr 1 year after FY end
// FY end SepDec → Aug 1 year after FY end
const getDeadline = (fyEndYear: number) => {
if (fyEndMonth >= 1 && fyEndMonth <= 4) {
return { day: 1, month: 11, year: fyEndYear } // Dec 1
} else if (fyEndMonth >= 5 && fyEndMonth <= 6) {
return { day: 15, month: 0, year: fyEndYear + 1 } // Jan 15
} else if (fyEndMonth >= 7 && fyEndMonth <= 8) {
return { day: 1, month: 3, year: fyEndYear + 1 } // Apr 1
} else {
return { day: 1, month: 7, year: fyEndYear + 1 } // Aug 1
}
}
// For non-calendar fiscal years, calculate based on fiscal year end + 6 months
const fiscalYearEnd = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
const deadlineMonth = (fiscalYearEnd + 5) % 12 // 6 months after year end
return [
{ day: 1, month: deadlineMonth, year, period: `${year - 1}/${year}`, periodLabel: `${year - 1}/${year}` },
]
// We need to find which FY ending produces a deadline in `year`.
// Try FY endings in year-1 and year (both could produce deadlines in `year`).
const results: DeadlineInstance[] = []
for (const fyEndYear of [year - 1, year]) {
const dl = getDeadline(fyEndYear)
if (dl.year === year) {
// Compute the FY start year
const fyStartYear = fyEndMonth === 12 ? fyEndYear : fyEndYear - (fyEndMonth < settings.fiscal_year_start_month ? 0 : 0)
const fyStart = fyEndMonth === 12 ? fyEndYear : fyEndYear
const periodLabel = fyEndMonth === 12
? `${fyEndYear}`
: `${fyStart - 1}/${fyStart}`
const period = fyEndMonth === 12
? `${fyEndYear}`
: `${fyStart - 1}/${fyStart}`
results.push({
day: dl.day,
month: dl.month,
year: dl.year,
period,
periodLabel,
})
}
}
return results
},
},
// Årsredovisning (AB) - 30 juni (6 months after fiscal year end)
// Årsredovisning (AB) — 7 months after fiscal year end per ÅRL 8:3
{
type: 'arsredovisning',
titleTemplate: 'Årsredovisning till Bolagsverket {periodLabel}',
@@ -212,19 +244,45 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
priority: 'critical',
linkedReportType: null,
generateDates: (year, settings) => {
// For calendar year fiscal, due June 30th
if (settings.fiscal_year_start_month === 1) {
return [
{ day: 30, month: 5, year, period: `${year - 1}`, periodLabel: `${year - 1}` },
]
// FY end month (1-indexed)
const fyEndMonth = settings.fiscal_year_start_month === 1 ? 12 : settings.fiscal_year_start_month - 1
// 7 months after FY end per ÅRL 8:3
// Deadline month (0-indexed): ((fyEndMonth - 1) + 7) % 12
const deadlineMonth0 = ((fyEndMonth - 1) + 7) % 12
// Last day of the deadline month
// Determine which year the deadline falls in
const wrapsYear = fyEndMonth > 5 // Jun+ wraps into next year
// For calendar year (Dec end): deadline Jul 31 same year+1
// The FY ending in `year` produces a deadline:
const fyEndYear = year - 1 // By default we show deadline for the FY that ended in year-1
const deadlineYear = wrapsYear ? fyEndYear + 1 + 1 : fyEndYear + 1
// Simpler: compute from a concrete FY end date
// FY ends: fyEndMonth (1-indexed), last day, in some year.
// We want the deadline that falls in `year`.
// Try FY endings in year-1 and year
const results: DeadlineInstance[] = []
for (const endYr of [year - 1, year]) {
// Deadline: 7 months after last day of fyEndMonth in endYr
const dlMonth0 = ((fyEndMonth - 1) + 7) % 12
const dlYear = (fyEndMonth - 1) + 7 >= 12 ? endYr + 1 : endYr
if (dlYear === year) {
const lastDay = new Date(dlYear, dlMonth0 + 1, 0).getDate()
const periodLabel = fyEndMonth === 12
? `${endYr}`
: `${endYr - 1}/${endYr}`
const period = periodLabel
results.push({
day: lastDay,
month: dlMonth0,
year: dlYear,
period,
periodLabel,
})
}
}
// For non-calendar fiscal years, 6 months after year end
const fiscalYearEnd = settings.fiscal_year_start_month - 1 // 0-indexed month
const deadlineMonth = (fiscalYearEnd + 6) % 12
const deadlineYear = deadlineMonth < fiscalYearEnd ? year + 1 : year
return [
{ day: 30, month: deadlineMonth, year: deadlineYear, period: `${year - 1}/${year}`, periodLabel: `${year - 1}/${year}` },
]
return results
},
},
+1 -1
View File
@@ -154,7 +154,7 @@ export function getCategoryDisplayName(category: string): string {
expense_currency_exchange: 'Valutaväxling',
expense_other: 'Övriga kostnader',
private: 'Privat',
uncategorized: 'Ej kategoriserad',
uncategorized: 'Ej bokförd',
}
return names[category] || category
+126
View File
@@ -30,6 +30,13 @@ vi.mock('@/lib/invoice/invoice-matching', () => ({
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
}))
const mockTryReconcileTransaction = vi.fn()
const mockFetchUnlinkedGLLines = vi.fn()
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
tryReconcileTransaction: (...args: unknown[]) => mockTryReconcileTransaction(...args),
fetchUnlinkedGLLines: (...args: unknown[]) => mockFetchUnlinkedGLLines(...args),
}))
// ---------------------------------------------------------------------------
// Queue-based Supabase mock
// ---------------------------------------------------------------------------
@@ -112,6 +119,9 @@ function makeMappingResult(overrides: Record<string, unknown> = {}) {
describe('ingestTransactions', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default: no GL lines for reconciliation
mockFetchUnlinkedGLLines.mockResolvedValue([])
mockTryReconcileTransaction.mockReturnValue(null)
})
// -----------------------------------------------------------------------
@@ -426,6 +436,7 @@ describe('ingestTransactions', () => {
expect(result).toEqual({
imported: 0,
duplicates: 0,
reconciled: 0,
auto_categorized: 0,
auto_matched_invoices: 0,
errors: 0,
@@ -474,4 +485,119 @@ describe('ingestTransactions', () => {
expect(result.auto_categorized).toBe(0)
expect(result.errors).toBe(0)
})
// -----------------------------------------------------------------------
// Reconciliation: matched transactions skip auto-categorization
// -----------------------------------------------------------------------
it('reconciles transactions against GL lines and skips auto-categorization', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -500, external_id: 'ext-recon' })
const inserted = makeTransaction({
id: 'tx-recon',
amount: -500,
external_id: 'ext-recon',
currency: 'SEK',
})
const glLine = {
line_id: 'line-1',
journal_entry_id: 'je-1',
debit_amount: 0,
credit_amount: 500,
line_description: null,
entry_date: '2024-06-15',
voucher_number: 1,
voucher_series: 'A',
entry_description: 'Test entry',
source_type: 'import',
}
// Pre-fetch returns GL lines
mockFetchUnlinkedGLLines.mockResolvedValue([glLine])
// tryReconcileTransaction returns a match
mockTryReconcileTransaction.mockReturnValue({
transaction: inserted,
glLine,
method: 'auto_exact',
confidence: 0.95,
})
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
// Reconciliation update
enqueue({ data: null, error: null })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.reconciled).toBe(1)
// Should NOT have attempted auto-categorization
expect(mockEvaluateMappingRules).not.toHaveBeenCalled()
expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// Reconciliation: falls through when no GL matches
// -----------------------------------------------------------------------
it('falls through to auto-categorization when reconciliation finds no match', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -200 })
const inserted = makeTransaction({ id: 'tx-no-recon', amount: -200 })
mockFetchUnlinkedGLLines.mockResolvedValue([
{
line_id: 'line-other',
journal_entry_id: 'je-other',
debit_amount: 999,
credit_amount: 0,
entry_date: '2024-01-01',
voucher_number: 1,
voucher_series: 'A',
entry_description: 'Unrelated',
source_type: 'import',
line_description: null,
},
])
mockTryReconcileTransaction.mockReturnValue(null)
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.reconciled).toBe(0)
// Should have fallen through to auto-categorization
expect(mockEvaluateMappingRules).toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// Reconciliation: error is non-critical
// -----------------------------------------------------------------------
it('continues when reconciliation throws an error', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -300 })
const inserted = makeTransaction({ id: 'tx-recon-err', amount: -300 })
mockFetchUnlinkedGLLines.mockRejectedValue(new Error('RPC error'))
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.reconciled).toBe(0)
expect(result.errors).toBe(0)
})
})
+36
View File
@@ -2,6 +2,8 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
import type { Transaction } from '@/types'
/**
@@ -24,6 +26,7 @@ export interface RawTransaction {
export interface IngestResult {
imported: number
duplicates: number
reconciled: number
auto_categorized: number
auto_matched_invoices: number
errors: number
@@ -51,12 +54,21 @@ export async function ingestTransactions(
const result: IngestResult = {
imported: 0,
duplicates: 0,
reconciled: 0,
auto_categorized: 0,
auto_matched_invoices: 0,
errors: 0,
transaction_ids: [],
}
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
let glLinePool: UnlinkedGLLine[] = []
try {
glLinePool = await fetchUnlinkedGLLines(supabase, userId)
} catch {
// Non-critical — reconciliation will be skipped
}
for (const raw of rawTransactions) {
// 1. Check for duplicates via external_id
const { data: existing } = await supabase
@@ -100,6 +112,30 @@ export async function ingestTransactions(
result.imported++
result.transaction_ids.push(newTransaction.id)
// 2.5. Try reconciliation against pre-fetched unlinked GL lines
if (glLinePool.length > 0) {
try {
const match = tryReconcileTransaction(newTransaction as Transaction, glLinePool)
if (match) {
await supabase
.from('transactions')
.update({
journal_entry_id: match.glLine.journal_entry_id,
reconciliation_method: match.method,
is_business: true,
})
.eq('id', newTransaction.id)
// Remove matched GL line from pool to prevent double-matching
glLinePool = glLinePool.filter((l) => l.line_id !== match.glLine.line_id)
result.reconciled++
continue // Skip invoice matching and auto-categorization
}
} catch {
// Non-critical — fall through to normal flow
}
}
// 3. For income transactions, try invoice matching
if (newTransaction.amount > 0) {
try {

Some files were not shown because too many files have changed in this diff Show More