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:
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import ChartOfAccounts from '@/components/bookkeeping/ChartOfAccounts'
|
||||
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
|
||||
import { Lock } from 'lucide-react'
|
||||
|
||||
export default function BookkeepingPage() {
|
||||
@@ -45,7 +45,7 @@ export default function BookkeepingPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts">
|
||||
<ChartOfAccounts />
|
||||
<ChartOfAccountsManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -133,8 +133,10 @@ function BankFileImportWizard() {
|
||||
description: `${txCount} transaktioner hittades`,
|
||||
})
|
||||
} else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) {
|
||||
// Unrecognized format — show upload step with error
|
||||
setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".')
|
||||
} else {
|
||||
// Format detected but no transactions parsed — parser couldn't extract rows
|
||||
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
|
||||
}
|
||||
} catch (err) {
|
||||
setBankError(err instanceof Error ? err.message : 'Kunde inte läsa filen')
|
||||
@@ -293,8 +295,6 @@ function BankFileImportWizard() {
|
||||
// SIE Import Wizard (unchanged, extracted into component)
|
||||
// ============================================================
|
||||
|
||||
const SIE_STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result']
|
||||
|
||||
const SIE_STEP_LABELS: Record<ImportWizardStep, string> = {
|
||||
upload: 'Ladda upp',
|
||||
preview: 'Förhandsgranskning',
|
||||
@@ -320,8 +320,14 @@ function SIEImportWizard() {
|
||||
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
|
||||
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
|
||||
|
||||
const currentStepIndex = SIE_STEPS.indexOf(step)
|
||||
const progress = ((currentStepIndex + 1) / SIE_STEPS.length) * 100
|
||||
// Skip the mapping step when all accounts are already mapped
|
||||
const hasUnmapped = mappings.some((m) => !m.targetAccount)
|
||||
const sieSteps: ImportWizardStep[] = hasUnmapped
|
||||
? ['upload', 'preview', 'mapping', 'review', 'result']
|
||||
: ['upload', 'preview', 'review', 'result']
|
||||
|
||||
const currentStepIndex = sieSteps.indexOf(step)
|
||||
const progress = ((currentStepIndex + 1) / sieSteps.length) * 100
|
||||
|
||||
const handleFileSelect = useCallback(async (selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
@@ -490,7 +496,7 @@ function SIEImportWizard() {
|
||||
}, [file, mappings, toast])
|
||||
|
||||
const goToStep = (targetStep: ImportWizardStep) => { setStep(targetStep); setError(null) }
|
||||
const goBack = () => { const i = SIE_STEPS.indexOf(step); if (i > 0) setStep(SIE_STEPS[i - 1]) }
|
||||
const goBack = () => { const i = sieSteps.indexOf(step); if (i > 0) setStep(sieSteps[i - 1]) }
|
||||
|
||||
const handleNewImport = () => {
|
||||
setStep('upload'); setFile(null); setParsed(null); setMappings([])
|
||||
@@ -504,7 +510,7 @@ function SIEImportWizard() {
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
{SIE_STEPS.map((s, i) => (
|
||||
{sieSteps.map((s, i) => (
|
||||
<span key={s} className={i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
||||
{SIE_STEP_LABELS[s]}
|
||||
</span>
|
||||
@@ -519,7 +525,7 @@ function SIEImportWizard() {
|
||||
{step === 'preview' && preview && (
|
||||
<SIEPreviewStep preview={preview} issues={issues} missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts} isCreatingAccounts={isCreatingAccounts}
|
||||
onContinue={() => goToStep('mapping')} onBack={goBack} />
|
||||
onContinue={() => goToStep(hasUnmapped ? 'mapping' : 'review')} onBack={goBack} />
|
||||
)}
|
||||
{step === 'mapping' && (
|
||||
<AccountMappingStep mappings={mappings} basAccounts={basAccounts}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder } from '@/types'
|
||||
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary', icon: FileText },
|
||||
@@ -63,6 +63,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
|
||||
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
|
||||
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
|
||||
const [convertedFromInvoice, setConvertedFromInvoice] = useState<Invoice | null>(null)
|
||||
const [isConverting, setIsConverting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
@@ -139,6 +141,19 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
}
|
||||
}
|
||||
|
||||
// If this invoice was converted from a proforma, fetch it
|
||||
if (data.converted_from_id) {
|
||||
const { data: convertedData } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('id', data.converted_from_id)
|
||||
.single()
|
||||
|
||||
if (convertedData) {
|
||||
setConvertedFromInvoice(convertedData as Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -235,6 +250,38 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
setIsSendingEmail(false)
|
||||
}
|
||||
|
||||
async function convertToInvoice() {
|
||||
if (!invoice) return
|
||||
setIsConverting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/convert`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Kunde inte konvertera proformafakturan')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Konverterad till faktura',
|
||||
description: `Faktura ${data.data.invoice_number} har skapats`,
|
||||
})
|
||||
|
||||
router.push(`/invoices/${data.data.id}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte konvertera',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsConverting(false)
|
||||
}
|
||||
|
||||
async function downloadPDF() {
|
||||
if (!invoice) return
|
||||
|
||||
@@ -288,6 +335,11 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
const StatusIcon = status.icon
|
||||
const customer = invoice.customer
|
||||
const customerHasEmail = !!customer.email
|
||||
const docType = ((invoice as Invoice & { document_type?: InvoiceDocumentType }).document_type || 'invoice') as InvoiceDocumentType
|
||||
const isProforma = docType === 'proforma'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const isRealInvoice = docType === 'invoice'
|
||||
const docLabel = isProforma ? 'Proformafaktura' : isDeliveryNote ? 'Följesedel' : 'Faktura'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -300,6 +352,12 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
|
||||
{isProforma && (
|
||||
<Badge variant="secondary" className="bg-blue-100 text-blue-700">Proforma</Badge>
|
||||
)}
|
||||
{isDeliveryNote && (
|
||||
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">Följesedel</Badge>
|
||||
)}
|
||||
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{status.label}
|
||||
@@ -314,7 +372,17 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{invoice.status === 'draft' && (
|
||||
{isProforma && invoice.status !== 'cancelled' && (
|
||||
<Button onClick={convertToInvoice} disabled={isConverting}>
|
||||
{isConverting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Konvertera till faktura
|
||||
</Button>
|
||||
)}
|
||||
{invoice.status === 'draft' && !isDeliveryNote && (
|
||||
customerHasEmail ? (
|
||||
<Button onClick={sendInvoiceEmail} disabled={isSendingEmail}>
|
||||
{isSendingEmail ? (
|
||||
@@ -331,7 +399,13 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
|
||||
{isDeliveryNote && invoice.status === 'draft' && (
|
||||
<Button onClick={() => updateStatus('sent')} disabled={isUpdating}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Markera som skickad
|
||||
</Button>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
|
||||
<Button onClick={() => updateStatus('paid')} disabled={isUpdating}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Markera som betald
|
||||
@@ -656,6 +730,29 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Converted from proforma */}
|
||||
{convertedFromInvoice && (
|
||||
<Card className="border-blue-300">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-blue-600">
|
||||
<FileText className="h-5 w-5" />
|
||||
Konverterad
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Konverterad från proformafaktura
|
||||
</p>
|
||||
<Link href={`/invoices/${convertedFromInvoice.id}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Se proforma {convertedFromInvoice.invoice_number}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status actions */}
|
||||
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
|
||||
<Card>
|
||||
@@ -663,9 +760,34 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
<CardTitle>Åtgärder</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{invoice.status === 'draft' && (
|
||||
{isProforma && (
|
||||
<>
|
||||
{customerHasEmail ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={convertToInvoice}
|
||||
disabled={isConverting}
|
||||
>
|
||||
{isConverting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Konvertera till faktura
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('cancelled')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Makulera
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isProforma && invoice.status === 'draft' && (
|
||||
<>
|
||||
{!isDeliveryNote && customerHasEmail ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={sendInvoiceEmail}
|
||||
@@ -680,12 +802,14 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-700">
|
||||
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
|
||||
</p>
|
||||
</div>
|
||||
{!isDeliveryNote && (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-700">
|
||||
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('sent')}
|
||||
@@ -707,7 +831,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && isRealInvoice && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -734,7 +858,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
{invoice.status === 'paid' && isRealInvoice && (
|
||||
<Link href={`/invoices/${invoice.id}/credit`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ReceiptText className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -16,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}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle } from 'lucide-react'
|
||||
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle, FileQuestion, Truck } from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
import type { Invoice, InvoiceStatus } from '@/types'
|
||||
|
||||
@@ -82,11 +82,14 @@ export default function InvoicesPage() {
|
||||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote) ||
|
||||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote && docType === 'invoice') ||
|
||||
(activeTab === 'credit' && isCreditNote) ||
|
||||
invoice.status === activeTab
|
||||
(activeTab === 'proforma' && docType === 'proforma') ||
|
||||
(activeTab === 'delivery_note' && docType === 'delivery_note') ||
|
||||
(activeTab !== 'proforma' && activeTab !== 'delivery_note' && invoice.status === activeTab)
|
||||
|
||||
return matchesSearch && matchesTab
|
||||
})
|
||||
@@ -181,6 +184,8 @@ export default function InvoicesPage() {
|
||||
<TabsTrigger value="unpaid">Obetalda</TabsTrigger>
|
||||
<TabsTrigger value="paid">Betalda</TabsTrigger>
|
||||
<TabsTrigger value="draft">Utkast</TabsTrigger>
|
||||
<TabsTrigger value="proforma">Proforma</TabsTrigger>
|
||||
<TabsTrigger value="delivery_note">Följesedel</TabsTrigger>
|
||||
<TabsTrigger value="credit">Kredit</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -232,9 +237,12 @@ export default function InvoicesPage() {
|
||||
{filteredInvoices.map((invoice) => {
|
||||
const status = statusConfig[invoice.status]
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const StatusIcon = isCreditNote ? ReceiptText : status.icon
|
||||
const docType = (invoice as Invoice & { document_type?: string }).document_type || 'invoice'
|
||||
const isProforma = docType === 'proforma'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
const StatusIcon = isCreditNote ? ReceiptText : isProforma ? FileQuestion : isDeliveryNote ? Truck : status.icon
|
||||
const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null
|
||||
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : `border-l-4 ${status.borderColor}`
|
||||
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : isProforma ? 'border-l-4 border-l-blue-400' : isDeliveryNote ? 'border-l-4 border-l-emerald-400' : `border-l-4 ${status.borderColor}`
|
||||
|
||||
return (
|
||||
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
|
||||
@@ -253,6 +261,16 @@ export default function InvoicesPage() {
|
||||
Kredit
|
||||
</Badge>
|
||||
)}
|
||||
{isProforma && (
|
||||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-700">
|
||||
Proforma
|
||||
</Badge>
|
||||
)}
|
||||
{isDeliveryNote && (
|
||||
<Badge variant="secondary" className="text-xs bg-emerald-100 text-emerald-700">
|
||||
Följesedel
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
|
||||
@@ -6,10 +6,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>
|
||||
|
||||
@@ -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 på fakturor</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
defaultValue={settings?.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Texten föreslås automatiskt i anteckningsfältet vid ny faktura.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 "Kategorisera" ovan för att snabbt svepkategorisera transaktioner en i taget. Använd "Välj flera" för att hantera flera samtidigt.
|
||||
<span className="font-medium text-foreground">Tips:</span> Klicka "Bokför" ovan för att snabbt bokföra transaktioner en i taget. Använd "Välj flera" för att hantera flera samtidigt.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,63 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const { number } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch the account to check if it's a system account
|
||||
const { data: account, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, is_system_account')
|
||||
.eq('user_id', user.id)
|
||||
.eq('account_number', number)
|
||||
.single()
|
||||
|
||||
if (fetchError || !account) {
|
||||
return NextResponse.json({ error: 'Kontot hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (account.is_system_account) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Systemkonton kan inte tas bort' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if account is referenced in posted journal entries
|
||||
const { count } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('account_number', number)
|
||||
|
||||
if (count && count > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kontot kan inte tas bort eftersom det används i bokförda verifikationer. Inaktivera det istället.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.delete()
|
||||
.eq('id', account.id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
@@ -15,14 +72,17 @@ export async function PUT(
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Build update object with only provided fields
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (body.account_name !== undefined) updates.account_name = body.account_name
|
||||
if (body.is_active !== undefined) updates.is_active = body.is_active
|
||||
if (body.description !== undefined) updates.description = body.description
|
||||
if (body.default_vat_code !== undefined) updates.default_vat_code = body.default_vat_code
|
||||
if (body.sru_code !== undefined) updates.sru_code = body.sru_code
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({
|
||||
account_name: body.account_name,
|
||||
is_active: body.is_active,
|
||||
description: body.description,
|
||||
default_vat_code: body.default_vat_code,
|
||||
})
|
||||
.update(updates)
|
||||
.eq('user_id', user.id)
|
||||
.eq('account_number', number)
|
||||
.select()
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* POST /api/bookkeeping/accounts/activate
|
||||
*
|
||||
* Batch-activate BAS accounts for a user.
|
||||
* Accepts { account_numbers: string[] } and creates chart_of_accounts rows from reference data.
|
||||
* Skips any accounts that already exist for the user.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const accountNumbers: string[] = body.account_numbers
|
||||
|
||||
if (!Array.isArray(accountNumbers) || accountNumbers.length === 0) {
|
||||
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check which accounts already exist
|
||||
const { data: existing } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', user.id)
|
||||
.in('account_number', accountNumbers)
|
||||
|
||||
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
|
||||
|
||||
// Build rows for accounts that don't already exist
|
||||
const newAccounts = accountNumbers
|
||||
.filter((num) => !existingNumbers.has(num))
|
||||
.map((num) => {
|
||||
const ref = getBASReference(num)
|
||||
if (!ref) return null
|
||||
|
||||
return {
|
||||
user_id: user.id,
|
||||
account_number: ref.account_number,
|
||||
account_name: ref.account_name,
|
||||
account_class: ref.account_class,
|
||||
account_group: ref.account_group,
|
||||
account_type: ref.account_type,
|
||||
normal_balance: ref.normal_balance,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
description: ref.description,
|
||||
sru_code: ref.sru_code,
|
||||
sort_order: parseInt(ref.account_number),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
message: 'All accounts already activated',
|
||||
activated: 0,
|
||||
skipped: accountNumbers.length,
|
||||
})
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(newAccounts)
|
||||
.select()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
activated: data?.length || 0,
|
||||
skipped: accountNumbers.length - (data?.length || 0),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accounts/reference
|
||||
*
|
||||
* Returns the full BAS reference catalog merged with the user's activation status.
|
||||
* Each reference account includes: is_activated (exists in user's chart), is_active, is_system_account, is_custom.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch user's chart of accounts (paginated to avoid 1000-row limit)
|
||||
try {
|
||||
const userAccounts = await fetchAllRows<{ account_number: string; is_active: boolean; is_system_account: boolean }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, is_active, is_system_account')
|
||||
.eq('user_id', user.id)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Build lookup map
|
||||
const userAccountMap = new Map(
|
||||
userAccounts.map((a) => [a.account_number, a])
|
||||
)
|
||||
|
||||
// Merge reference with user status
|
||||
const merged = BAS_REFERENCE.map((ref) => {
|
||||
const userAccount = userAccountMap.get(ref.account_number)
|
||||
return {
|
||||
...ref,
|
||||
is_activated: !!userAccount,
|
||||
is_active: userAccount?.is_active ?? false,
|
||||
is_system_account: userAccount?.is_system_account ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
// Also identify custom accounts (in user's chart but not in BAS reference)
|
||||
const basNumbers = new Set(BAS_REFERENCE.map((r) => r.account_number))
|
||||
const customAccounts = userAccounts
|
||||
.filter((a) => !basNumbers.has(a.account_number))
|
||||
.map((a) => ({
|
||||
account_number: a.account_number,
|
||||
is_custom: true,
|
||||
is_activated: true,
|
||||
is_active: a.is_active,
|
||||
is_system_account: a.is_system_account,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ data: merged, customAccounts })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -13,27 +14,29 @@ export async function GET(request: Request) {
|
||||
const accountClass = searchParams.get('class')
|
||||
const activeOnly = searchParams.get('active') !== 'false'
|
||||
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('sort_order')
|
||||
try {
|
||||
const data = await fetchAllRows(({ from, to }) => {
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('sort_order')
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.eq('is_active', true)
|
||||
if (activeOnly) {
|
||||
query = query.eq('is_active', true)
|
||||
}
|
||||
|
||||
if (accountClass) {
|
||||
query = query.eq('account_class', parseInt(accountClass))
|
||||
}
|
||||
|
||||
return query.range(from, to)
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to fetch accounts' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (accountClass) {
|
||||
query = query.eq('account_class', parseInt(accountClass))
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateFiscalPeriodInput } from '@/types'
|
||||
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
@@ -33,6 +34,12 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = await request.json() as CreateFiscalPeriodInput
|
||||
|
||||
// Validate period duration (max 18 months per BFL 3 kap.)
|
||||
const durationError = validatePeriodDuration(body.period_start, body.period_end)
|
||||
if (durationError) {
|
||||
return NextResponse.json({ error: durationError }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
|
||||
@@ -71,27 +71,8 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing accounts to avoid duplicates
|
||||
const { data: existingAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const existingNumbers = new Set(existingAccounts?.map(a => a.account_number) || [])
|
||||
|
||||
// Filter to only accounts that don't exist
|
||||
const newAccounts = accounts.filter(a => !existingNumbers.has(a.number))
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: 0,
|
||||
message: 'All accounts already exist'
|
||||
})
|
||||
}
|
||||
|
||||
// Prepare accounts for insertion
|
||||
const accountsToInsert = newAccounts.map(account => {
|
||||
// Prepare accounts for upsert (idempotent — safe to retry)
|
||||
const accountsToUpsert = accounts.map(account => {
|
||||
const accountClass = parseInt(account.number.charAt(0), 10) || 1
|
||||
const accountGroup = account.number.substring(0, 2)
|
||||
const accountType = getAccountType(account.number)
|
||||
@@ -112,26 +93,32 @@ export async function POST(request: Request) {
|
||||
}
|
||||
})
|
||||
|
||||
// Insert in batches of 100 to avoid timeout
|
||||
// Upsert in batches of 100 to avoid timeout
|
||||
// ignoreDuplicates skips rows that already exist (no update)
|
||||
const batchSize = 100
|
||||
let totalCreated = 0
|
||||
|
||||
for (let i = 0; i < accountsToInsert.length; i += batchSize) {
|
||||
const batch = accountsToInsert.slice(i, i + batchSize)
|
||||
for (let i = 0; i < accountsToUpsert.length; i += batchSize) {
|
||||
const batch = accountsToUpsert.slice(i, i + batchSize)
|
||||
|
||||
const { error } = await supabase
|
||||
const { data: upserted, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(batch)
|
||||
.upsert(batch, {
|
||||
onConflict: 'user_id,account_number',
|
||||
ignoreDuplicates: true,
|
||||
count: 'exact',
|
||||
})
|
||||
.select('account_number')
|
||||
|
||||
if (error) {
|
||||
console.error('Error inserting accounts batch:', error)
|
||||
console.error('Error upserting accounts batch:', error)
|
||||
return NextResponse.json({
|
||||
error: `Failed to create accounts: ${error.message}`,
|
||||
created: totalCreated,
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
totalCreated += batch.length
|
||||
totalCreated += upserted?.length ?? batch.length
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings } from '@/lib/import/account-mapper'
|
||||
@@ -53,15 +54,18 @@ export async function POST(request: Request) {
|
||||
if (mappingsJson) {
|
||||
mappings = JSON.parse(mappingsJson)
|
||||
} else {
|
||||
// Fetch user's chart of accounts and generate mappings
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
|
||||
const basAccounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
if (basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
parseSIEFile,
|
||||
@@ -77,15 +78,18 @@ export async function POST(request: Request) {
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch user's chart of accounts
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
// Fetch user's full chart of accounts (paginated to avoid 1000-row limit)
|
||||
const basAccounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
if (basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/convert
|
||||
*
|
||||
* Converts a proforma invoice to a real invoice.
|
||||
* Copies all data, generates a real invoice number, and marks the proforma as cancelled.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch proforma with items
|
||||
const { data: proforma, error: proformaError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (proformaError || !proforma) {
|
||||
return NextResponse.json({ error: 'Proformafakturan hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (proforma.document_type !== 'proforma') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endast proformafakturor kan konverteras' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (proforma.status === 'cancelled') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Denna proformafaktura har redan makuleras' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate real invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
|
||||
// Create the real invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
customer_id: proforma.customer_id,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: proforma.due_date,
|
||||
currency: proforma.currency,
|
||||
exchange_rate: proforma.exchange_rate,
|
||||
exchange_rate_date: proforma.exchange_rate_date,
|
||||
subtotal: proforma.subtotal,
|
||||
subtotal_sek: proforma.subtotal_sek,
|
||||
vat_amount: proforma.vat_amount,
|
||||
vat_amount_sek: proforma.vat_amount_sek,
|
||||
total: proforma.total,
|
||||
total_sek: proforma.total_sek,
|
||||
vat_treatment: proforma.vat_treatment,
|
||||
vat_rate: proforma.vat_rate,
|
||||
moms_ruta: proforma.moms_ruta,
|
||||
reverse_charge_text: proforma.reverse_charge_text,
|
||||
your_reference: proforma.your_reference,
|
||||
our_reference: proforma.our_reference,
|
||||
notes: proforma.notes,
|
||||
document_type: 'invoice',
|
||||
converted_from_id: id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError) {
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Copy invoice items
|
||||
const items = (proforma.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.line_total,
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(items)
|
||||
|
||||
if (itemsError) {
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// Mark proforma as cancelled
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', id)
|
||||
|
||||
// Fetch complete invoice
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
if (completeInvoice) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeInvoice })
|
||||
}
|
||||
@@ -77,29 +77,33 @@ export async function POST(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate,
|
||||
entityType
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry on mark-paid:', err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry on mark-paid:', err)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -45,10 +45,7 @@ export async function POST(
|
||||
// Update status to sent
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
@@ -65,9 +62,10 @@ export async function POST(
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Faktureringsmetoden: book at send
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
if (accountingMethod === 'accrual') {
|
||||
if (isRealInvoice && accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
|
||||
@@ -113,11 +113,19 @@ export async function POST(
|
||||
company: company as CompanySettings
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
// Determine filename based on document type
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
const docType = invoice.document_type || 'invoice'
|
||||
let filename: string
|
||||
if (isCreditNote) {
|
||||
filename = `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
} else if (docType === 'proforma') {
|
||||
filename = `proformafaktura-${invoice.invoice_number}.pdf`
|
||||
} else if (docType === 'delivery_note') {
|
||||
filename = `foljesedel-${invoice.invoice_number}.pdf`
|
||||
} else {
|
||||
filename = `faktura-${invoice.invoice_number}.pdf`
|
||||
}
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
@@ -144,13 +152,10 @@ export async function POST(
|
||||
)
|
||||
}
|
||||
|
||||
// Update invoice status to "sent" and set sent_at timestamp
|
||||
// Update invoice status to "sent"
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString()
|
||||
})
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
@@ -159,8 +164,9 @@ export async function POST(
|
||||
// Don't fail the request - the email was sent successfully
|
||||
}
|
||||
|
||||
// Faktureringsmetoden: create journal entry when invoice is issued
|
||||
if ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method) {
|
||||
// Only create journal entries for real invoices (not proformas or delivery notes)
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
if (isRealInvoice && ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method)) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import { getVatRules } from '@/lib/invoice/vat-rules'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/preview-pdf
|
||||
*
|
||||
* Generates a preview PDF from form data without creating an invoice.
|
||||
* Returns the PDF as an inline blob for display in a new browser tab.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { customer_id, invoice_date, due_date, currency, items, your_reference, our_reference, notes, document_type } = body
|
||||
|
||||
if (!customer_id || !items || items.length === 0) {
|
||||
return NextResponse.json({ error: 'Kunduppgifter och rader krävs' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch customer
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Kunden hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
|
||||
}
|
||||
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
|
||||
const docType: InvoiceDocumentType = document_type || 'invoice'
|
||||
const isDeliveryNote = docType === 'delivery_note'
|
||||
|
||||
// Build items with line totals
|
||||
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({
|
||||
id: `preview-${index}`,
|
||||
invoice_id: 'preview',
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: Math.round(item.quantity * item.unit_price * 100) / 100,
|
||||
vat_rate: item.vat_rate ?? vatRules.rate,
|
||||
vat_amount: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0)
|
||||
const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100
|
||||
const total = isDeliveryNote ? 0 : subtotal + vatAmount
|
||||
|
||||
// Construct a temporary Invoice-like object
|
||||
const previewInvoice = {
|
||||
id: 'preview',
|
||||
user_id: user.id,
|
||||
customer_id,
|
||||
invoice_number: 'FÖRHANDSGRANSKNING',
|
||||
invoice_date: invoice_date || new Date().toISOString().split('T')[0],
|
||||
due_date: due_date || new Date().toISOString().split('T')[0],
|
||||
status: 'draft',
|
||||
currency: currency || 'SEK',
|
||||
exchange_rate: null,
|
||||
exchange_rate_date: null,
|
||||
subtotal: isDeliveryNote ? 0 : subtotal,
|
||||
subtotal_sek: null,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: null,
|
||||
total,
|
||||
total_sek: null,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: isDeliveryNote ? 0 : vatRules.rate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
your_reference: your_reference || null,
|
||||
our_reference: our_reference || null,
|
||||
notes: notes || null,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
credited_invoice_id: null,
|
||||
document_type: docType,
|
||||
converted_from_id: null,
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
} as Invoice
|
||||
|
||||
try {
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: previewInvoice,
|
||||
customer: customer as Customer,
|
||||
items: invoiceItems,
|
||||
company: company as CompanySettings,
|
||||
})
|
||||
)
|
||||
|
||||
return new Response(new Uint8Array(pdfBuffer), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'inline; filename="forhandsvisning.pdf"',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Preview PDF generation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte generera PDF-förhandsgranskning' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
+77
-30
@@ -2,8 +2,8 @@ import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateInvoiceInput, EntityType, Invoice, CreditNote } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal } from '@/lib/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,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id, journal_entry_id } = body
|
||||
|
||||
if (!transaction_id || !journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'transaction_id and journal_entry_id are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { date_from, date_to, dry_run } = body
|
||||
|
||||
const result = await runReconciliation(supabase, user.id, {
|
||||
dateFrom: date_from,
|
||||
dateTo: date_to,
|
||||
dryRun: dry_run ?? false,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
matches: result.matches.map((m) => ({
|
||||
transaction_id: m.transaction.id,
|
||||
transaction_date: m.transaction.date,
|
||||
transaction_description: m.transaction.description,
|
||||
transaction_amount: m.transaction.amount,
|
||||
journal_entry_id: m.glLine.journal_entry_id,
|
||||
voucher_number: m.glLine.voucher_number,
|
||||
voucher_series: m.glLine.voucher_series,
|
||||
entry_date: m.glLine.entry_date,
|
||||
entry_description: m.glLine.entry_description,
|
||||
method: m.method,
|
||||
confidence: m.confidence,
|
||||
})),
|
||||
applied: result.applied,
|
||||
errors: result.errors,
|
||||
dry_run: dry_run ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const dateFrom = searchParams.get('date_from') || undefined
|
||||
const dateTo = searchParams.get('date_to') || undefined
|
||||
|
||||
const status = await getReconciliationStatus(supabase, user.id, dateFrom, dateTo)
|
||||
|
||||
return NextResponse.json({ data: status })
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id } = body
|
||||
|
||||
if (!transaction_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'transaction_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await unlinkReconciliation(supabase, user.id, transaction_id)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { success: true } })
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const dateFrom = searchParams.get('date_from') || undefined
|
||||
const dateTo = searchParams.get('date_to') || undefined
|
||||
|
||||
const lines = await fetchUnlinkedGLLines(supabase, user.id, dateFrom, dateTo)
|
||||
|
||||
return NextResponse.json({ data: lines })
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
calculateVatDeclaration,
|
||||
formatPeriodLabel,
|
||||
} from '@/lib/reports/vat-declaration'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import type { VatPeriodType, AccountingMethod } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/reports/vat-declaration
|
||||
@@ -90,12 +90,22 @@ export async function GET(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
try {
|
||||
const declaration = await calculateVatDeclaration(
|
||||
user.id,
|
||||
periodType,
|
||||
year,
|
||||
period
|
||||
period,
|
||||
accountingMethod
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -42,6 +42,16 @@ export async function PUT(request: Request) {
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Validate: enskild firma must use calendar year (BFL 3 kap.)
|
||||
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
|
||||
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
|
||||
if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.update(body)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
@@ -88,24 +88,36 @@ export async function POST(
|
||||
|
||||
await supabase.from('supplier_invoice_items').insert(creditItems)
|
||||
|
||||
// Create credit note journal entry
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
|
||||
|
||||
// Create credit note journal entry (accrual only)
|
||||
// Cash method: skip — no original registration entry exists to reverse; deferred until refund
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
const journalEntry = await createSupplierCreditNoteEntry(
|
||||
user.id,
|
||||
creditNote as SupplierInvoice,
|
||||
creditItems as SupplierInvoiceItem[],
|
||||
original.supplier?.supplier_type || 'swedish_business'
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierCreditNoteEntry(
|
||||
user.id,
|
||||
creditNote as SupplierInvoice,
|
||||
creditItems as SupplierInvoiceItem[],
|
||||
original.supplier?.supplier_type || 'swedish_business'
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create credit note journal entry:', err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create credit note journal entry:', err)
|
||||
}
|
||||
|
||||
// Update original invoice: reduce remaining_amount
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
@@ -69,17 +68,19 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate totals from items
|
||||
// Calculate totals from items (supports both amount-based and legacy quantity*price)
|
||||
const items = body.items.map((item, index) => {
|
||||
const vatRate = item.vat_rate ?? 0.25
|
||||
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
|
||||
const lineTotal = item.amount != null
|
||||
? Math.round(item.amount * 100) / 100
|
||||
: Math.round((item.quantity ?? 1) * (item.unit_price ?? 0) * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||||
return {
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || 'st',
|
||||
unit_price: item.unit_price,
|
||||
quantity: item.amount != null ? 1 : (item.quantity ?? 1),
|
||||
unit: item.amount != null ? 'st' : (item.unit || 'st'),
|
||||
unit_price: item.amount != null ? lineTotal : (item.unit_price ?? 0),
|
||||
line_total: lineTotal,
|
||||
account_number: item.account_number,
|
||||
vat_code: item.vat_code || null,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "BAS-katalog"-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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
"okategoriserade" på transaktionssidan. Du kan kategorisera dem manuellt
|
||||
Importerade transaktioner som inte automatiskt bokförs visas som
|
||||
"obokförda" på transaktionssidan. Du kan bokföra dem manuellt
|
||||
efteråt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 på 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¤cy=SEK${dateFrom ? `&date_from=${dateFrom}` : ''}${dateTo ? `&date_to=${dateTo}` : ''}`),
|
||||
fetch(`/api/transactions?reconciled=true¤cy=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">↔</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">↔</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 på <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 */}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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 |
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -357,6 +357,7 @@ async function importVouchers(
|
||||
description: v.description,
|
||||
source_type: 'import',
|
||||
status: 'posted',
|
||||
committed_at: new Date().toISOString(),
|
||||
}))
|
||||
|
||||
// Insert headers
|
||||
|
||||
+100
-29
@@ -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 && (
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 Jan–Apr → Dec 1 same year as FY end
|
||||
// FY end May–Jun → Jan 15 year after FY end
|
||||
// FY end Jul–Aug → Apr 1 year after FY end
|
||||
// FY end Sep–Dec → 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
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user