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:
co-authored by
Claude Opus 4.6
parent
885f362a29
commit
ba94f60d06
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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) : ''}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} · {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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user