feat: add review confirmation dialogs and BAS account number tooltips

Add modal review/confirmation dialogs before submitting invoices, supplier
invoices, and journal entries. Since journal entries are legally immutable
once posted, users now see a full summary with an amber warning before
confirming. Add AccountNumber component with rich tooltips showing account
name, class, type, and plain-language Swedish explanation for ~45 key BAS
accounts across all report views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-20 15:27:45 +01:00
co-authored by Claude Opus 4.6
parent 885f362a29
commit ba94f60d06
40 changed files with 5633 additions and 64 deletions
+117
View File
@@ -0,0 +1,117 @@
---
name: erp-api-route
description: "Generate Next.js 16 API routes for erp-base with correct auth guards, Supabase client usage, event emission, journal entry creation, and error handling. Use when creating new API endpoints in app/api/. Handles the Next.js 16 async params pattern, ensureInitialized() for events, non-blocking journal entry wrapping, and defense-in-depth user_id filtering."
---
# ERP API Route Generator
## Standard Route Template
```typescript
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
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 { data, error } = await supabase
.from('table')
.select('*')
.eq('user_id', user.id) // Defense in depth alongside RLS
.order('created_at', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
}
```
## Route That Emits Events
Add at module level (outside the handler):
```typescript
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
ensureInitialized() // MUST be module-level — loads extensions
```
Then emit after successful operations:
```typescript
await eventBus.emit('invoice.created', { invoice: result, userId: user.id })
```
## Dynamic Route Params (Next.js 16)
Params are a Promise — must await:
```typescript
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params // MUST await
// ...
}
```
## Non-Blocking Journal Entry Creation
Journal entry failures must never block the business operation:
```typescript
try {
const entry = await createXxxJournalEntry(user.id, ...)
if (entry) {
await supabase.from('table')
.update({ journal_entry_id: entry.id })
.eq('id', id)
}
} catch (err) {
console.error('Failed to create journal entry:', err)
// Continue — don't fail the request
}
```
## Response Conventions
- Success: `NextResponse.json({ data: result })`
- Success with count: `NextResponse.json({ data, count })`
- Error: `NextResponse.json({ error: 'message' }, { status: N })`
## DB Query Pattern
Every query re-filters by `user_id` as defense in depth:
```typescript
const { data, error } = await supabase
.from('table')
.select('*')
.eq('user_id', user.id) // Always include
.eq('id', id)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
if (!data) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
```
## Common Mistakes
1. Forgetting `ensureInitialized()` on routes that emit events — events silently won't fire
2. Using `params.id` instead of `(await params).id` — Next.js 16 breaking change
3. Missing `user_id` filter on queries — relies solely on RLS
4. Blocking on journal entry failure — must wrap in try/catch
5. Returning `{ message }` instead of `{ error }` on failure — inconsistent with codebase
6. Forgetting `await` on `createClient()` — it's async in server context
+108
View File
@@ -0,0 +1,108 @@
---
name: supabase-migration
description: "Generate Supabase database migrations for the erp-base project with correct RLS policies, triggers, indexes, and Swedish accounting constraints. Use when creating new tables, adding columns, modifying constraints (e.g. source_type CHECK), or any DDL operation on the Supabase database. Ensures legal compliance with BFL 7-year retention, immutability triggers, and period lock enforcement."
---
# Supabase Migration Generator
## Migration Numbering
Series: `20240101000001` through `20240101000028`. Next: `20240101000029`. Increment from there.
## New Table — Complete Template
Every new table requires ALL five parts. Missing any is a bug.
```sql
-- 1. Table with UUID PK + user_id FK
CREATE TABLE public.tablename (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- domain columns --
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 2. RLS
ALTER TABLE public.tablename ENABLE ROW LEVEL SECURITY;
-- 3. All four CRUD policies
CREATE POLICY "Users can view own tablename"
ON public.tablename FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own tablename"
ON public.tablename FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own tablename"
ON public.tablename FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own tablename"
ON public.tablename FOR DELETE USING (auth.uid() = user_id);
-- 4. Indexes (minimum: user_id + any FK/filter columns)
CREATE INDEX idx_tablename_user_id ON public.tablename (user_id);
-- 5. updated_at trigger
CREATE TRIGGER set_updated_at_tablename
BEFORE UPDATE ON public.tablename
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- 6. Audit trigger
CREATE TRIGGER audit_tablename
AFTER INSERT OR UPDATE OR DELETE ON public.tablename
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
```
## Child Tables (No Direct user_id)
Tables owned via parent use subquery-based RLS:
```sql
CREATE POLICY "Users can view own child" ON public.child_table
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.parent_table pt
WHERE pt.id = child_table.parent_id AND pt.user_id = auth.uid()
)
);
-- Repeat for INSERT (WITH CHECK), UPDATE, DELETE
```
## Expanding source_type CHECK
When adding a new journal entry source type, expand the constraint:
```sql
ALTER TABLE public.journal_entries
DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
ALTER TABLE public.journal_entries
ADD CONSTRAINT journal_entries_source_type_check
CHECK (source_type IN (
'manual','bank_transaction','invoice_created','invoice_paid',
'invoice_cash_payment','credit_note','salary_payment',
'opening_balance','year_end','storno','correction','import','system',
'supplier_invoice_registered','supplier_invoice_paid',
'supplier_invoice_cash_payment','supplier_credit_note',
'NEW_TYPE_HERE'
));
```
## Protected Triggers — NEVER Modify
Migration `20240101000017` defines legally-required triggers:
- `enforce_journal_entry_immutability` — blocks edits/deletes on posted/reversed entries
- `enforce_journal_entry_line_immutability` — blocks line mods on committed entries
- `enforce_period_lock` — blocks writes to closed/locked periods
- `block_document_deletion` — prevents deletion of docs linked to committed entries
- `enforce_retention_journal_entries` — 7-year retention
- `set_committed_at` / `calculate_retention_expiry` — auto-set timestamps
## Apply
Use `mcp__plugin_supabase_supabase__apply_migration` with snake_case `name`. Never modify existing migration files.
## Common Mistakes
1. Missing `ENABLE ROW LEVEL SECURITY` — table publicly accessible
2. Missing DELETE policy — users can't remove own records
3. Missing `updated_at` trigger — column never updates
4. Missing audit trigger — no audit trail
5. Hardcoded UUIDs in data migrations — use subqueries
6. Forgetting `source_type` CHECK expansion for new entry generators
+150
View File
@@ -0,0 +1,150 @@
---
name: swedish-bookkeeping
description: "Swedish double-entry bookkeeping domain knowledge for erp-base: BAS account codes, VAT treatments, journal entry patterns, entity type differences (enskild firma vs aktiebolag), and legal constraints (BFL/BFN). Use when creating journal entry generators, modifying bookkeeping logic, adding VAT handling, working with reports, or any accounting-related code. Prevents illegal accounting operations and ensures correct account/VAT mappings."
---
# Swedish Bookkeeping Reference
## Critical Rules (Legally Enforced)
1. **Committed entries are immutable** — never edit, use storno reversal
2. **Every entry must balance** — `sum(debits) === sum(credits)`, both `> 0`
3. **Monetary math**: `Math.round(x * 100) / 100` — NEVER `toFixed()`
4. **Account numbers are strings** — `'1930'`, never `1930`
5. **Always use engine** — `createJournalEntry()` from `lib/bookkeeping/engine.ts`, never direct DB inserts
6. **Voucher numbers** — assigned by DB RPC `next_voucher_number`, never manually
## Entry Generator Skeleton
```typescript
export async function createXxxEntry(userId: string, entity: Entity): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, entity.date)
if (!fiscalPeriodId) {
console.warn('No open fiscal period for date:', entity.date)
return null // Caller handles null
}
const lines: CreateJournalEntryLineInput[] = [
{ account_number: '1930', debit_amount: amount, credit_amount: 0, line_description: '...' },
{ account_number: '3001', debit_amount: 0, credit_amount: amount, line_description: '...' },
]
return createJournalEntry(userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entity.date,
description: 'Swedish description here',
source_type: 'xxx', // Must exist in DB CHECK constraint
source_id: entity.id,
lines,
})
}
```
## VAT Treatments & Accounts
| Treatment | Rate | Output VAT Account | Revenue Account |
|-----------|------|--------------------|-----------------|
| `standard_25` | 25% | `2611` | `3001` |
| `reduced_12` | 12% | `2621` | `3002` |
| `reduced_6` | 6% | `2631` | `3003` |
| `reverse_charge` | 0% | — | `3308` (EU service) |
| `export` | 0% | — | `3305` |
| `exempt` | 0% | — | `3004` (AB) / `3100` (EF) |
Input VAT (purchases): `2641` (Debiterad ingående moms)
## EU Reverse Charge (Fiktiv Moms)
Creates offsetting entries that net to zero:
```
Debit 2645 Beräknad ingående moms [vat_amount]
Credit 2614 Utgående moms omvänd skattsk. [vat_amount]
```
## VAT From Gross Amount
```typescript
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
const netAmount = Math.round((grossAmount / (1 + vatRate)) * 100) / 100
```
## Key Account Quick Reference
For full BAS chart, see `references/bas-accounts.md`.
| Account | Name | Usage |
|---------|------|-------|
| `1510` | Kundfordringar | Accounts receivable |
| `1930` | Företagskonto | Bank account |
| `2013` | Övriga egna uttag | Private withdrawals (EF) |
| `2440` | Leverantörsskulder | Accounts payable |
| `2893` | Skuld till aktieägare | Shareholder loan (AB) |
## Entity Type Differences
| Context | Enskild Firma | Aktiebolag |
|---------|--------------|------------|
| Private transactions | `2013` | `2893` |
| Exempt revenue | `3100` | `3004` |
| Education expense | `6991` | `7610` |
## Common Journal Entry Patterns
**Sales invoice (accrual)**:
```
Debit 1510 [total] Kundfordringar
Credit 30xx [subtotal] Försäljning
Credit 26xx [vat] Utgående moms
```
**Invoice payment**:
```
Debit 1930 [total] Företagskonto
Credit 1510 [total] Kundfordringar
```
**Supplier invoice registration**:
```
Debit 4xxx/5xxx/6xxx [net] Expense account
Debit 2641 [vat] Ingående moms
Credit 2440 [total] Leverantörsskulder
```
**Supplier invoice payment**:
```
Debit 2440 [total] Leverantörsskulder
Credit 1930 [total] Företagskonto
```
## Swedish Description Conventions
- `Faktura {invoice_number}` — sales invoice
- `Betalning faktura {invoice_number}` — payment
- `Kreditfaktura {invoice_number}` — credit note
- `Lev.faktura {supplier_invoice_number} (ankomst {arrival_number})` — supplier invoice
- `Makulering: {original_description}` — storno reversal
The `ankomstnummer` (arrival number) is a BFL requirement on supplier invoices.
## Momsdeklaration Boxes (Rutor)
| Ruta | Description | Maps from |
|------|-------------|-----------|
| 05 | Utgående moms 25% | Account 2611 |
| 06 | Utgående moms 12% | Account 2621 |
| 07 | Utgående moms 6% | Account 2631 |
| 10 | Underlag 25% | Revenue at 25% |
| 11 | Underlag 12% | Revenue at 12% |
| 12 | Underlag 6% | Revenue at 6% |
| 39 | EU tjänsteförsäljning | Account 3308 |
| 40 | Export | Account 3305 |
| 48 | Ingående moms | Account 2641 |
| 49 | Moms att betala/återfå | Sum 05+06+07 - 48 |
## EU VAT Rule
EU business customers MUST have a validated VAT number to qualify for reverse charge. Without validation, charge standard 25% Swedish VAT.
## source_type Values
Adding a new generator with a new source_type requires a DB migration to expand the CHECK constraint. Current values: `manual`, `bank_transaction`, `invoice_created`, `invoice_paid`, `invoice_cash_payment`, `credit_note`, `salary_payment`, `opening_balance`, `year_end`, `storno`, `correction`, `import`, `system`, `supplier_invoice_registered`, `supplier_invoice_paid`, `supplier_invoice_cash_payment`, `supplier_credit_note`.
@@ -0,0 +1,71 @@
# BAS Account Quick Reference — erp-base
Accounts used in codebase. Search `dev_docs/BASKONTOPLAN.md` for full chart.
## Class 1 — Assets
| Account | Name | Usage |
|---------|------|-------|
| `1510` | Kundfordringar | Accounts receivable (sales invoices) |
| `1930` | Företagskonto/checkkonto | Primary bank account |
## Class 2 — Equity, Liabilities & VAT
| Account | Name | Usage |
|---------|------|-------|
| `2013` | Övriga egna uttag | Private withdrawals (enskild firma only) |
| `2440` | Leverantörsskulder | Accounts payable |
| `2611` | Utg. moms 25% | Output VAT standard rate |
| `2614` | Utg. moms omvänd skattskyldighet | Reverse charge output |
| `2621` | Utg. moms 12% | Output VAT reduced |
| `2631` | Utg. moms 6% | Output VAT reduced |
| `2641` | Debiterad ingående moms | Input VAT (deductible) |
| `2645` | Beräknad ingående moms utlandet | Calculated input VAT (EU reverse charge) |
| `2893` | Skuld till aktieägare | Shareholder loan (aktiebolag only) |
## Class 3 — Revenue
| Account | Name | Usage |
|---------|------|-------|
| `3001` | Försäljning 25% | Revenue at standard VAT |
| `3002` | Försäljning 12% | Revenue at reduced 12% |
| `3003` | Försäljning 6% | Revenue at reduced 6% |
| `3004` | Försäljning momsfri (AB) | Exempt revenue, aktiebolag |
| `3100` | Försäljning momsfri (EF) | Exempt revenue, enskild firma |
| `3305` | Försäljning tjänst export | Non-EU export |
| `3308` | Försäljning tjänst EU | EU service (reverse charge) |
| `3900` | Övriga rörelseintäkter | Other operating income |
| `3960` | Valutakursvinster | FX gains |
## Class 4-6 — Expenses
| Account | Name | Category mapping |
|---------|------|-----------------|
| `5010` | Lokalhyra | `expense_office` |
| `5410` | Förbrukningsinventarier | `expense_equipment` |
| `5420` | Programvaror | `expense_software` |
| `5800` | Resekostnader | `expense_travel` |
| `5910` | Annonsering | `expense_marketing` |
| `6530` | Redovisningstjänster | `expense_professional_services` |
| `6570` | Bankavgifter | `expense_bank_fees` / `expense_card_fees` |
| `6900` | Övriga kostnader | Default fallback for uncategorized |
| `6991` | Övriga avdragsgilla kostnader | `expense_other` / `expense_education` (EF) |
## Class 7 — Personnel & FX
| Account | Name | Usage |
|---------|------|-------|
| `7510` | Arbetsgivaravgifter | Employer contributions 31.42% |
| `7610` | Utbildning | Education (aktiebolag only) |
| `7960` | Valutakursförluster | FX losses / `expense_currency_exchange` |
## VAT-Exempt Expense Categories
These categories never get input VAT deduction (`2641`):
- `expense_bank_fees`
- `expense_card_fees`
- `expense_currency_exchange`
## Capitalization Threshold
Equipment above 29,400 SEK uses `capitalized_debit_account` instead of normal expense. Half-year rule for 2024.
+25 -16
View File
@@ -43,12 +43,13 @@ components/
[feature]/ Feature-organized components (banking, invoices, suppliers, etc.)
extensions/ First-party extension implementations
receipt-ocr/ Receipt image OCR processing
ai-categorization/ AI-powered transaction categorization
ai-chat/ Claude-based chat assistant
enable-banking/ PSD2 bank integration via Enable Banking (JWT auth, sync)
ne-bilaga/ NE tax form attachment generation
sru-export/ SRU file export
push-notifications/ Web push notification system
receipt-ocr/ Receipt image OCR processing
sru-export/ SRU file export
example-logger/ Minimal reference extension (not loaded by default)
lib/
@@ -65,20 +66,29 @@ lib/
documents/ Document archive (upload, versioning, SHA-256 integrity)
audit/ Audit trail service
tax/ Tax code service
banking/ PSD2 integration via Enable Banking
reports/ Financial reports (trial-balance, income-statement,
balance-sheet, vat-declaration, sie-export,
supplier-ledger, supplier-reconciliation)
calendar/ Calendar and scheduling utilities
currency/ Riksbanken exchange rates
customers/ Customer management helpers
deadlines/ Tax deadline tracking
email/ Email service (Resend integration)
events/ Event bus (bus.ts, types.ts)
extensions/ Extension registry, loader, types
tax/ Tax calculations, deadlines, Swedish holidays
import/ SIE file parser
import/ SIE and bank file parser
invoice/ VAT rules for invoicing
currency/ Riksbanken exchange rates
invoices/ Invoice business logic helpers
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)
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 (70+ types, single source of truth)
types/index.ts Canonical type definitions (110+ 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
dev_docs/ Extensive project documentation (PRD, architecture, BAS guide, etc.)
@@ -306,11 +316,11 @@ mockResult({ data: makeTransaction(), error: null })
### Location
`supabase/migrations/` — currently 27 files numbered `20240101000001` through `20240101000027`.
`supabase/migrations/` — currently 28 files numbered `20240101000001` through `20240101000028`.
### Naming Convention
`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000028_*.sql`
`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000029_*.sql`
### Migration Rules
@@ -422,11 +432,11 @@ Hosted on **Vercel** with cron jobs defined in `vercel.json`:
| Cron Job | Schedule |
|----------|----------|
| `/api/banking/sync/cron` | Daily 05:00 |
| `/api/extensions/enable-banking/sync/cron` | Daily 05:00 |
| `/api/deadlines/status/cron` | Daily 06:00 |
| `/api/invoices/reminders/cron` | Daily 08:00 |
| `/api/extensions/push-notifications/cron` | Daily 09:00 |
| `/api/tax-deadlines/cron` | Monthly, 1st at 00:00 |
| `/api/tax-deadlines/cron` | Yearly, January 2nd at 00:00 |
| `/api/documents/verify/cron` | Weekly, Sunday 03:00 |
### Required Environment Variables
@@ -436,8 +446,7 @@ NEXT_PUBLIC_SUPABASE_URL # Supabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY # Supabase anonymous key
SUPABASE_SERVICE_ROLE_KEY # Supabase service role key
ENABLE_BANKING_APP_ID # Enable Banking app ID
ENABLE_BANKING_APP_SECRET # Enable Banking app secret
ENABLE_BANKING_SANDBOX # true for sandbox mode
ENABLE_BANKING_PRIVATE_KEY # Enable Banking private key (base64-encoded)
ANTHROPIC_API_KEY # Claude API key (ai-chat)
OPENAI_API_KEY # OpenAI API key (embeddings)
NEXT_PUBLIC_APP_URL # App base URL
@@ -36,6 +36,7 @@ import {
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type {
FiscalPeriod,
YearEndValidation,
@@ -439,7 +440,7 @@ export default function YearEndPage() {
<TableBody>
{preview.resultAccountSummary.map((account) => (
<TableRow key={account.account_number}>
<TableCell className="font-mono">{account.account_number}</TableCell>
<TableCell><AccountNumber number={account.account_number} name={account.account_name} /></TableCell>
<TableCell>{account.account_name}</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(account.amount)} kr
@@ -480,7 +481,7 @@ export default function YearEndPage() {
<TableBody>
{preview.closingLines.map((line, i) => (
<TableRow key={i}>
<TableCell className="font-mono">{line.account_number}</TableCell>
<TableCell><AccountNumber number={line.account_number} /></TableCell>
<TableCell>{line.line_description}</TableCell>
<TableCell className="text-right font-mono">
{line.debit_amount > 0 ? formatAmount(line.debit_amount) : ''}
+41 -10
View File
@@ -18,6 +18,8 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
import { Loader2, Plus, Trash2, ArrowLeft } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import type { Customer, Currency, CreateInvoiceInput } from '@/types'
const itemSchema = z.object({
@@ -54,6 +56,8 @@ export default function NewInvoicePage() {
const [isLoading, setIsLoading] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const {
register,
@@ -136,14 +140,20 @@ export default function NewInvoicePage() {
const vatAmount = vatRules ? subtotal * (vatRules.rate / 100) : 0
const total = subtotal + vatAmount
async function onSubmit(data: FormData) {
function onSubmit(data: FormData) {
setPendingData(data)
setShowReview(true)
}
async function handleConfirm() {
if (!pendingData) return
setIsSubmitting(true)
try {
const response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data as CreateInvoiceInput),
body: JSON.stringify(pendingData as CreateInvoiceInput),
})
const result = await response.json()
@@ -157,6 +167,7 @@ export default function NewInvoicePage() {
description: `Faktura ${result.data.invoice_number} har skapats`,
})
setShowReview(false)
router.push(`/invoices/${result.data.id}`)
} catch (error) {
toast({
@@ -422,18 +433,38 @@ export default function NewInvoicePage() {
{/* Actions */}
<Button type="submit" className="w-full" size="lg" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
</>
) : (
'Skapa faktura'
)}
Granska & skapa
</Button>
</div>
</div>
</form>
{selectedCustomer && vatRules && (
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska faktura"
warningText="En faktura skapas och en verifikation bokförs. Verifikationen kan inte ändras efteråt."
>
<InvoiceReviewContent
customer={selectedCustomer}
invoiceDate={pendingData?.invoice_date || ''}
dueDate={pendingData?.due_date || ''}
currency={(pendingData?.currency || 'SEK') as Currency}
items={pendingData?.items || []}
subtotal={subtotal}
vatRate={vatRules.rate}
vatAmount={vatAmount}
total={total}
vatTreatment={vatRules.treatment}
yourReference={pendingData?.your_reference}
ourReference={pendingData?.our_reference}
notes={pendingData?.notes}
/>
</ConfirmationDialog>
)}
</div>
)
}
+664 -7
View File
@@ -1,12 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import React, { useState, useEffect } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2 } from 'lucide-react'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight } 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 type {
@@ -62,7 +63,7 @@ export default function ReportsPage() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Rapporter</h1>
<p className="text-muted-foreground">
Saldobalans, resultaträkning, balansräkning, momsdeklaration, NE-bilaga, SRU-export och SIE-export
Huvudbok, grundbok, kundreskontra, saldobalans, resultaträkning, balansräkning, momsdeklaration och mer
</p>
</div>
</div>
@@ -124,6 +125,18 @@ export default function ReportsPage() {
<FileDown className="h-4 w-4 mr-1" />
SRU-export
</TabsTrigger>
<TabsTrigger value="huvudbok">
<BookOpen className="h-4 w-4 mr-1" />
Huvudbok
</TabsTrigger>
<TabsTrigger value="grundbok">
<List className="h-4 w-4 mr-1" />
Grundbok
</TabsTrigger>
<TabsTrigger value="kundreskontra">
<Users className="h-4 w-4 mr-1" />
Kundreskontra
</TabsTrigger>
<TabsTrigger value="supplier-ledger">
<Building2 className="h-4 w-4 mr-1" />
Lev.reskontra
@@ -150,6 +163,15 @@ export default function ReportsPage() {
<TabsContent value="sru-export">
<SRUExportView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="huvudbok">
<GeneralLedgerView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="grundbok">
<JournalRegisterView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="kundreskontra">
<ARLedgerView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="supplier-ledger">
<SupplierLedgerView periodId={selectedPeriod} />
</TabsContent>
@@ -252,7 +274,7 @@ function TrialBalanceView({ periodId }: { periodId: string }) {
<tbody>
{data.rows.map((row) => (
<tr key={row.account_number} className="border-b last:border-0">
<td className="py-2 font-mono">{row.account_number}</td>
<td className="py-2"><AccountNumber number={row.account_number} name={row.account_name} /></td>
<td className="py-2">{row.account_name}</td>
<td className="py-2 text-right">
{row.period_debit > 0 ? formatAmount(row.period_debit) : ''}
@@ -553,7 +575,7 @@ function ReportSectionTable({
<tbody>
{section.rows.map((row) => (
<tr key={row.account_number} className="border-b last:border-0">
<td className="py-1 font-mono w-16">{row.account_number}</td>
<td className="py-1 w-16"><AccountNumber number={row.account_number} name={row.account_name} /></td>
<td className="py-1">{row.account_name}</td>
<td className="py-1 text-right w-28">
{negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr
@@ -1074,7 +1096,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
{reconciliation && (
<Card className="border-2">
<CardHeader>
<CardTitle>Avstämning mot konto 2440</CardTitle>
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
@@ -1083,7 +1105,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
<span className="font-mono">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
</div>
<div className="flex justify-between">
<span>Konto 2440 saldo (huvudbok)</span>
<span><AccountNumber number="2440" /> saldo (huvudbok)</span>
<span className="font-mono">{formatAmount(reconciliation.account_2440_balance)} kr</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
@@ -1106,3 +1128,638 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
</div>
)
}
// --- General Ledger (Huvudbok) ---
interface GeneralLedgerData {
accounts: {
account_number: string
account_name: string
opening_balance: number
lines: {
date: string
voucher_series: string
voucher_number: number
description: string
source_type: string
debit: number
credit: number
balance: number
}[]
closing_balance: number
total_debit: number
total_credit: number
}[]
period: { start: string; end: string }
}
function GeneralLedgerView({ periodId }: { periodId: string }) {
const [data, setData] = useState<GeneralLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [accountFrom, setAccountFrom] = useState('')
const [accountTo, setAccountTo] = useState('')
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ period_id: periodId })
if (accountFrom) params.set('account_from', accountFrom)
if (accountTo) params.set('account_to', accountTo)
const res = await fetch(`/api/reports/general-ledger?${params}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta huvudbok')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (periodId) fetchData()
}, [periodId])
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar huvudbok...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.accounts.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga bokförda verifikationer i denna period.
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Account range filter */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-end gap-4">
<div>
<Label>Konto från</Label>
<input
type="text"
value={accountFrom}
onChange={(e) => setAccountFrom(e.target.value)}
placeholder="t.ex. 1510"
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<div>
<Label>Konto till</Label>
<input
type="text"
value={accountTo}
onChange={(e) => setAccountTo(e.target.value)}
placeholder="t.ex. 1519"
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
</div>
<Button onClick={fetchData} variant="outline">
Filtrera
</Button>
</div>
</CardContent>
</Card>
{data.period.start && (
<p className="text-sm text-muted-foreground">
Period: {data.period.start} — {data.period.end} | {data.accounts.length} konton
</p>
)}
{data.accounts.map((account) => (
<Card key={account.account_number}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
<AccountNumber number={account.account_number} name={account.account_name} showName />
</CardTitle>
<span className="text-sm text-muted-foreground">
IB: {formatAmount(account.opening_balance)} kr
</span>
</div>
</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-24 text-right">Debet</th>
<th className="py-2 w-24 text-right">Kredit</th>
<th className="py-2 w-28 text-right">Saldo</th>
</tr>
</thead>
<tbody>
{account.lines.map((line, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 font-mono text-xs">
{line.voucher_series}{line.voucher_number}
</td>
<td className="py-1.5">{line.date}</td>
<td className="py-1.5 truncate max-w-[200px]">{line.description}</td>
<td className="py-1.5 text-right">
{line.debit > 0 ? formatAmount(line.debit) : ''}
</td>
<td className="py-1.5 text-right">
{line.credit > 0 ? formatAmount(line.credit) : ''}
</td>
<td className="py-1.5 text-right font-mono">{formatAmount(line.balance)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td colSpan={3} className="py-2">Summa / Utgående balans</td>
<td className="py-2 text-right">{formatAmount(account.total_debit)}</td>
<td className="py-2 text-right">{formatAmount(account.total_credit)}</td>
<td className="py-2 text-right font-mono">{formatAmount(account.closing_balance)}</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
))}
</div>
)
}
// --- Journal Register (Grundbok) ---
interface JournalRegisterData {
entries: {
voucher_series: string
voucher_number: number
date: string
description: string
source_type: string
status: string
lines: {
account_number: string
account_name: string
debit: number
credit: number
}[]
total_debit: number
total_credit: number
}[]
total_entries: number
total_debit: number
total_credit: number
period: { start: string; end: string }
}
function JournalRegisterView({ periodId }: { periodId: string }) {
const [data, setData] = useState<JournalRegisterData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedEntries, setExpandedEntries] = useState<Set<number>>(new Set())
const fetchData = async () => {
setLoading(true)
setError(null)
setExpandedEntries(new Set())
try {
const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta grundbok')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (periodId) fetchData()
}, [periodId])
const toggleEntry = (index: number) => {
setExpandedEntries((prev) => {
const next = new Set(prev)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
return next
})
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar grundbok...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || data.entries.length === 0) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga bokförda verifikationer i denna period.
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{data.period.start && (
<p className="text-sm text-muted-foreground">
Period: {data.period.start} — {data.period.end} | {data.total_entries} verifikationer
</p>
)}
<Card>
<CardHeader>
<CardTitle>Grundbok (registreringsordning)</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-8"></th>
<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-24">Typ</th>
<th className="py-2 w-24 text-right">Debet</th>
<th className="py-2 w-24 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{data.entries.map((entry, index) => {
const isExpanded = expandedEntries.has(index)
const isReversed = entry.status === 'reversed'
return (
<React.Fragment key={index}>
<tr
className={`border-b cursor-pointer hover:bg-muted/50 ${isReversed ? 'line-through opacity-60' : ''}`}
onClick={() => toggleEntry(index)}
>
<td className="py-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</td>
<td className="py-2 font-mono text-xs">
{entry.voucher_series}{entry.voucher_number}
</td>
<td className="py-2">{entry.date}</td>
<td className="py-2">
{entry.description}
{isReversed && (
<Badge variant="outline" className="ml-2 text-xs">Makulerad</Badge>
)}
</td>
<td className="py-2 text-xs text-muted-foreground">{entry.source_type}</td>
<td className="py-2 text-right">{formatAmount(entry.total_debit)}</td>
<td className="py-2 text-right">{formatAmount(entry.total_credit)}</td>
</tr>
{isExpanded && entry.lines.map((line, lineIndex) => (
<tr key={`${index}-${lineIndex}`} className="bg-muted/30 border-b last:border-0">
<td></td>
<td></td>
<td className="py-1"><AccountNumber number={line.account_number} name={line.account_name} size="sm" /></td>
<td className="py-1 text-muted-foreground">{line.account_name}</td>
<td></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>
))}
</React.Fragment>
)
})}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td colSpan={5} className="py-2">Summa</td>
<td className="py-2 text-right">{formatAmount(data.total_debit)}</td>
<td className="py-2 text-right">{formatAmount(data.total_credit)}</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
</div>
)
}
// --- AR Ledger (Kundreskontra) ---
interface ARLedgerData {
ledger: {
entries: {
customer_id: string
customer_name: string
invoices: {
invoice_id: string
invoice_number: string
invoice_date: string
due_date: string
total: number
paid_amount: number
outstanding: number
days_overdue: number
currency: string
}[]
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}[]
total_outstanding: number
total_current: number
total_overdue: number
unpaid_count: number
}
reconciliation: {
ar_ledger_total: number
account_1510_balance: number
difference: number
is_reconciled: boolean
} | null
}
function ARLedgerView({ periodId }: { periodId: string }) {
const [data, setData] = useState<ARLedgerData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set())
const fetchData = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta kundreskontra')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (periodId) fetchData()
}, [periodId])
const toggleCustomer = (customerId: string) => {
setExpandedCustomers((prev) => {
const next = new Set(prev)
if (next.has(customerId)) {
next.delete(customerId)
} else {
next.add(customerId)
}
return next
})
}
if (loading) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Laddar kundreskontra...
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)
}
if (!data || !data.ledger) {
return (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Ingen data tillgänglig.
</CardContent>
</Card>
)
}
const { ledger, reconciliation } = data
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{formatAmount(ledger.total_outstanding)} kr</p>
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-green-600">{formatAmount(ledger.total_current)} kr</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-red-600">{formatAmount(ledger.total_overdue)} kr</p>
</CardContent>
</Card>
</div>
{/* Aging table with expandable invoice details */}
{ledger.entries.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ålderfördelning per kund</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-8"></th>
<th className="py-2">Kund</th>
<th className="py-2 text-right">Ej förfallet</th>
<th className="py-2 text-right">1-30 dagar</th>
<th className="py-2 text-right">31-60 dagar</th>
<th className="py-2 text-right">61-90 dagar</th>
<th className="py-2 text-right">90+ dagar</th>
<th className="py-2 text-right font-semibold">Totalt</th>
</tr>
</thead>
<tbody>
{ledger.entries.map((entry) => {
const isExpanded = expandedCustomers.has(entry.customer_id)
return (
<React.Fragment key={entry.customer_id}>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => toggleCustomer(entry.customer_id)}
>
<td className="py-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</td>
<td className="py-2">{entry.customer_name}</td>
<td className="py-2 text-right">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
<td className="py-2 text-right text-red-600">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
</tr>
{isExpanded && entry.invoices.map((inv) => (
<tr key={inv.invoice_id} className="bg-muted/30 border-b last:border-0">
<td></td>
<td className="py-1 text-xs" colSpan={2}>
<span className="font-mono">{inv.invoice_number}</span>
<span className="text-muted-foreground ml-2">{inv.invoice_date}</span>
<span className="text-muted-foreground ml-2">förfaller {inv.due_date}</span>
</td>
<td className="py-1 text-right text-xs text-muted-foreground" colSpan={2}>
{inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'}
</td>
<td className="py-1 text-right text-xs text-muted-foreground">
{inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''}
</td>
<td></td>
<td className="py-1 text-right text-xs font-medium">
{formatAmount(inv.outstanding)} {inv.currency}
</td>
</tr>
))}
</React.Fragment>
)
})}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td className="py-2"></td>
<td className="py-2">Summa</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
<td className="py-2 text-right text-red-600">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
)}
{/* Reconciliation */}
{reconciliation && (
<Card className="border-2">
<CardHeader>
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Kundreskontra (summa utestående)</span>
<span className="font-mono">{formatAmount(reconciliation.ar_ledger_total)} kr</span>
</div>
<div className="flex justify-between">
<span><AccountNumber number="1510" /> saldo (huvudbok)</span>
<span className="font-mono">{formatAmount(reconciliation.account_1510_balance)} kr</span>
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={reconciliation.is_reconciled ? 'text-green-600' : 'text-red-600'}>
{formatAmount(reconciliation.difference)} kr
</span>
</div>
<div className="pt-2">
{reconciliation.is_reconciled ? (
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
) : (
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
)}
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}
@@ -11,6 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
function formatAmount(amount: number): string {
@@ -316,7 +317,7 @@ export default function SupplierInvoiceDetailPage() {
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2">{item.unit}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.unit_price)}</td>
<td className="py-2 font-mono">{item.account_number}</td>
<td className="py-2"><AccountNumber number={item.account_number} /></td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
+58 -21
View File
@@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Plus, Trash2, Loader2 } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import type { Supplier, VatTreatment } from '@/types'
interface LineItem {
@@ -47,6 +49,8 @@ export default function NewSupplierInvoicePage() {
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
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>({
defaultValues: {
@@ -125,7 +129,7 @@ export default function NewSupplierInvoicePage() {
const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0)
const total = Math.round((subtotal + totalVat) * 100) / 100
async function onSubmit(data: FormData) {
function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' })
return
@@ -135,21 +139,27 @@ export default function NewSupplierInvoicePage() {
return
}
setPendingData(data)
setShowReview(true)
}
async function handleConfirm() {
if (!pendingData) return
setIsSubmitting(true)
const payload = {
supplier_id: data.supplier_id,
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: data.due_date,
delivery_date: data.delivery_date || undefined,
currency: data.currency,
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
vat_treatment: data.vat_treatment,
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
items: data.items.map((item) => ({
supplier_id: pendingData.supplier_id,
supplier_invoice_number: pendingData.supplier_invoice_number,
invoice_date: pendingData.invoice_date,
due_date: pendingData.due_date,
delivery_date: pendingData.delivery_date || undefined,
currency: pendingData.currency,
exchange_rate: pendingData.exchange_rate ? parseFloat(pendingData.exchange_rate) : undefined,
vat_treatment: pendingData.vat_treatment,
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,
@@ -171,6 +181,7 @@ export default function NewSupplierInvoicePage() {
toast({ title: 'Fel', description: result.error || 'Kunde inte registrera faktura', variant: 'destructive' })
} else {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
setShowReview(false)
router.push(`/supplier-invoices/${result.data.id}`)
}
@@ -489,17 +500,43 @@ export default function NewSupplierInvoicePage() {
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrerar...
</>
) : (
'Registrera faktura'
)}
Granska & registrera
</Button>
</div>
</form>
{pendingData && (() => {
const selectedSupplier = suppliers.find((s) => s.id === pendingData.supplier_id)
if (!selectedSupplier) return null
return (
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska leverantörsfaktura"
warningText="Leverantörsfakturan registreras och en verifikation bokförs. Verifikationen kan inte ändras efteråt."
confirmLabel="Bekräfta & registrera"
>
<SupplierInvoiceReviewContent
supplier={selectedSupplier}
invoiceNumber={pendingData.supplier_invoice_number}
invoiceDate={pendingData.invoice_date}
dueDate={pendingData.due_date}
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}
subtotal={subtotal}
totalVat={totalVat}
total={total}
/>
</ConfirmationDialog>
)
})()}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const asOfDate = searchParams.get('as_of_date') || undefined
const periodId = searchParams.get('period_id') || undefined
const ledger = await generateARLedger(user.id, asOfDate)
let reconciliation = null
if (periodId) {
reconciliation = await generateARReconciliation(user.id, periodId)
}
return NextResponse.json({
data: {
ledger,
reconciliation,
},
})
}
+26
View File
@@ -0,0 +1,26 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const accountFrom = searchParams.get('account_from') || undefined
const accountTo = searchParams.get('account_to') || undefined
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const data = await generateGeneralLedger(user.id, periodId, accountFrom, accountTo)
return NextResponse.json({ data })
}
+24
View File
@@ -0,0 +1,24 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateJournalRegister } from '@/lib/reports/journal-register'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const data = await generateJournalRegister(user.id, periodId)
return NextResponse.json({ data })
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ChevronDown, ChevronRight, Search } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { BASAccount } from '@/types'
const CLASS_LABELS: Record<number, string> = {
@@ -150,7 +151,7 @@ export default function ChartOfAccounts() {
!account.is_active ? 'opacity-50' : ''
}`}
>
<td className="py-2 font-mono">{account.account_number}</td>
<td className="py-2"><AccountNumber number={account.account_number} name={account.account_name} /></td>
<td className="py-2">{account.account_name}</td>
<td className="py-2 text-center">
{editingSRU === account.id ? (
+28 -3
View File
@@ -7,6 +7,8 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Plus, Trash2 } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import type { CreateJournalEntryLineInput, FiscalPeriod } from '@/types'
interface Props {
@@ -31,6 +33,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
{ account_number: '', debit_amount: '', credit_amount: '', line_description: '' },
])
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
useEffect(() => {
fetchPeriods()
@@ -75,9 +78,12 @@ export default function JournalEntryForm({ onCreated }: Props) {
const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const isBalanced = Math.abs(totalDebit - totalCredit) < 0.01 && totalDebit > 0
const handleSubmit = async () => {
const handleReview = () => {
if (!selectedPeriod || !description || !isBalanced) return
setShowReview(true)
}
const handleConfirm = async () => {
setIsSubmitting(true)
const entryLines: CreateJournalEntryLineInput[] = lines
@@ -114,6 +120,7 @@ export default function JournalEntryForm({ onCreated }: Props) {
title: 'Verifikation skapad',
description: `Verifikation ${result.data?.voucher_series}${result.data?.voucher_number} har skapats.`,
})
setShowReview(false)
// Reset form
setDescription('')
setLines([
@@ -276,12 +283,30 @@ export default function JournalEntryForm({ onCreated }: Props) {
<div className="flex justify-end">
<Button
onClick={handleSubmit}
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || isSubmitting}
>
{isSubmitting ? 'Sparar...' : 'Skapa verifikation'}
Granska & skapa
</Button>
</div>
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska verifikation"
warningText="En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno."
>
<JournalEntryReviewContent
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
entryDate={entryDate}
description={description}
lines={lines}
totalDebit={totalDebit}
totalCredit={totalCredit}
/>
</ConfirmationDialog>
</CardContent>
</Card>
)
+2 -1
View File
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
@@ -138,7 +139,7 @@ export default function JournalEntryList({ periodId }: Props) {
.sort((a, b) => a.sort_order - b.sort_order)
.map((line) => (
<tr key={line.id} className="border-b last:border-0">
<td className="py-2 font-mono">{line.account_number}</td>
<td className="py-2"><AccountNumber number={line.account_number} /></td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
@@ -0,0 +1,109 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import { CheckCircle2 } from 'lucide-react'
interface ReviewLine {
account_number: string
debit_amount: string
credit_amount: string
line_description: string
}
interface JournalEntryReviewContentProps {
periodName: string
entryDate: string
description: string
lines: ReviewLine[]
totalDebit: number
totalCredit: number
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function JournalEntryReviewContent({
periodName,
entryDate,
description,
lines,
totalDebit,
totalCredit,
}: JournalEntryReviewContentProps) {
const activeLines = lines.filter(
(l) => l.account_number && (l.debit_amount || l.credit_amount)
)
return (
<div className="space-y-4">
{/* Header info */}
<div className="bg-muted rounded-lg p-4 space-y-2">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Räkenskapsår</span>
<p className="font-medium">{periodName}</p>
</div>
<div>
<span className="text-muted-foreground">Datum</span>
<p className="font-medium">{entryDate}</p>
</div>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Beskrivning</span>
<p className="font-medium">{description}</p>
</div>
</div>
{/* Balance status */}
<div className="flex items-center gap-2">
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Debet = Kredit
</Badge>
</div>
{/* Debit/Credit table */}
<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">Beskrivning</th>
<th className="py-2 w-28 text-right">Debet</th>
<th className="py-2 w-28 text-right">Kredit</th>
</tr>
</thead>
<tbody>
{activeLines.map((line, index) => (
<tr key={index} className="border-b last:border-0">
<td className="py-2">
<AccountNumber number={line.account_number} />
</td>
<td className="py-2 text-muted-foreground">
{line.line_description || ''}
</td>
<td className="py-2 text-right">
{parseFloat(line.debit_amount) > 0
? formatAmount(parseFloat(line.debit_amount))
: ''}
</td>
<td className="py-2 text-right">
{parseFloat(line.credit_amount) > 0
? formatAmount(parseFloat(line.credit_amount))
: ''}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold border-t-2">
<td colSpan={2} className="py-2">Summa</td>
<td className="py-2 text-right text-green-600">{formatAmount(totalDebit)}</td>
<td className="py-2 text-right text-green-600">{formatAmount(totalCredit)}</td>
</tr>
</tfoot>
</table>
</div>
)
}
@@ -0,0 +1,137 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
import { formatCurrency } from '@/lib/utils'
import type { Customer, Currency, VatTreatment } from '@/types'
interface ReviewItem {
description: string
quantity: number
unit: string
unit_price: number
}
interface InvoiceReviewContentProps {
customer: Customer
invoiceDate: string
dueDate: string
currency: Currency
items: ReviewItem[]
subtotal: number
vatRate: number
vatAmount: number
total: number
vatTreatment: VatTreatment
yourReference?: string
ourReference?: string
notes?: string
}
export function InvoiceReviewContent({
customer,
invoiceDate,
dueDate,
currency,
items,
subtotal,
vatRate,
vatAmount,
total,
vatTreatment,
yourReference,
ourReference,
notes,
}: InvoiceReviewContentProps) {
const customerTypeLabel: Record<string, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt företag',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
return (
<div className="space-y-4">
{/* Customer info */}
<div className="bg-muted rounded-lg p-4 flex items-center justify-between">
<div>
<p className="font-medium text-base">{customer.name}</p>
<p className="text-sm text-muted-foreground">{customer.email}</p>
</div>
<Badge variant="outline">
{customerTypeLabel[customer.customer_type] || customer.customer_type}
</Badge>
</div>
{/* VAT treatment */}
<Badge className="text-sm px-3 py-1">
{getVatTreatmentLabel(vatTreatment)}
</Badge>
{/* Dates */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoiceDate}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{dueDate}</p>
</div>
</div>
{/* Line items table */}
<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-16 text-center">Enhet</th>
<th className="py-2 w-24 text-right">À-pris</th>
<th className="py-2 w-28 text-right">Belopp</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => (
<tr key={index} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
</td>
</tr>
))}
</tbody>
</table>
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<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>
<Separator />
<div className="flex justify-between font-bold text-2xl">
<span>Totalt</span>
<span>{formatCurrency(total, currency)}</span>
</div>
</div>
{/* References/notes */}
{(yourReference || ourReference || notes) && (
<div className="border-t pt-3 space-y-1 text-sm text-muted-foreground">
{yourReference && <p>Er referens: {yourReference}</p>}
{ourReference && <p>Vår referens: {ourReference}</p>}
{notes && <p>Anteckning: {notes}</p>}
</div>
)}
</div>
)
}
@@ -0,0 +1,171 @@
'use client'
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'
interface ReviewLineItem {
description: string
quantity: number
unit: string
unit_price: number
account_number: string
vat_rate: number
}
interface SupplierInvoiceReviewContentProps {
supplier: Supplier
invoiceNumber: string
invoiceDate: string
dueDate: string
deliveryDate?: string
currency: string
exchangeRate?: string
vatTreatment: VatTreatment
reverseCharge: boolean
paymentReference?: string
items: ReviewLineItem[]
subtotal: number
totalVat: number
total: number
}
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',
}
export function SupplierInvoiceReviewContent({
supplier,
invoiceNumber,
invoiceDate,
dueDate,
deliveryDate,
currency,
exchangeRate,
vatTreatment,
reverseCharge,
paymentReference,
items,
subtotal,
totalVat,
total,
}: SupplierInvoiceReviewContentProps) {
return (
<div className="space-y-4">
{/* Supplier info */}
<div className="bg-muted rounded-lg p-4 flex items-center justify-between">
<div>
<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>
{/* Dates */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoiceDate}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{dueDate}</p>
</div>
{deliveryDate && (
<div>
<span className="text-muted-foreground">Leveransdatum</span>
<p className="font-medium">{deliveryDate}</p>
</div>
)}
</div>
{/* Line items table */}
<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 w-28 text-right">Belopp</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 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 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right">{formatAmount(lineTotal)}</td>
</tr>
)
})}
</tbody>
</table>
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span>{formatAmount(subtotal)} kr</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatAmount(totalVat)} kr</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-2xl">
<span>Totalt</span>
<span>{formatAmount(total)} kr</span>
</div>
{currency !== 'SEK' && exchangeRate && (
<div className="flex justify-between text-muted-foreground">
<span>SEK-belopp (vid kurs {exchangeRate})</span>
<span>{formatAmount(total * parseFloat(exchangeRate))} kr</span>
</div>
)}
</div>
{/* Payment reference */}
{paymentReference && (
<div className="border-t pt-3 text-sm text-muted-foreground">
<p>Betalningsreferens: {paymentReference}</p>
</div>
)}
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
'use client'
import { getAccountDescription, type AccountType } from '@/lib/bookkeeping/account-descriptions'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '@/components/ui/info-tooltip'
import { cn } from '@/lib/utils'
const TYPE_COLORS: Record<AccountType, string> = {
asset: 'bg-green-500',
liability: 'bg-orange-500',
equity: 'bg-blue-500',
revenue: 'bg-purple-500',
expense: 'bg-red-500',
}
const TYPE_LABELS: Record<AccountType, string> = {
asset: 'Tillgång',
liability: 'Skuld',
equity: 'Eget kapital',
revenue: 'Intäkt',
expense: 'Kostnad',
}
interface AccountNumberProps {
number: string
name?: string
showName?: boolean
size?: 'sm' | 'default'
className?: string
}
export function AccountNumber({
number,
name,
showName,
size = 'default',
className,
}: AccountNumberProps) {
const desc = getAccountDescription(number)
const displayName = desc?.name ?? name
const numberElement = (
<span
className={cn(
'font-mono',
size === 'sm' ? 'text-xs' : 'text-sm',
className,
)}
>
{number}
</span>
)
if (!desc) {
return (
<>
{numberElement}
{showName && displayName && (
<span className="ml-1">{displayName}</span>
)}
</>
)
}
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center gap-1 cursor-help',
'decoration-dotted underline underline-offset-4 decoration-muted-foreground/40',
)}
>
<span
className={cn(
'font-mono',
size === 'sm' ? 'text-xs' : 'text-sm',
className,
)}
>
{number}
</span>
{showName && displayName && (
<span className={size === 'sm' ? 'text-xs' : 'text-sm'}>
{displayName}
</span>
)}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs p-3">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className={cn('h-2 w-2 rounded-full shrink-0', TYPE_COLORS[desc.type])} />
<span className="text-xs text-muted-foreground">
{desc.classLabel} &middot; {TYPE_LABELS[desc.type]}
</span>
</div>
<div className="font-medium">
<span className="font-mono mr-1.5">{number}</span>
{desc.name}
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{desc.explanation}
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
+84
View File
@@ -0,0 +1,84 @@
'use client'
import { ReactNode } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { ClipboardCheck, Loader2, AlertTriangle } from 'lucide-react'
interface ConfirmationDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
isSubmitting: boolean
title: string
warningText?: string
confirmLabel?: string
children: ReactNode
}
export function ConfirmationDialog({
open,
onOpenChange,
onConfirm,
isSubmitting,
title,
warningText = 'En verifikation skapas och kan inte ändras efteråt.',
confirmLabel = 'Bekräfta & skapa',
children,
}: ConfirmationDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl border-t-2 border-primary p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<ClipboardCheck className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle className="text-xl">{title}</DialogTitle>
<DialogDescription>Granska uppgifterna innan du bekräftar</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="overflow-y-auto max-h-[70vh] px-6 pb-4">
{children}
</div>
<div className="border-t px-6 py-4 space-y-4">
<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">{warningText}</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Tillbaka
</Button>
<Button onClick={onConfirm} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
</>
) : (
confirmLabel
)}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
)
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { NEDeclaration } from '@/types'
function formatAmount(amount: number): string {
@@ -279,7 +280,7 @@ function NEDeclarationRow({
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 font-mono">{acc.accountNumber}</td>
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
+2 -1
View File
@@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { SRUExportResult, SRUCoverageStats } from '@/types'
function formatAmount(amount: number): string {
@@ -228,7 +229,7 @@ function SRUBalanceRow({
<tbody>
{balance.accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 font-mono w-16">{acc.accountNumber}</td>
<td className="py-1 w-16"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">{formatAmount(acc.amount)} kr</td>
</tr>
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { getAccountDescription, ACCOUNT_DESCRIPTIONS } from '../account-descriptions'
describe('account-descriptions', () => {
describe('getAccountDescription', () => {
it('returns description for known accounts', () => {
const desc = getAccountDescription('1510')
expect(desc).toBeDefined()
expect(desc!.name).toBe('Kundfordringar')
expect(desc!.type).toBe('asset')
})
it('returns undefined for unknown accounts', () => {
expect(getAccountDescription('9999')).toBeUndefined()
expect(getAccountDescription('')).toBeUndefined()
})
})
describe('data completeness', () => {
const KEY_ACCOUNTS = [
'1510', '1930', '2013', '2440', '2611', '2621', '2631',
'2614', '2641', '2645', '2893', '3001', '3002', '3003',
'3305', '3308', '3960', '7960',
]
it.each(KEY_ACCOUNTS)('includes key BAS account %s', (account) => {
expect(getAccountDescription(account)).toBeDefined()
})
it('all entries have non-empty required fields', () => {
for (const [number, desc] of Object.entries(ACCOUNT_DESCRIPTIONS)) {
expect(desc.name, `${number} missing name`).toBeTruthy()
expect(desc.classLabel, `${number} missing classLabel`).toBeTruthy()
expect(desc.type, `${number} missing type`).toBeTruthy()
expect(desc.explanation, `${number} missing explanation`).toBeTruthy()
}
})
it('type values are valid', () => {
const validTypes = ['asset', 'liability', 'equity', 'revenue', 'expense']
for (const [number, desc] of Object.entries(ACCOUNT_DESCRIPTIONS)) {
expect(validTypes, `${number} has invalid type: ${desc.type}`).toContain(desc.type)
}
})
})
})
+332
View File
@@ -0,0 +1,332 @@
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
export interface AccountDescription {
name: string
classLabel: string
type: AccountType
explanation: string
}
const ACCOUNT_DESCRIPTIONS: Record<string, AccountDescription> = {
// Class 1: Assets (Tillgångar)
'1510': {
name: 'Kundfordringar',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Pengar som kunder är skyldiga dig för skickade fakturor som inte betalats ännu.',
},
'1580': {
name: 'Fordran för skatt',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Pengar du har att fordra från Skatteverket, t.ex. överskjutande moms.',
},
'1630': {
name: 'Skattekonto',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Ditt skattekonto hos Skatteverket, visar saldo för inbetalda skatter och avgifter.',
},
'1710': {
name: 'Förutbetalda hyreskostnader',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Hyra som betalats i förskott men som avser kommande perioder.',
},
'1910': {
name: 'Kassa',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Kontanta pengar i företagets kassa.',
},
'1920': {
name: 'PlusGiro',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Pengar på företagets PlusGiro-konto.',
},
'1930': {
name: 'Företagskonto',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Företagets huvudsakliga bankkonto. Hit kommer inbetalningar och härifrån görs utbetalningar.',
},
'1940': {
name: 'Övriga bankkonton',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Ytterligare bankkonton utöver huvudkontot.',
},
// Class 2: Equity & Liabilities (Eget kapital och skulder)
'2010': {
name: 'Eget kapital',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Ägarens insatta kapital i enskild firma. Visar vad ägaren har investerat i verksamheten.',
},
'2013': {
name: 'Egna uttag',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Pengar som ägaren av en enskild firma tar ut privat ur företaget.',
},
'2018': {
name: 'Egna insättningar',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Pengar som ägaren sätter in privat i företaget (enskild firma).',
},
'2081': {
name: 'Aktiekapital',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Det registrerade aktiekapitalet i ett aktiebolag.',
},
'2091': {
name: 'Balanserat resultat',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Ackumulerade vinster/förluster från tidigare år som inte delats ut.',
},
'2099': {
name: 'Årets resultat',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Vinst eller förlust för innevarande räkenskapsår.',
},
'2440': {
name: 'Leverantörsskulder',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Pengar du är skyldig leverantörer för mottagna fakturor som inte betalats ännu.',
},
'2510': {
name: 'Skatteskulder',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Skulder till Skatteverket för preliminär skatt och andra skattebetalningar.',
},
'2611': {
name: 'Utgående moms 25%',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du tar ut på försäljning med 25% momssats. Ska betalas in till Skatteverket.',
},
'2614': {
name: 'Utgående moms omvänd skattskyldighet',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Utgående moms vid omvänd skattskyldighet (reverse charge). Köparen redovisar momsen.',
},
'2621': {
name: 'Utgående moms 12%',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du tar ut på försäljning med 12% momssats (t.ex. livsmedel, hotell).',
},
'2631': {
name: 'Utgående moms 6%',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du tar ut på försäljning med 6% momssats (t.ex. böcker, kollektivtrafik).',
},
'2641': {
name: 'Ingående moms',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du betalat på inköp som du har rätt att dra av. Minskar din momsskuld.',
},
'2645': {
name: 'Beräknad ingående moms på EU-förvärv',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Ingående moms du beräknar själv vid inköp från andra EU-länder (omvänd skattskyldighet).',
},
'2893': {
name: 'Lån från aktieägare',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Pengar som aktiebolaget lånat av sina ägare. Vanligt i mindre AB.',
},
'2898': {
name: 'Outtagen vinstutdelning',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Beslutad men ännu ej utbetald aktieutdelning.',
},
'2920': {
name: 'Upplupna semesterlöner',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Skuld för intjänade men ej uttagna semesterdagar.',
},
'2940': {
name: 'Upplupna arbetsgivaravgifter',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Arbetsgivaravgifter som hänför sig till redovisade löner men ännu inte betalats.',
},
// Class 3: Revenue (Intäkter)
'3001': {
name: 'Försäljning varor/tjänster 25%',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning med 25% moms — den vanligaste intäktsraden.',
},
'3002': {
name: 'Försäljning varor/tjänster 12%',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning med 12% moms (t.ex. livsmedel).',
},
'3003': {
name: 'Försäljning varor/tjänster 6%',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning med 6% moms (t.ex. böcker, tidningar).',
},
'3305': {
name: 'Försäljning export utanför EU',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning till kunder utanför EU. Momsfritt.',
},
'3308': {
name: 'Försäljning tjänster EU',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning av tjänster till företag i andra EU-länder. Omvänd skattskyldighet.',
},
'3900': {
name: 'Övriga rörelseintäkter',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Andra intäkter som inte hör till kärnverksamheten, t.ex. uthyrning av lokaler.',
},
'3960': {
name: 'Valutakursvinster',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Vinster som uppstår vid valutaväxling eller betalningar i utländsk valuta.',
},
// Class 4: Cost of goods (Varor och material)
'4010': {
name: 'Varuinköp',
classLabel: 'Varor och material',
type: 'expense',
explanation: 'Kostnader för inköp av varor som säljs vidare.',
},
// Class 5: External expenses (Övriga externa kostnader)
'5010': {
name: 'Lokalkostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Kostnader för kontorslokal, lager eller annan arbetsplats (hyra, el, städning).',
},
'5410': {
name: 'Förbrukningsinventarier',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Inköp av mindre inventarier, t.ex. kontorsmöbler, dator (under halva prisbasbeloppet).',
},
'5420': {
name: 'Programvaror',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Kostnader för mjukvara, prenumerationer och licenser.',
},
'5800': {
name: 'Resekostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Tjänsteresor: tåg, flyg, hotell, taxi.',
},
'5910': {
name: 'Annonsering och reklam',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Kostnader för marknadsföring, annonser och reklamkampanjer.',
},
// Class 6: Other external expenses
'6530': {
name: 'Redovisningstjänster',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Avgifter till bokföringsbyrå eller redovisningskonsult.',
},
'6570': {
name: 'Bankkostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Avgifter för banktjänster, betalförmedling och transaktionsavgifter.',
},
'6991': {
name: 'Övriga externa kostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Diverse externa kostnader som inte passar in under andra konton.',
},
// Class 7: Personnel & depreciation
'7010': {
name: 'Löner tjänstemän',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Bruttolöner (före skatt) till anställda tjänstemän.',
},
'7210': {
name: 'Löner kollektiv',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Bruttolöner till arbetare och kollektivanställda.',
},
'7510': {
name: 'Arbetsgivaravgifter',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Lagstadgade sociala avgifter som arbetsgivaren betalar (ca 31% av bruttolönen).',
},
'7820': {
name: 'Avskrivningar inventarier',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Årlig värdeminskning på inventarier och maskiner. Fördelas över tillgångens livslängd.',
},
'7960': {
name: 'Valutakursförluster',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Förluster som uppstår vid valutaväxling eller betalningar i utländsk valuta.',
},
// Class 8: Financial items
'8310': {
name: 'Ränteintäkter',
classLabel: 'Finansiella poster',
type: 'revenue',
explanation: 'Ränta du får på bankkontosaldo eller utlånade pengar.',
},
'8410': {
name: 'Räntekostnader',
classLabel: 'Finansiella poster',
type: 'expense',
explanation: 'Ränta du betalar på lån och krediter.',
},
'8999': {
name: 'Årets resultat',
classLabel: 'Finansiella poster',
type: 'equity',
explanation: 'Slutresultatkonto som visar vinst eller förlust efter alla intäkter och kostnader.',
},
}
export function getAccountDescription(accountNumber: string): AccountDescription | undefined {
return ACCOUNT_DESCRIPTIONS[accountNumber]
}
export { ACCOUNT_DESCRIPTIONS }
+249
View File
@@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateARLedger } from '../ar-ledger'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateARLedger', () => {
it('returns empty report when no invoices found', async () => {
results = [
{ data: [], error: null },
]
const report = await generateARLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.unpaid_count).toBe(0)
})
it('returns empty report on query error', async () => {
results = [
{ data: null, error: { message: 'DB error' } },
]
const report = await generateARLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
it('groups invoices by customer with correct aging buckets', async () => {
// Reference date: 2024-06-15
const asOfDate = '2024-06-15'
results = [
{
data: [
// Customer A: one current, one 1-30 days overdue
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Acme AB' },
invoice_number: 'F001',
invoice_date: '2024-05-01',
due_date: '2024-06-20', // not yet due
total: 5000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
{
id: 'inv-2',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Acme AB' },
invoice_number: 'F002',
invoice_date: '2024-04-01',
due_date: '2024-06-01', // 14 days overdue
total: 3000,
paid_amount: 1000,
currency: 'SEK',
status: 'overdue',
},
// Customer B: 90+ days overdue
{
id: 'inv-3',
customer_id: 'cust-b',
customer: { id: 'cust-b', name: 'Beta Corp' },
invoice_number: 'F003',
invoice_date: '2024-01-01',
due_date: '2024-02-01', // 135 days overdue
total: 10000,
paid_amount: 0,
currency: 'SEK',
status: 'overdue',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', asOfDate)
expect(report.unpaid_count).toBe(3)
expect(report.entries).toHaveLength(2)
// Sorted by total outstanding descending: Beta Corp (10000), then Acme (7000)
expect(report.entries[0].customer_name).toBe('Beta Corp')
expect(report.entries[0].total_outstanding).toBe(10000)
expect(report.entries[0].days_90_plus).toBe(10000)
expect(report.entries[1].customer_name).toBe('Acme AB')
expect(report.entries[1].total_outstanding).toBe(7000)
expect(report.entries[1].current).toBe(5000) // inv-1
expect(report.entries[1].days_1_30).toBe(2000) // inv-2 (3000 - 1000 paid)
expect(report.entries[1].invoices).toHaveLength(2)
// Totals
expect(report.total_outstanding).toBe(17000)
expect(report.total_current).toBe(5000)
expect(report.total_overdue).toBe(12000)
})
it('computes outstanding as total minus paid_amount', async () => {
results = [
{
data: [
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test AB' },
invoice_number: 'F001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
total: 10000,
paid_amount: 7500,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(2500)
expect(report.total_outstanding).toBe(2500)
})
it('sorts invoices within customer by due_date', async () => {
results = [
{
data: [
{
id: 'inv-2',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test AB' },
invoice_number: 'F002',
invoice_date: '2024-05-01',
due_date: '2024-07-01',
total: 1000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test AB' },
invoice_number: 'F001',
invoice_date: '2024-04-01',
due_date: '2024-06-01',
total: 2000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-05-15')
// Sorted by due_date: F001 (June 1) before F002 (July 1)
expect(report.entries[0].invoices[0].invoice_number).toBe('F001')
expect(report.entries[0].invoices[1].invoice_number).toBe('F002')
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test' },
invoice_number: 'F001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
total: 100.1,
paid_amount: 33.33,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(66.77)
expect(report.total_outstanding).toBe(66.77)
})
it('handles missing customer name gracefully', async () => {
results = [
{
data: [
{
id: 'inv-1',
customer_id: 'cust-a',
customer: null,
invoice_number: 'F001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
total: 1000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-06-15')
expect(report.entries[0].customer_name).toBe('Okänd kund')
})
})
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateARReconciliation } from '../ar-reconciliation'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateARReconciliation', () => {
it('returns reconciled when AR ledger matches account 1510', async () => {
results = [
// 0: invoices
{
data: [
{ total: 5000, paid_amount: 2000 },
{ total: 3000, paid_amount: 0 },
],
error: null,
},
// 1: journal_entry_lines for account 1510
{
data: [
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'e1' },
{ debit_amount: 0, credit_amount: 2000, journal_entry_id: 'e2' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
// AR: (5000-2000) + (3000-0) = 6000
expect(result.ar_ledger_total).toBe(6000)
// 1510: 8000 - 2000 = 6000
expect(result.account_1510_balance).toBe(6000)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('detects difference when AR ledger does not match account 1510', async () => {
results = [
// 0: invoices
{
data: [
{ total: 5000, paid_amount: 0 },
],
error: null,
},
// 1: journal_entry_lines — manual debit on 1510 creates mismatch
{
data: [
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'e1' },
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'e2' }, // manual entry
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(5000)
expect(result.account_1510_balance).toBe(6000)
expect(result.difference).toBe(-1000)
expect(result.is_reconciled).toBe(false)
})
it('returns zero balances when no data exists', async () => {
results = [
{ data: [], error: null },
{ data: [], error: null },
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(0)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('handles null invoice data gracefully', async () => {
results = [
{ data: null, error: null },
{
data: [
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(3000)
expect(result.difference).toBe(-3000)
expect(result.is_reconciled).toBe(false)
})
it('uses correct debit-normal balance for account 1510 (asset)', async () => {
results = [
{ data: [], error: null },
{
data: [
{ debit_amount: 10000, credit_amount: 0, journal_entry_id: 'e1' },
{ debit_amount: 0, credit_amount: 4000, journal_entry_id: 'e2' },
{ debit_amount: 0, credit_amount: 3000, journal_entry_id: 'e3' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
// Balance = debits - credits = 10000 - 4000 - 3000 = 3000
expect(result.account_1510_balance).toBe(3000)
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{ total: 100.1, paid_amount: 33.33 },
],
error: null,
},
{
data: [
{ debit_amount: 66.77, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(66.77)
expect(result.account_1510_balance).toBe(66.77)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
})
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { generateBalanceSheet } from '../balance-sheet'
import { generateTrialBalance } from '../trial-balance'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
beforeEach(() => {
vi.clearAllMocks()
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
account_number: '1930',
account_name: 'Test Account',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
}
describe('generateBalanceSheet', () => {
it('returns empty report when no rows', async () => {
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toEqual([])
expect(report.equity_liability_sections).toEqual([])
expect(report.total_assets).toBe(0)
expect(report.total_equity_liabilities).toBe(0)
})
it('calculates assets (class 1) with debit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 15000, closing_credit: 0 }),
makeRow({ account_number: '1930', account_name: 'Företagskonto', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
],
totalDebit: 65000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(2)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
expect(report.asset_sections[0].rows[0].amount).toBe(15000) // debit - credit
expect(report.asset_sections[1].title).toBe('Kassa och bank')
expect(report.asset_sections[1].rows[0].amount).toBe(50000)
expect(report.total_assets).toBe(65000)
})
it('calculates equity/liabilities (class 2) with credit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '2010', account_name: 'Eget kapital', account_class: 2, closing_credit: 30000, closing_debit: 0 }),
makeRow({ account_number: '2440', account_name: 'Leverantörsskulder', account_class: 2, closing_credit: 10000, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 40000,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.equity_liability_sections).toHaveLength(2)
expect(report.equity_liability_sections[0].title).toBe('Eget kapital')
expect(report.equity_liability_sections[0].rows[0].amount).toBe(30000) // credit - debit
expect(report.equity_liability_sections[1].title).toBe('Kortfristiga skulder')
expect(report.total_equity_liabilities).toBe(40000)
})
it('filters rows with |amount| < 0.005', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// This row has amount = 0.003, should be filtered
makeRow({ account_number: '1940', account_name: 'Tiny', account_class: 1, closing_debit: 0.003, closing_credit: 0 }),
],
totalDebit: 50000.003,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(bankSection.rows).toHaveLength(1)
expect(bankSection.rows[0].account_number).toBe('1930')
})
it('filters empty sections after zero-filtering', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// Section 10 (Immateriella) will be empty after filtering
makeRow({ account_number: '1010', account_name: 'Goodwill', account_class: 1, closing_debit: 0.001, closing_credit: 0 }),
],
totalDebit: 50000.001,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kassa och bank')
})
it('groups by two-digit prefix (1510 + 1520 under 15)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 10000, closing_credit: 0 }),
makeRow({ account_number: '1520', account_name: 'Osäkra kundfordringar', account_class: 1, closing_debit: 2000, closing_credit: 0 }),
],
totalDebit: 12000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
expect(report.asset_sections[0].rows).toHaveLength(2)
expect(report.asset_sections[0].subtotal).toBe(12000)
})
it('ignores class 3-8 accounts (income statement)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 40000, closing_debit: 0 }),
makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '8310', account_name: 'Interest', account_class: 8, closing_credit: 500, closing_debit: 0 }),
],
totalDebit: 58000,
totalCredit: 40500,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1) // Only 1930
expect(report.equity_liability_sections).toEqual([])
expect(report.total_assets).toBe(50000)
expect(report.total_equity_liabilities).toBe(0)
})
it('uses Math.round for monetary precision on subtotals', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 33.33, closing_credit: 0 }),
makeRow({ account_number: '1940', account_name: 'Kassa', account_class: 1, closing_debit: 33.33, closing_credit: 0 }),
makeRow({ account_number: '1950', account_name: 'Annan bank', account_class: 1, closing_debit: 33.34, closing_credit: 0 }),
],
totalDebit: 100,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
// All three are in group '19' (Kassa och bank)
const section = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(section.subtotal).toBe(100)
expect(report.total_assets).toBe(100)
})
})
@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'order']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateGeneralLedger } from '../general-ledger'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateGeneralLedger', () => {
it('returns empty report when no fiscal period found', async () => {
results = [
// 0: fiscal_periods.single() → null
{ data: null, error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '', end: '' })
})
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 },
// 1: journal_entries (empty)
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
})
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 },
// 1: journal_entries for this period
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' },
{ id: 'e2', entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' },
],
error: null,
},
// 2: journal_entry_lines
{
data: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entry_id: 'e2' },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2611', account_name: 'Utgående moms 25%' },
{ account_number: '3001', account_name: 'Försäljning 25%' },
],
error: null,
},
// 4: prior entries (none)
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
expect(report.accounts).toHaveLength(4)
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930', '2611', '3001'])
// Account 1510: debit 1250, credit 1250 → closing 0
const acc1510 = report.accounts.find((a) => a.account_number === '1510')!
expect(acc1510.total_debit).toBe(1250)
expect(acc1510.total_credit).toBe(1250)
expect(acc1510.closing_balance).toBe(0)
expect(acc1510.lines).toHaveLength(2)
expect(acc1510.lines[0].balance).toBe(1250)
expect(acc1510.lines[1].balance).toBe(0)
// Account 1930: debit 1250, credit 0 → closing 1250
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.total_debit).toBe(1250)
expect(acc1930.total_credit).toBe(0)
expect(acc1930.closing_balance).toBe(1250)
})
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 },
// 1: journal_entries for this period
{
data: [
{ id: 'e1', entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' },
],
error: null,
},
// 2: journal_entry_lines
{
data: [
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
{ account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '5410', account_name: 'Förbrukningsinventarier' },
],
error: null,
},
// 4: prior entries
{ data: [{ id: 'prior-1' }], error: null },
// 5: prior lines
{
data: [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
],
error: null,
},
]
const report = await generateGeneralLedger('user-1', 'period-2')
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.opening_balance).toBe(10000)
expect(acc1930.closing_balance).toBe(9500) // 10000 - 500
expect(acc1930.lines[0].balance).toBe(9500)
})
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 },
// 1: journal_entries
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' },
],
error: null,
},
// 2: lines across multiple accounts
{
data: [
{ account_number: '1510', debit_amount: 1000, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
],
error: null,
},
// 3: chart_of_accounts
{ data: [], error: null },
// 4: prior entries (none)
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1', '1500', '1999')
// Only accounts in 1500–1999 range
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930'])
})
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 },
// 1: entries out of order
{
data: [
{ id: 'e2', entry_date: '2024-01-10', voucher_number: 2, voucher_series: 'A', description: 'Second', source_type: 'manual' },
{ id: 'e1', entry_date: '2024-01-10', voucher_number: 1, voucher_series: 'A', description: 'First', source_type: 'manual' },
{ id: 'e3', entry_date: '2024-01-05', voucher_number: 3, voucher_series: 'A', description: 'Earlier date', source_type: 'manual' },
],
error: null,
},
// 2: lines all on same account
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1930', debit_amount: 200, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 300, credit_amount: 0, journal_entry_id: 'e3' },
],
error: null,
},
// 3: chart_of_accounts
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
// 4: prior entries
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const acc = report.accounts[0]
// e3 (Jan 5) first, then e1 (Jan 10, #1), then e2 (Jan 10, #2)
expect(acc.lines[0].description).toBe('Earlier date')
expect(acc.lines[1].description).toBe('First')
expect(acc.lines[2].description).toBe('Second')
})
it('uses Math.round for monetary precision', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const acc = report.accounts[0]
expect(acc.total_debit).toBe(33.33)
expect(acc.closing_balance).toBe(33.33)
})
})
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { generateIncomeStatement } from '../income-statement'
import { generateTrialBalance } from '../trial-balance'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
beforeEach(() => {
vi.clearAllMocks()
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
account_number: '3001',
account_name: 'Test Account',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
}
describe('generateIncomeStatement', () => {
it('returns empty report when no rows', async () => {
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.revenue_sections).toEqual([])
expect(report.expense_sections).toEqual([])
expect(report.financial_sections).toEqual([])
expect(report.net_result).toBe(0)
})
it('calculates revenue (class 3) with credit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
makeRow({ account_number: '3002', account_name: 'Försäljning 12%', account_class: 3, closing_credit: 5000, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 15000,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.revenue_sections).toHaveLength(1)
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
expect(report.revenue_sections[0].rows).toHaveLength(2)
expect(report.revenue_sections[0].rows[0].amount).toBe(10000)
expect(report.revenue_sections[0].rows[1].amount).toBe(5000)
expect(report.total_revenue).toBe(15000)
})
it('calculates expenses (class 4-7) with debit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '6200', account_name: 'Telefon', account_class: 6, closing_debit: 2000, closing_credit: 0 }),
],
totalDebit: 10000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.expense_sections).toHaveLength(2)
expect(report.expense_sections[0].title).toBe('Lokalkostnader')
expect(report.expense_sections[0].rows[0].amount).toBe(8000)
expect(report.expense_sections[1].title).toBe('Tele och post')
expect(report.total_expenses).toBe(10000)
})
it('calculates financial items (class 8) with mixed balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '8310', account_name: 'Ränteintäkter', account_class: 8, closing_credit: 500, closing_debit: 0 }),
makeRow({ account_number: '8410', account_name: 'Räntekostnader', account_class: 8, closing_debit: 300, closing_credit: 0 }),
],
totalDebit: 300,
totalCredit: 500,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.financial_sections).toHaveLength(2)
// Financial uses credit - debit
const interestIncome = report.financial_sections.find(s => s.title === 'Ränteintäkter')!
expect(interestIncome.rows[0].amount).toBe(500) // credit 500 - debit 0
const interestExpense = report.financial_sections.find(s => s.title === 'Räntekostnader')!
expect(interestExpense.rows[0].amount).toBe(-300) // credit 0 - debit 300
})
it('computes net_result = revenue - expenses + financial', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 20000, closing_debit: 0 }),
makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '8310', account_name: 'Interest', account_class: 8, closing_credit: 1000, closing_debit: 0 }),
],
totalDebit: 8000,
totalCredit: 21000,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.total_revenue).toBe(20000)
expect(report.total_expenses).toBe(8000)
expect(report.total_financial).toBe(1000)
expect(report.net_result).toBe(13000) // 20000 - 8000 + 1000
})
it('filters rows with |amount| < 0.005', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
// This row has amount = 0.004, should be filtered
makeRow({ account_number: '3002', account_name: 'Tiny', account_class: 3, closing_credit: 0.004, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 10000.004,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
// Only the significant revenue row should appear
const revenueSection = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
expect(revenueSection.rows).toHaveLength(1)
expect(revenueSection.rows[0].account_number).toBe('3001')
})
it('filters empty sections (no rows after zero-filtering)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 5000, closing_debit: 0 }),
// Section 36 will be empty after filtering
makeRow({ account_number: '3601', account_name: 'Tiny side income', account_class: 3, closing_credit: 0.001, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 5000.001,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
// Only the non-empty section should remain
expect(report.revenue_sections).toHaveLength(1)
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
})
it('ignores class 1-2 accounts (balance sheet)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
makeRow({ account_number: '2440', account_name: 'Leverantörsskulder', account_class: 2, closing_credit: 10000, closing_debit: 0 }),
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 40000, closing_debit: 0 }),
],
totalDebit: 50000,
totalCredit: 50000,
isBalanced: true,
})
const report = await generateIncomeStatement('user-1', 'period-1')
// Only class 3 should appear
expect(report.revenue_sections).toHaveLength(1)
expect(report.expense_sections).toEqual([])
expect(report.financial_sections).toEqual([])
expect(report.total_revenue).toBe(40000)
})
})
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateJournalRegister } from '../journal-register'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateJournalRegister', () => {
it('returns empty report when no fiscal period found', async () => {
results = [
{ data: null, error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '', end: '' })
})
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: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
})
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 },
// 1: journal_entries (already ordered by series/number)
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale invoice', source_type: 'invoice', status: 'posted' },
{ id: 'e2', entry_date: '2024-02-01', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction', status: 'posted' },
],
error: null,
},
// 2: journal_entry_lines
{
data: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entry_id: 'e2' },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2611', account_name: 'Utgående moms 25%' },
{ account_number: '3001', account_name: 'Försäljning 25%' },
],
error: null,
},
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.total_entries).toBe(2)
expect(report.entries[0].voucher_number).toBe(1)
expect(report.entries[1].voucher_number).toBe(2)
// Entry 1: sale invoice
expect(report.entries[0].total_debit).toBe(1250)
expect(report.entries[0].total_credit).toBe(1250)
expect(report.entries[0].lines).toHaveLength(3)
// Lines sorted by account number
expect(report.entries[0].lines[0].account_number).toBe('1510')
expect(report.entries[0].lines[1].account_number).toBe('2611')
expect(report.entries[0].lines[2].account_number).toBe('3001')
// Grand totals
expect(report.total_debit).toBe(2500)
expect(report.total_credit).toBe(2500)
})
it('includes reversed entries with correct status', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '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' },
{ id: 'e2', entry_date: '2024-01-16', voucher_number: 2, voucher_series: 'A', description: 'Reversal', source_type: 'manual', status: 'posted' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '5410', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
{ account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e2' },
],
error: null,
},
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries[0].status).toBe('reversed')
expect(report.entries[1].status).toBe('posted')
expect(report.total_entries).toBe(2)
})
it('resolves account names from chart_of_accounts', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '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' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '9999', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1' },
],
error: null,
},
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
],
error: null,
},
]
const report = await generateJournalRegister('user-1', 'period-1')
const line1930 = report.entries[0].lines.find((l) => l.account_number === '1930')!
expect(line1930.account_name).toBe('Företagskonto')
// Unknown account gets fallback name
const line9999 = report.entries[0].lines.find((l) => l.account_number === '9999')!
expect(line9999.account_name).toBe('Konto 9999')
})
it('defaults voucher_series to A when null', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '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' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1' },
],
error: null,
},
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries[0].voucher_series).toBe('A')
})
})
+345
View File
@@ -0,0 +1,345 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateSIEExport } from '../sie-export'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
const baseOptions = {
fiscal_period_id: 'period-1',
company_name: 'Test AB',
org_number: '556677-8899',
program_name: 'ERPBase',
}
describe('generateSIEExport', () => {
it('throws when fiscal period not found', async () => {
results = [
// 0: fiscal_periods.single() → null
{ data: null, error: null },
]
await expect(generateSIEExport('user-1', baseOptions))
.rejects.toThrow('Fiscal period not found')
})
it('generates correct header format', async () => {
results = [
// 0: fiscal_periods
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: chart_of_accounts (empty)
{ data: [], error: null },
// 2: journal_entries (empty)
{ data: [], error: null },
// 3: cost_centers (empty)
{ data: [], error: null },
// 4: projects (empty)
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const lines = output.split('\r\n')
expect(lines[0]).toBe('#FLAGGA 0')
expect(lines[1]).toBe('#FORMAT PC8')
expect(lines[2]).toBe('#SIETYP 4')
expect(lines[3]).toMatch(/^#PROGRAM "ERPBase" "1\.0"$/)
expect(lines[4]).toMatch(/^#GEN \d{8}$/)
expect(lines[5]).toBe('#ORGNR 556677-8899')
expect(lines[6]).toBe('#FNAMN "Test AB"')
expect(lines[7]).toBe('#RAR 0 20240101 20241231')
})
it('omits #ORGNR when org_number is null', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', {
...baseOptions,
org_number: undefined,
})
expect(output).not.toContain('#ORGNR')
})
it('generates #KONTO and #SRU for accounts', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', sru_code: '7301', is_active: true },
{ account_number: '3001', account_name: 'Försäljning', sru_code: null, is_active: true },
],
error: null,
},
// 2: journal_entries (empty)
{ data: [], error: null },
// 3: cost_centers
{ data: [], error: null },
// 4: projects
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#KONTO 1930 "Företagskonto"')
expect(output).toContain('#SRU 1930 7301')
expect(output).toContain('#KONTO 3001 "Försäljning"')
// No SRU for 3001 since sru_code is null
expect(output).not.toContain('#SRU 3001')
})
it('generates #VER and #TRANS for journal entries', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: accounts
{ data: [], error: null },
// 2: journal_entries with lines
{
data: [
{
id: 'e1',
entry_date: '2024-03-15',
voucher_number: 1,
voucher_series: 'A',
description: 'Sale invoice',
status: 'posted',
lines: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', cost_center: null, project: null },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
// 3: cost_centers
{ data: [], error: null },
// 4: projects
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240315 "Sale invoice"')
expect(output).toContain('{')
expect(output).toContain('\t#TRANS 1510 {} 1250.00 20240315')
expect(output).toContain('\t#TRANS 3001 {} -1000.00 20240315 "Revenue"')
expect(output).toContain('\t#TRANS 2611 {} -250.00 20240315')
expect(output).toContain('}')
})
it('generates #DIM and #OBJEKT for dimensions', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
// 3: cost_centers
{
data: [
{ code: 'CC1', name: 'Avdelning 1', is_active: true },
],
error: null,
},
// 4: projects
{
data: [
{ code: 'P001', name: 'Projekt Alpha', is_active: true },
],
error: null,
},
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#DIM 1 "Kostnadsställe"')
expect(output).toContain('#DIM 6 "Projekt"')
expect(output).toContain('#OBJEKT 1 "CC1" "Avdelning 1"')
expect(output).toContain('#OBJEKT 6 "P001" "Projekt Alpha"')
})
it('includes dimension objects in #TRANS lines', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{
data: [
{
id: 'e1',
entry_date: '2024-03-15',
voucher_number: 1,
voucher_series: 'A',
description: 'With dimensions',
status: 'posted',
lines: [
{ account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, cost_center: 'CC1', project: 'P001' },
{ account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
{ data: [{ code: 'CC1', name: 'Avdelning 1', is_active: true }], error: null },
{ data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('\t#TRANS 5010 {1 "CC1" 6 "P001"} 8000.00 20240315')
expect(output).toContain('\t#TRANS 1930 {} -8000.00 20240315')
})
it('generates #UB for class 1-2 and #RES for class 3-8', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{
data: [
{
id: 'e1',
entry_date: '2024-01-15',
voucher_number: 1,
voucher_series: 'A',
description: 'Sale',
status: 'posted',
lines: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, cost_center: null, project: null },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
// Account 1510 (class 1) → #UB, balance = 1250 - 0 = 1250
expect(output).toContain('#UB 0 1510 1250.00')
// Account 2611 (class 2) → #UB, balance = 0 - 250 = -250
expect(output).toContain('#UB 0 2611 -250.00')
// Account 3001 (class 3) → #RES, balance = 0 - 1000 = -1000
expect(output).toContain('#RES 0 3001 -1000.00')
})
it('escapes quotes in descriptions', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{
data: [
{
id: 'e1',
entry_date: '2024-01-15',
voucher_number: 1,
voucher_series: 'A',
description: 'Invoice for "consulting"',
status: 'posted',
lines: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240115 "Invoice for \\"consulting\\""')
})
it('uses \\r\\n line endings', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
// Every line should end with \r\n
expect(output).toContain('\r\n')
// Should not have bare \n (that isn't preceded by \r)
const lines = output.split('\r\n')
for (const line of lines.slice(0, -1)) {
expect(line).not.toContain('\n')
}
// File should end with \r\n
expect(output.endsWith('\r\n')).toBe(true)
})
it('produces no #VER lines when no entries exist', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).not.toContain('#VER')
expect(output).not.toContain('#TRANS')
})
it('produces no #DIM lines when no dimensions exist', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).not.toContain('#DIM')
expect(output).not.toContain('#OBJEKT')
})
})
@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateSupplierLedger } from '../supplier-ledger'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateSupplierLedger', () => {
it('returns empty report when no invoices found', async () => {
results = [
{ data: [], error: null },
]
const report = await generateSupplierLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.total_current).toBe(0)
expect(report.total_overdue).toBe(0)
expect(report.unpaid_count).toBe(0)
})
it('returns empty report on query error', async () => {
results = [
{ data: null, error: { message: 'DB error' } },
]
const report = await generateSupplierLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
it('places invoices in correct aging buckets', async () => {
// Reference date: 2024-06-15
const asOfDate = '2024-06-15'
results = [
{
data: [
// Current: due in the future (days overdue <= 0)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-06-20',
remaining_amount: 5000,
},
// 1-30 days overdue: due_date 2024-06-01 (14 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-06-01',
remaining_amount: 3000,
},
// 31-60 days overdue: due_date 2024-05-01 (45 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-05-01',
remaining_amount: 2000,
},
// 61-90 days overdue: due_date 2024-04-01 (75 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-04-01',
remaining_amount: 1500,
},
// 90+ days overdue: due_date 2024-02-01 (135 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-02-01',
remaining_amount: 1000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', asOfDate)
expect(report.entries).toHaveLength(1)
const entry = report.entries[0]
expect(entry.current).toBe(5000)
expect(entry.days_1_30).toBe(3000)
expect(entry.days_31_60).toBe(2000)
expect(entry.days_61_90).toBe(1500)
expect(entry.days_90_plus).toBe(1000)
expect(entry.total_outstanding).toBe(12500)
})
it('groups by supplier and uses fallback name for missing supplier', async () => {
results = [
{
data: [
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-07-01',
remaining_amount: 5000,
},
{
supplier_id: 'sup-2',
supplier: null,
due_date: '2024-07-01',
remaining_amount: 3000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.entries).toHaveLength(2)
const names = report.entries.map(e => e.supplier_name)
expect(names).toContain('Leverantör A')
expect(names).toContain('Okänd leverantör')
})
it('sorts entries by outstanding descending', async () => {
results = [
{
data: [
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Small' },
due_date: '2024-07-01',
remaining_amount: 1000,
},
{
supplier_id: 'sup-2',
supplier: { id: 'sup-2', name: 'Large' },
due_date: '2024-07-01',
remaining_amount: 10000,
},
{
supplier_id: 'sup-3',
supplier: { id: 'sup-3', name: 'Medium' },
due_date: '2024-07-01',
remaining_amount: 5000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.entries[0].supplier_name).toBe('Large')
expect(report.entries[1].supplier_name).toBe('Medium')
expect(report.entries[2].supplier_name).toBe('Small')
})
it('calculates grand totals correctly', async () => {
results = [
{
data: [
// Supplier A: current 5000
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'A' },
due_date: '2024-07-01',
remaining_amount: 5000,
},
// Supplier B: 1-30 days overdue 3000
{
supplier_id: 'sup-2',
supplier: { id: 'sup-2', name: 'B' },
due_date: '2024-06-01',
remaining_amount: 3000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.total_outstanding).toBe(8000)
expect(report.total_current).toBe(5000)
expect(report.total_overdue).toBe(3000) // outstanding - current
expect(report.unpaid_count).toBe(2)
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Test' },
due_date: '2024-07-01',
remaining_amount: 33.33,
},
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Test' },
due_date: '2024-07-02',
remaining_amount: 33.33,
},
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Test' },
due_date: '2024-07-03',
remaining_amount: 33.34,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.total_outstanding).toBe(100)
expect(report.total_current).toBe(100)
})
})
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateReconciliation } from '../supplier-reconciliation'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateReconciliation', () => {
it('returns reconciled when supplier total matches account 2440 balance', async () => {
results = [
// 0: supplier_invoices
{
data: [
{ remaining_amount: 5000 },
{ remaining_amount: 3000 },
],
error: null,
},
// 1: journal_entry_lines for account 2440
{
data: [
{ debit_amount: 0, credit_amount: 10000, journal_entry_id: 'e1' },
{ debit_amount: 2000, credit_amount: 0, journal_entry_id: 'e2' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
// Supplier total: 5000 + 3000 = 8000
expect(result.supplier_ledger_total).toBe(8000)
// Account 2440 (credit-normal): credits - debits = 10000 - 2000 = 8000
expect(result.account_2440_balance).toBe(8000)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('detects mismatch when difference != 0', async () => {
results = [
// 0: supplier_invoices — total 5000
{
data: [
{ remaining_amount: 5000 },
],
error: null,
},
// 1: journal_entry_lines — balance 7000
{
data: [
{ debit_amount: 0, credit_amount: 7000, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(5000)
expect(result.account_2440_balance).toBe(7000)
expect(result.difference).toBe(-2000)
expect(result.is_reconciled).toBe(false)
})
it('returns reconciled when both are zero/empty', async () => {
results = [
{ data: [], error: null },
{ data: [], error: null },
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(0)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('handles null invoice data gracefully', async () => {
results = [
{ data: null, error: null },
{
data: [
{ debit_amount: 0, credit_amount: 3000, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(3000)
expect(result.difference).toBe(-3000)
expect(result.is_reconciled).toBe(false)
})
it('computes credit-normal balance for account 2440 (liability)', async () => {
results = [
{ data: [], error: null },
{
data: [
{ debit_amount: 0, credit_amount: 15000, journal_entry_id: 'e1' },
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'e2' },
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'e3' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
// Balance = credits - debits = 15000 - 5000 - 3000 = 7000
expect(result.account_2440_balance).toBe(7000)
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{ remaining_amount: 33.33 },
{ remaining_amount: 33.34 },
],
error: null,
},
{
data: [
{ debit_amount: 0, credit_amount: 66.67, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(66.67)
expect(result.account_2440_balance).toBe(66.67)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
})
+315
View File
@@ -0,0 +1,315 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue with rpc support
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateTrialBalance } from '../trial-balance'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateTrialBalance', () => {
it('returns rows directly from RPC when successful', async () => {
results = [
// 0: rpc('generate_trial_balance')
{
data: [
{
account_number: '1930',
account_name: 'Företagskonto',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 5000,
period_credit: 0,
closing_debit: 5000,
closing_credit: 0,
},
{
account_number: '3001',
account_name: 'Försäljning 25%',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 5000,
closing_debit: 0,
closing_credit: 5000,
},
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(5000)
expect(result.totalCredit).toBe(5000)
expect(result.isBalanced).toBe(true)
})
it('falls back to manual aggregation when RPC returns error', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'function not found' } },
// 1: manual — journal_entries
{
data: [{ id: 'e1' }],
error: null,
},
// 2: manual — journal_entry_lines
{
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
error: null,
},
// 3: manual — chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(1000)
expect(result.isBalanced).toBe(true)
})
it('falls back to manual aggregation when RPC returns null data', async () => {
results = [
// 0: rpc succeeds but data is null
{ data: null, error: null },
// 1: manual — journal_entries (empty)
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.isBalanced).toBe(true)
})
it('returns empty report when no entries exist (manual path)', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: manual — journal_entries empty
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.totalDebit).toBe(0)
expect(result.totalCredit).toBe(0)
expect(result.isBalanced).toBe(true)
})
it('aggregates lines by account and sorts by account_number', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }, { id: 'e2' }], error: null },
// 2: journal_entry_lines — multiple lines per account
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
{ account_number: '1930', debit_amount: 300, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 200 },
{ account_number: '1930', debit_amount: 450, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toHaveLength(2)
// Sorted by account number
expect(result.rows[0].account_number).toBe('1930')
expect(result.rows[1].account_number).toBe('3001')
// Aggregated correctly
expect(result.rows[0].closing_debit).toBe(750)
expect(result.rows[0].closing_credit).toBe(0)
expect(result.rows[1].closing_debit).toBe(0)
expect(result.rows[1].closing_credit).toBe(700)
})
it('falls back to "Konto {number}" when account not in chart_of_accounts', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines
{
data: [
{ account_number: '9999', debit_amount: 100, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts — empty
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows[0].account_name).toBe('Konto 9999')
})
it('derives account_class from first digit when account not in chart', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines
{
data: [
{ account_number: '5410', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts — empty
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows[0].account_class).toBe(5)
})
it('uses Math.round for monetary precision', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines — values that cause floating point issues
{
data: [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
{ account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows[0].closing_debit).toBe(100)
expect(result.totalDebit).toBe(100)
expect(result.totalCredit).toBe(100)
expect(result.isBalanced).toBe(true)
})
it('detects unbalanced entries (isBalanced=false)', async () => {
results = [
// 0: rpc succeeds with unbalanced data
{
data: [
{
account_number: '1930',
account_name: 'Bank',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 1000,
period_credit: 0,
closing_debit: 1000,
closing_credit: 0,
},
{
account_number: '3001',
account_name: 'Revenue',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 999,
closing_debit: 0,
closing_credit: 999,
},
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(999)
expect(result.isBalanced).toBe(false)
})
it('returns empty when entries query errors (manual path)', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries query errors
{ data: null, error: { message: 'DB error' } },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.isBalanced).toBe(true)
})
})
@@ -0,0 +1,430 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
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']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import {
calculatePeriodDates,
formatPeriodLabel,
getVatDeclarationSummary,
calculateVatDeclaration,
calculateVatDeclarationFromTaxCodes,
} from '../vat-declaration'
import type { VatDeclaration } from '@/types'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
// ============================================================
// Pure function tests — no mocks needed
// ============================================================
describe('calculatePeriodDates', () => {
it('returns correct dates for monthly period', () => {
const { start, end } = calculatePeriodDates('monthly', 2024, 1)
expect(start).toBe('2024-01-01')
expect(end).toBe('2024-01-31')
})
it('returns correct dates for monthly period 12 (December)', () => {
const { start, end } = calculatePeriodDates('monthly', 2024, 12)
expect(start).toBe('2024-12-01')
expect(end).toBe('2024-12-31')
})
it('returns correct dates for quarterly period', () => {
const q1 = calculatePeriodDates('quarterly', 2024, 1)
expect(q1.start).toBe('2024-01-01')
expect(q1.end).toBe('2024-03-31')
const q4 = calculatePeriodDates('quarterly', 2024, 4)
expect(q4.start).toBe('2024-10-01')
expect(q4.end).toBe('2024-12-31')
})
it('returns full year for yearly period', () => {
const { start, end } = calculatePeriodDates('yearly', 2024, 1)
expect(start).toBe('2024-01-01')
expect(end).toBe('2024-12-31')
})
})
describe('formatPeriodLabel', () => {
it('formats monthly period', () => {
expect(formatPeriodLabel('monthly', 2024, 1)).toBe('Januari 2024')
expect(formatPeriodLabel('monthly', 2024, 6)).toBe('Juni 2024')
expect(formatPeriodLabel('monthly', 2024, 12)).toBe('December 2024')
})
it('formats quarterly period', () => {
expect(formatPeriodLabel('quarterly', 2024, 3)).toBe('Kvartal 3 2024')
})
it('formats yearly period', () => {
expect(formatPeriodLabel('yearly', 2024, 1)).toBe('Helår 2024')
})
})
describe('getVatDeclarationSummary', () => {
it('calculates totals and detects payment', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 2500,
ruta06: 0,
ruta07: 0,
ruta10: 10000,
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 1000,
ruta49: 1500,
},
invoiceCount: 5,
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 },
},
}
const summary = getVatDeclarationSummary(declaration)
expect(summary.totalOutputVat).toBe(2500)
expect(summary.totalInputVat).toBe(1000)
expect(summary.vatToPay).toBe(1500)
expect(summary.isRefund).toBe(false)
})
it('identifies refund when ruta49 is negative', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 500,
ruta06: 0,
ruta07: 0,
ruta10: 2000,
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 3000,
ruta49: -2500,
},
invoiceCount: 1,
transactionCount: 20,
breakdown: {
invoices: { ruta05: 500, ruta06: 0, ruta07: 0, ruta10: 2000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
transactions: { ruta48: 3000 },
receipts: { ruta48: 0 },
},
}
const summary = getVatDeclarationSummary(declaration)
expect(summary.isRefund).toBe(true)
expect(summary.vatToPay).toBe(-2500)
})
})
// ============================================================
// Async tests — require Supabase mocks
// ============================================================
describe('calculateVatDeclaration', () => {
it('returns all zeros when no data exists', async () => {
results = [
// 0: invoices
{ data: [], 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(0)
expect(result.rutor.ruta06).toBe(0)
expect(result.rutor.ruta07).toBe(0)
expect(result.rutor.ruta48).toBe(0)
expect(result.rutor.ruta49).toBe(0)
expect(result.invoiceCount).toBe(0)
expect(result.transactionCount).toBe(0)
})
it('maps invoice VAT to correct rutor by moms_ruta', 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 },
],
error: null,
},
// 1: transactions (none)
{ data: [], error: null },
// 2: receipts (none)
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 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.ruta39).toBe(8000)
expect(result.rutor.ruta40).toBe(12000)
})
it('prefers subtotal_sek/vat_amount_sek for foreign currency invoices', 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 },
],
error: null,
},
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], 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)
})
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 },
],
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)
})
it('user tax codes override system codes', 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' },
},
],
error: null,
},
]
const result = await calculateVatDeclarationFromTaxCodes('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)
})
it('returns all zeros when no lines have tax codes', 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)
expect(result.rutor.ruta49).toBe(0)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { createClient } from '@/lib/supabase/server'
export interface ARInvoiceDetail {
invoice_id: string
invoice_number: string
invoice_date: string
due_date: string
total: number
paid_amount: number
outstanding: number
days_overdue: number
currency: string
}
export interface ARLedgerEntry {
customer_id: string
customer_name: string
invoices: ARInvoiceDetail[]
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}
export interface ARLedgerReport {
entries: ARLedgerEntry[]
total_outstanding: number
total_current: number
total_overdue: number
unpaid_count: number
}
/**
* Generate AR ledger (kundreskontra) with aging analysis.
* BFL 5 kap. 4 § — sidoordnad bokföring: outstanding customer invoices with aging.
*/
export async function generateARLedger(
userId: string,
asOfDate?: string
): Promise<ARLedgerReport> {
const supabase = await createClient()
const refDate = asOfDate ? new Date(asOfDate) : new Date()
// Fetch all unpaid/sent/overdue invoices with customer info
const { data: invoices, error } = await supabase
.from('invoices')
.select('*, customer:customers(id, name)')
.eq('user_id', userId)
.in('status', ['sent', 'overdue'])
if (error || !invoices) {
return {
entries: [],
total_outstanding: 0,
total_current: 0,
total_overdue: 0,
unpaid_count: 0,
}
}
// Group by customer and calculate aging
const byCustomer = new Map<string, ARLedgerEntry>()
for (const inv of invoices) {
const customerId = inv.customer_id
const customerName = inv.customer?.name || 'Okänd kund'
if (!byCustomer.has(customerId)) {
byCustomer.set(customerId, {
customer_id: customerId,
customer_name: customerName,
invoices: [],
current: 0,
days_1_30: 0,
days_31_60: 0,
days_61_90: 0,
days_90_plus: 0,
total_outstanding: 0,
})
}
const entry = byCustomer.get(customerId)!
const dueDate = new Date(inv.due_date)
const daysOverdue = Math.floor((refDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
const paidAmount = Number(inv.paid_amount) || 0
const total = Number(inv.total) || 0
const outstanding = Math.round((total - paidAmount) * 100) / 100
// Add invoice detail
entry.invoices.push({
invoice_id: inv.id,
invoice_number: inv.invoice_number || '',
invoice_date: inv.invoice_date || '',
due_date: inv.due_date,
total,
paid_amount: paidAmount,
outstanding,
days_overdue: Math.max(0, daysOverdue),
currency: inv.currency || 'SEK',
})
// Bucket by aging
if (daysOverdue <= 0) {
entry.current += outstanding
} else if (daysOverdue <= 30) {
entry.days_1_30 += outstanding
} else if (daysOverdue <= 60) {
entry.days_31_60 += outstanding
} else if (daysOverdue <= 90) {
entry.days_61_90 += outstanding
} else {
entry.days_90_plus += outstanding
}
entry.total_outstanding += outstanding
}
// Round all amounts and sort invoices within each customer
const entries = Array.from(byCustomer.values()).map((entry) => ({
...entry,
invoices: entry.invoices.sort((a, b) => a.due_date.localeCompare(b.due_date)),
current: Math.round(entry.current * 100) / 100,
days_1_30: Math.round(entry.days_1_30 * 100) / 100,
days_31_60: Math.round(entry.days_31_60 * 100) / 100,
days_61_90: Math.round(entry.days_61_90 * 100) / 100,
days_90_plus: Math.round(entry.days_90_plus * 100) / 100,
total_outstanding: Math.round(entry.total_outstanding * 100) / 100,
}))
// Sort by total outstanding descending
entries.sort((a, b) => b.total_outstanding - a.total_outstanding)
const total_outstanding = entries.reduce((sum, e) => sum + e.total_outstanding, 0)
const total_current = entries.reduce((sum, e) => sum + e.current, 0)
const total_overdue = total_outstanding - total_current
return {
entries,
total_outstanding: Math.round(total_outstanding * 100) / 100,
total_current: Math.round(total_current * 100) / 100,
total_overdue: Math.round(total_overdue * 100) / 100,
unpaid_count: invoices.length,
}
}
+53
View File
@@ -0,0 +1,53 @@
import { createClient } from '@/lib/supabase/server'
export interface ARReconciliationResult {
ar_ledger_total: number
account_1510_balance: number
difference: number
is_reconciled: boolean
}
/**
* Compare sum of open customer invoices against account 1510 balance.
* Account 1510 is debit-normal (asset): balance = debits - credits.
*/
export async function generateARReconciliation(
userId: string,
_periodId: string
): Promise<ARReconciliationResult> {
const supabase = await createClient()
// Get total outstanding from customer invoices
const { data: invoices } = await supabase
.from('invoices')
.select('total, paid_amount')
.eq('user_id', userId)
.in('status', ['sent', 'overdue'])
const arLedgerTotal = (invoices || [])
.reduce((sum, inv) => sum + ((Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)), 0)
// Get account 1510 balance from journal entry lines
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entry_id')
.eq('account_number', '1510')
// Account 1510 is an asset: debit normal balance
// Balance = debits - credits
let account1510Balance = 0
if (journalLines) {
for (const line of journalLines) {
account1510Balance += (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
}
}
const difference = Math.round((arLedgerTotal - account1510Balance) * 100) / 100
return {
ar_ledger_total: Math.round(arLedgerTotal * 100) / 100,
account_1510_balance: Math.round(account1510Balance * 100) / 100,
difference,
is_reconciled: Math.abs(difference) < 0.01,
}
}
+183
View File
@@ -0,0 +1,183 @@
import { createClient } from '@/lib/supabase/server'
export interface GeneralLedgerLine {
date: string
voucher_series: string
voucher_number: number
description: string
source_type: string
debit: number
credit: number
balance: number
}
export interface GeneralLedgerAccount {
account_number: string
account_name: string
opening_balance: number
lines: GeneralLedgerLine[]
closing_balance: number
total_debit: number
total_credit: number
}
export interface GeneralLedgerReport {
accounts: GeneralLedgerAccount[]
period: { start: string; end: string }
}
/**
* Generate general ledger (huvudbok) for a fiscal period.
* BFL 5 kap. 1 § — systematisk ordning: all transactions grouped by account.
*/
export async function generateGeneralLedger(
userId: string,
periodId: string,
accountFrom?: string,
accountTo?: string
): Promise<GeneralLedgerReport> {
const supabase = await createClient()
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) {
return { accounts: [], period: { start: '', end: '' } }
}
// Fetch posted entries for this period
const { data: entries } = await supabase
.from('journal_entries')
.select('id, entry_date, voucher_number, voucher_series, description, source_type')
.eq('user_id', userId)
.eq('fiscal_period_id', periodId)
.in('status', ['posted', 'reversed'])
if (!entries || entries.length === 0) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
}
const entryIds = entries.map((e) => e.id)
const entryMap = new Map(entries.map((e) => [e.id, e]))
// Fetch lines for these entries
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry_id')
.in('journal_entry_id', entryIds)
if (!lines) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
}
// Fetch account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Compute opening balances: sum all posted lines from entries before this period
const { data: priorEntries } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.in('status', ['posted', 'reversed'])
.lt('entry_date', period.start_date)
const openingBalances = new Map<string, number>()
if (priorEntries && priorEntries.length > 0) {
const priorIds = priorEntries.map((e) => e.id)
const { data: priorLines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount')
.in('journal_entry_id', priorIds)
for (const line of priorLines || []) {
const current = openingBalances.get(line.account_number) || 0
openingBalances.set(
line.account_number,
current + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
)
}
}
// Group lines by account
const accountLines = new Map<string, GeneralLedgerLine[]>()
for (const line of lines) {
const entry = entryMap.get(line.journal_entry_id)
if (!entry) continue
const accNum = line.account_number
if (!accountLines.has(accNum)) {
accountLines.set(accNum, [])
}
accountLines.get(accNum)!.push({
date: entry.entry_date,
voucher_series: entry.voucher_series || 'A',
voucher_number: entry.voucher_number,
description: entry.description || '',
source_type: entry.source_type || '',
debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
balance: 0, // computed below
})
}
// Build account summaries
const result: GeneralLedgerAccount[] = []
for (const [accNum, accLines] of accountLines) {
// Apply optional account range filter
if (accountFrom && accNum < accountFrom) continue
if (accountTo && accNum > accountTo) continue
// Sort by date, then voucher number
accLines.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.voucher_number - b.voucher_number
})
const opening = Math.round((openingBalances.get(accNum) || 0) * 100) / 100
let runningBalance = opening
for (const line of accLines) {
runningBalance += line.debit - line.credit
line.balance = Math.round(runningBalance * 100) / 100
}
const totalDebit = accLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = accLines.reduce((sum, l) => sum + l.credit, 0)
result.push({
account_number: accNum,
account_name: accountNameMap.get(accNum) || `Konto ${accNum}`,
opening_balance: opening,
lines: accLines,
closing_balance: Math.round((opening + totalDebit - totalCredit) * 100) / 100,
total_debit: Math.round(totalDebit * 100) / 100,
total_credit: Math.round(totalCredit * 100) / 100,
})
}
// Sort by account number
result.sort((a, b) => a.account_number.localeCompare(b.account_number))
return {
accounts: result,
period: { start: period.start_date, end: period.end_date },
}
}
+131
View File
@@ -0,0 +1,131 @@
import { createClient } from '@/lib/supabase/server'
export interface JournalRegisterLine {
account_number: string
account_name: string
debit: number
credit: number
}
export interface JournalRegisterEntry {
voucher_series: string
voucher_number: number
date: string
description: string
source_type: string
status: string
lines: JournalRegisterLine[]
total_debit: number
total_credit: number
}
export interface JournalRegisterReport {
entries: JournalRegisterEntry[]
total_entries: number
total_debit: number
total_credit: number
period: { start: string; end: string }
}
/**
* Generate journal register (grundbok) for a fiscal period.
* BFL 5 kap. 1 § — registreringsordning: all vouchers in chronological registration order.
*/
export async function generateJournalRegister(
userId: string,
periodId: string
): Promise<JournalRegisterReport> {
const supabase = await createClient()
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) {
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: '', end: '' } }
}
// Fetch posted/reversed entries ordered by voucher series then number (registration order)
const { data: entries } = await supabase
.from('journal_entries')
.select('id, entry_date, voucher_number, voucher_series, description, source_type, status')
.eq('user_id', userId)
.eq('fiscal_period_id', periodId)
.in('status', ['posted', 'reversed'])
.order('voucher_series', { ascending: true })
.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 } }
}
const entryIds = entries.map((e) => e.id)
// Fetch lines for these entries
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry_id')
.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 accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Group lines by entry
const linesByEntry = new Map<string, JournalRegisterLine[]>()
for (const line of lines || []) {
if (!linesByEntry.has(line.journal_entry_id)) {
linesByEntry.set(line.journal_entry_id, [])
}
linesByEntry.get(line.journal_entry_id)!.push({
account_number: line.account_number,
account_name: accountNameMap.get(line.account_number) || `Konto ${line.account_number}`,
debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
})
}
// Build result
const result: JournalRegisterEntry[] = entries.map((entry) => {
const entryLines = linesByEntry.get(entry.id) || []
// Sort lines by account number within each entry
entryLines.sort((a, b) => a.account_number.localeCompare(b.account_number))
const totalDebit = entryLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = entryLines.reduce((sum, l) => sum + l.credit, 0)
return {
voucher_series: entry.voucher_series || 'A',
voucher_number: entry.voucher_number,
date: entry.entry_date,
description: entry.description || '',
source_type: entry.source_type || '',
status: entry.status,
lines: entryLines,
total_debit: Math.round(totalDebit * 100) / 100,
total_credit: Math.round(totalCredit * 100) / 100,
}
})
const grandTotalDebit = result.reduce((sum, e) => sum + e.total_debit, 0)
const grandTotalCredit = result.reduce((sum, e) => sum + e.total_credit, 0)
return {
entries: result,
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 },
}
}