From ba94f60d0629630599020bb9f86670c0d36209a7 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Fri, 20 Feb 2026 15:27:45 +0100 Subject: [PATCH] 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 --- .claude/skills/erp-api-route/SKILL.md | 117 +++ .claude/skills/supabase-migration/SKILL.md | 108 +++ .claude/skills/swedish-bookkeeping/SKILL.md | 150 ++++ .../references/bas-accounts.md | 71 ++ CLAUDE.md | 41 +- app/(dashboard)/bookkeeping/year-end/page.tsx | 5 +- app/(dashboard)/invoices/new/page.tsx | 51 +- app/(dashboard)/reports/page.tsx | 671 +++++++++++++++++- .../supplier-invoices/[id]/page.tsx | 3 +- .../supplier-invoices/new/page.tsx | 79 ++- app/api/reports/ar-ledger/route.ts | 32 + app/api/reports/general-ledger/route.ts | 26 + app/api/reports/journal-register/route.ts | 24 + components/bookkeeping/ChartOfAccounts.tsx | 3 +- components/bookkeeping/JournalEntryForm.tsx | 31 +- components/bookkeeping/JournalEntryList.tsx | 3 +- .../bookkeeping/JournalEntryReviewContent.tsx | 109 +++ components/invoices/InvoiceReviewContent.tsx | 137 ++++ .../SupplierInvoiceReviewContent.tsx | 171 +++++ components/ui/account-number.tsx | 115 +++ components/ui/confirmation-dialog.tsx | 84 +++ extensions/ne-bilaga/NEDeclarationView.tsx | 3 +- extensions/sru-export/SRUExportView.tsx | 3 +- .../__tests__/account-descriptions.test.ts | 46 ++ lib/bookkeeping/account-descriptions.ts | 332 +++++++++ lib/reports/__tests__/ar-ledger.test.ts | 249 +++++++ .../__tests__/ar-reconciliation.test.ts | 171 +++++ lib/reports/__tests__/balance-sheet.test.ts | 186 +++++ lib/reports/__tests__/general-ledger.test.ts | 261 +++++++ .../__tests__/income-statement.test.ts | 191 +++++ .../__tests__/journal-register.test.ts | 203 ++++++ lib/reports/__tests__/sie-export.test.ts | 345 +++++++++ lib/reports/__tests__/supplier-ledger.test.ts | 247 +++++++ .../__tests__/supplier-reconciliation.test.ts | 171 +++++ lib/reports/__tests__/trial-balance.test.ts | 315 ++++++++ lib/reports/__tests__/vat-declaration.test.ts | 430 +++++++++++ lib/reports/ar-ledger.ts | 146 ++++ lib/reports/ar-reconciliation.ts | 53 ++ lib/reports/general-ledger.ts | 183 +++++ lib/reports/journal-register.ts | 131 ++++ 40 files changed, 5633 insertions(+), 64 deletions(-) create mode 100644 .claude/skills/erp-api-route/SKILL.md create mode 100644 .claude/skills/supabase-migration/SKILL.md create mode 100644 .claude/skills/swedish-bookkeeping/SKILL.md create mode 100644 .claude/skills/swedish-bookkeeping/references/bas-accounts.md create mode 100644 app/api/reports/ar-ledger/route.ts create mode 100644 app/api/reports/general-ledger/route.ts create mode 100644 app/api/reports/journal-register/route.ts create mode 100644 components/bookkeeping/JournalEntryReviewContent.tsx create mode 100644 components/invoices/InvoiceReviewContent.tsx create mode 100644 components/suppliers/SupplierInvoiceReviewContent.tsx create mode 100644 components/ui/account-number.tsx create mode 100644 components/ui/confirmation-dialog.tsx create mode 100644 lib/bookkeeping/__tests__/account-descriptions.test.ts create mode 100644 lib/bookkeeping/account-descriptions.ts create mode 100644 lib/reports/__tests__/ar-ledger.test.ts create mode 100644 lib/reports/__tests__/ar-reconciliation.test.ts create mode 100644 lib/reports/__tests__/balance-sheet.test.ts create mode 100644 lib/reports/__tests__/general-ledger.test.ts create mode 100644 lib/reports/__tests__/income-statement.test.ts create mode 100644 lib/reports/__tests__/journal-register.test.ts create mode 100644 lib/reports/__tests__/sie-export.test.ts create mode 100644 lib/reports/__tests__/supplier-ledger.test.ts create mode 100644 lib/reports/__tests__/supplier-reconciliation.test.ts create mode 100644 lib/reports/__tests__/trial-balance.test.ts create mode 100644 lib/reports/__tests__/vat-declaration.test.ts create mode 100644 lib/reports/ar-ledger.ts create mode 100644 lib/reports/ar-reconciliation.ts create mode 100644 lib/reports/general-ledger.ts create mode 100644 lib/reports/journal-register.ts diff --git a/.claude/skills/erp-api-route/SKILL.md b/.claude/skills/erp-api-route/SKILL.md new file mode 100644 index 00000000..7afb5469 --- /dev/null +++ b/.claude/skills/erp-api-route/SKILL.md @@ -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 diff --git a/.claude/skills/supabase-migration/SKILL.md b/.claude/skills/supabase-migration/SKILL.md new file mode 100644 index 00000000..29ec0023 --- /dev/null +++ b/.claude/skills/supabase-migration/SKILL.md @@ -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 diff --git a/.claude/skills/swedish-bookkeeping/SKILL.md b/.claude/skills/swedish-bookkeeping/SKILL.md new file mode 100644 index 00000000..e12ecb5f --- /dev/null +++ b/.claude/skills/swedish-bookkeeping/SKILL.md @@ -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 { + 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`. diff --git a/.claude/skills/swedish-bookkeeping/references/bas-accounts.md b/.claude/skills/swedish-bookkeeping/references/bas-accounts.md new file mode 100644 index 00000000..9fd2a159 --- /dev/null +++ b/.claude/skills/swedish-bookkeeping/references/bas-accounts.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index e443bc74..ca8f6687 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index 1bf3ec76..c7266acb 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -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() { {preview.resultAccountSummary.map((account) => ( - {account.account_number} + {account.account_name} {formatAmount(account.amount)} kr @@ -480,7 +481,7 @@ export default function YearEndPage() { {preview.closingLines.map((line, i) => ( - {line.account_number} + {line.line_description} {line.debit_amount > 0 ? formatAmount(line.debit_amount) : ''} diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index 808c0cf9..35c67569 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -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(null) + const [showReview, setShowReview] = useState(false) + const [pendingData, setPendingData] = useState(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 */} + + {selectedCustomer && vatRules && ( + + + + )} ) } diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 76de10b4..7de96c9a 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -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() {

Rapporter

- Saldobalans, resultaträkning, balansräkning, momsdeklaration, NE-bilaga, SRU-export och SIE-export + Huvudbok, grundbok, kundreskontra, saldobalans, resultaträkning, balansräkning, momsdeklaration och mer

@@ -124,6 +125,18 @@ export default function ReportsPage() { SRU-export + + + Huvudbok + + + + Grundbok + + + + Kundreskontra + Lev.reskontra @@ -150,6 +163,15 @@ export default function ReportsPage() { + + + + + + + + + @@ -252,7 +274,7 @@ function TrialBalanceView({ periodId }: { periodId: string }) { {data.rows.map((row) => ( - {row.account_number} + {row.account_name} {row.period_debit > 0 ? formatAmount(row.period_debit) : ''} @@ -553,7 +575,7 @@ function ReportSectionTable({ {section.rows.map((row) => ( - {row.account_number} + {row.account_name} {negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr @@ -1074,7 +1096,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) { {reconciliation && ( - Avstämning mot konto 2440 + Avstämning mot
@@ -1083,7 +1105,7 @@ function SupplierLedgerView({ periodId }: { periodId: string }) { {formatAmount(reconciliation.supplier_ledger_total)} kr
- Konto 2440 saldo (huvudbok) + saldo (huvudbok) {formatAmount(reconciliation.account_2440_balance)} kr
@@ -1106,3 +1128,638 @@ function SupplierLedgerView({ periodId }: { periodId: string }) {
) } + +// --- 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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( + + + Laddar huvudbok... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.accounts.length === 0) { + return ( + + + Inga bokförda verifikationer i denna period. + + + ) + } + + return ( +
+ {/* Account range filter */} + + +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+
+
+ + {data.period.start && ( +

+ Period: {data.period.start} — {data.period.end} | {data.accounts.length} konton +

+ )} + + {data.accounts.map((account) => ( + + +
+ + + + + IB: {formatAmount(account.opening_balance)} kr + +
+
+ + + + + + + + + + + + + + {account.lines.map((line, i) => ( + + + + + + + + + ))} + + + + + + + + + +
Ver.nrDatumBeskrivningDebetKreditSaldo
+ {line.voucher_series}{line.voucher_number} + {line.date}{line.description} + {line.debit > 0 ? formatAmount(line.debit) : ''} + + {line.credit > 0 ? formatAmount(line.credit) : ''} + {formatAmount(line.balance)}
Summa / Utgående balans{formatAmount(account.total_debit)}{formatAmount(account.total_credit)}{formatAmount(account.closing_balance)}
+
+
+ ))} +
+ ) +} + +// --- 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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedEntries, setExpandedEntries] = useState>(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 ( + + + Laddar grundbok... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.entries.length === 0) { + return ( + + + Inga bokförda verifikationer i denna period. + + + ) + } + + return ( +
+ {data.period.start && ( +

+ Period: {data.period.start} — {data.period.end} | {data.total_entries} verifikationer +

+ )} + + + + Grundbok (registreringsordning) + + + + + + + + + + + + + + + + {data.entries.map((entry, index) => { + const isExpanded = expandedEntries.has(index) + const isReversed = entry.status === 'reversed' + + return ( + + toggleEntry(index)} + > + + + + + + + + + {isExpanded && entry.lines.map((line, lineIndex) => ( + + + + + + + + + + ))} + + ) + })} + + + + + + + + +
Ver.nrDatumBeskrivningTypDebetKredit
+ {isExpanded ? ( + + ) : ( + + )} + + {entry.voucher_series}{entry.voucher_number} + {entry.date} + {entry.description} + {isReversed && ( + Makulerad + )} + {entry.source_type}{formatAmount(entry.total_debit)}{formatAmount(entry.total_credit)}
{line.account_name} + {line.debit > 0 ? formatAmount(line.debit) : ''} + + {line.credit > 0 ? formatAmount(line.credit) : ''} +
Summa{formatAmount(data.total_debit)}{formatAmount(data.total_credit)}
+
+
+
+ ) +} + +// --- 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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [expandedCustomers, setExpandedCustomers] = useState>(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 ( + + + Laddar kundreskontra... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || !data.ledger) { + return ( + + + Ingen data tillgänglig. + + + ) + } + + const { ledger, reconciliation } = data + + return ( +
+ {/* Summary cards */} +
+ + + Totalt utestående + + +

{formatAmount(ledger.total_outstanding)} kr

+

{ledger.unpaid_count} fakturor

+
+
+ + + Ej förfallet + + +

{formatAmount(ledger.total_current)} kr

+
+
+ + + Förfallet + + +

{formatAmount(ledger.total_overdue)} kr

+
+
+
+ + {/* Aging table with expandable invoice details */} + {ledger.entries.length > 0 && ( + + + Ålderfördelning per kund + + + + + + + + + + + + + + + + + {ledger.entries.map((entry) => { + const isExpanded = expandedCustomers.has(entry.customer_id) + return ( + + toggleCustomer(entry.customer_id)} + > + + + + + + + + + + {isExpanded && entry.invoices.map((inv) => ( + + + + + + + + + ))} + + ) + })} + + + + + + + + + + + + + +
KundEj förfallet1-30 dagar31-60 dagar61-90 dagar90+ dagarTotalt
+ {isExpanded ? ( + + ) : ( + + )} + {entry.customer_name}{entry.current > 0 ? formatAmount(entry.current) : ''}{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}{formatAmount(entry.total_outstanding)}
+ {inv.invoice_number} + {inv.invoice_date} + förfaller {inv.due_date} + + {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'} + + {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''} + + {formatAmount(inv.outstanding)} {inv.currency} +
Summa{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}{formatAmount(ledger.total_outstanding)}
+
+
+ )} + + {/* Reconciliation */} + {reconciliation && ( + + + Avstämning mot + + +
+
+ Kundreskontra (summa utestående) + {formatAmount(reconciliation.ar_ledger_total)} kr +
+
+ saldo (huvudbok) + {formatAmount(reconciliation.account_1510_balance)} kr +
+
+ Differens + + {formatAmount(reconciliation.difference)} kr + +
+
+ {reconciliation.is_reconciled ? ( + Avstämd + ) : ( + Ej avstämd - kontrollera bokföring + )} +
+
+
+
+ )} +
+ ) +} diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 4bc8bac2..d5575cb0 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -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() { {item.quantity} {item.unit} {formatAmount(item.unit_price)} - {item.account_number} + {Math.round(item.vat_rate * 100)}% {formatAmount(item.line_total)} {formatAmount(item.vat_amount)} diff --git a/app/(dashboard)/supplier-invoices/new/page.tsx b/app/(dashboard)/supplier-invoices/new/page.tsx index 04f5cde4..d45096c4 100644 --- a/app/(dashboard)/supplier-invoices/new/page.tsx +++ b/app/(dashboard)/supplier-invoices/new/page.tsx @@ -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([]) const [isSubmitting, setIsSubmitting] = useState(false) + const [showReview, setShowReview] = useState(false) + const [pendingData, setPendingData] = useState(null) const { register, control, handleSubmit, watch, setValue, formState: { errors } } = useForm({ 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 + + {pendingData && (() => { + const selectedSupplier = suppliers.find((s) => s.id === pendingData.supplier_id) + if (!selectedSupplier) return null + return ( + + + + ) + })()} ) } diff --git a/app/api/reports/ar-ledger/route.ts b/app/api/reports/ar-ledger/route.ts new file mode 100644 index 00000000..282d49ab --- /dev/null +++ b/app/api/reports/ar-ledger/route.ts @@ -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, + }, + }) +} diff --git a/app/api/reports/general-ledger/route.ts b/app/api/reports/general-ledger/route.ts new file mode 100644 index 00000000..1b410f8c --- /dev/null +++ b/app/api/reports/general-ledger/route.ts @@ -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 }) +} diff --git a/app/api/reports/journal-register/route.ts b/app/api/reports/journal-register/route.ts new file mode 100644 index 00000000..fda018ba --- /dev/null +++ b/app/api/reports/journal-register/route.ts @@ -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 }) +} diff --git a/components/bookkeeping/ChartOfAccounts.tsx b/components/bookkeeping/ChartOfAccounts.tsx index f03cb8e6..05418c4b 100644 --- a/components/bookkeeping/ChartOfAccounts.tsx +++ b/components/bookkeeping/ChartOfAccounts.tsx @@ -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 = { @@ -150,7 +151,7 @@ export default function ChartOfAccounts() { !account.is_active ? 'opacity-50' : '' }`} > - {account.account_number} + {account.account_name} {editingSRU === account.id ? ( diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 7b862241..ffb0c622 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -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) {
+ + + p.id === selectedPeriod)?.name || ''} + entryDate={entryDate} + description={description} + lines={lines} + totalDebit={totalDebit} + totalCredit={totalCredit} + /> +
) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 1ef9acbf..da3a6de6 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -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) => ( - {line.account_number} + {line.line_description || ''} diff --git a/components/bookkeeping/JournalEntryReviewContent.tsx b/components/bookkeeping/JournalEntryReviewContent.tsx new file mode 100644 index 00000000..9332555b --- /dev/null +++ b/components/bookkeeping/JournalEntryReviewContent.tsx @@ -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 ( +
+ {/* Header info */} +
+
+
+ Räkenskapsår +

{periodName}

+
+
+ Datum +

{entryDate}

+
+
+
+ Beskrivning +

{description}

+
+
+ + {/* Balance status */} +
+ + + Debet = Kredit + +
+ + {/* Debit/Credit table */} + + + + + + + + + + + {activeLines.map((line, index) => ( + + + + + + + ))} + + + + + + + + +
KontoBeskrivningDebetKredit
+ + + {line.line_description || ''} + + {parseFloat(line.debit_amount) > 0 + ? formatAmount(parseFloat(line.debit_amount)) + : ''} + + {parseFloat(line.credit_amount) > 0 + ? formatAmount(parseFloat(line.credit_amount)) + : ''} +
Summa{formatAmount(totalDebit)}{formatAmount(totalCredit)}
+
+ ) +} diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx new file mode 100644 index 00000000..7da82d14 --- /dev/null +++ b/components/invoices/InvoiceReviewContent.tsx @@ -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 = { + individual: 'Privatperson', + swedish_business: 'Svenskt företag', + eu_business: 'EU-företag', + non_eu_business: 'Utanför EU', + } + + return ( +
+ {/* Customer info */} +
+
+

{customer.name}

+

{customer.email}

+
+ + {customerTypeLabel[customer.customer_type] || customer.customer_type} + +
+ + {/* VAT treatment */} + + {getVatTreatmentLabel(vatTreatment)} + + + {/* Dates */} +
+
+ Fakturadatum +

{invoiceDate}

+
+
+ Förfallodatum +

{dueDate}

+
+
+ + {/* Line items table */} + + + + + + + + + + + + {items.map((item, index) => ( + + + + + + + + ))} + +
BeskrivningAntalEnhetÀ-prisBelopp
{item.description}{item.quantity}{item.unit}{formatCurrency(item.unit_price, currency)} + {formatCurrency(item.quantity * item.unit_price, currency)} +
+ + {/* Totals */} +
+
+ Delsumma + {formatCurrency(subtotal, currency)} +
+
+ Moms ({vatRate}%) + {formatCurrency(vatAmount, currency)} +
+ +
+ Totalt + {formatCurrency(total, currency)} +
+
+ + {/* References/notes */} + {(yourReference || ourReference || notes) && ( +
+ {yourReference &&

Er referens: {yourReference}

} + {ourReference &&

Vår referens: {ourReference}

} + {notes &&

Anteckning: {notes}

} +
+ )} +
+ ) +} diff --git a/components/suppliers/SupplierInvoiceReviewContent.tsx b/components/suppliers/SupplierInvoiceReviewContent.tsx new file mode 100644 index 00000000..c265f9c4 --- /dev/null +++ b/components/suppliers/SupplierInvoiceReviewContent.tsx @@ -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 = { + 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 ( +
+ {/* Supplier info */} +
+
+

{supplier.name}

+

Fakturanr: {invoiceNumber}

+
+ {reverseCharge && ( + + Omvänd skattskyldighet + + )} +
+ + {/* VAT + currency badges */} +
+ + {VAT_TREATMENT_LABELS[vatTreatment]} + + {currency !== 'SEK' && ( + + {currency} + {exchangeRate && ` (kurs ${exchangeRate})`} + + )} +
+ + {/* Dates */} +
+
+ Fakturadatum +

{invoiceDate}

+
+
+ Förfallodatum +

{dueDate}

+
+ {deliveryDate && ( +
+ Leveransdatum +

{deliveryDate}

+
+ )} +
+ + {/* Line items table */} + + + + + + + + + + + + + {items.map((item, index) => { + const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 100) / 100 + return ( + + + + + + + + + ) + })} + +
BeskrivningAntalÀ-prisKontoMomsBelopp
{item.description}{item.quantity} {item.unit}{formatAmount(item.unit_price)} + + {Math.round(item.vat_rate * 100)}%{formatAmount(lineTotal)}
+ + {/* Totals */} +
+
+ Netto (exkl. moms) + {formatAmount(subtotal)} kr +
+
+ Moms + {formatAmount(totalVat)} kr +
+ +
+ Totalt + {formatAmount(total)} kr +
+ {currency !== 'SEK' && exchangeRate && ( +
+ SEK-belopp (vid kurs {exchangeRate}) + {formatAmount(total * parseFloat(exchangeRate))} kr +
+ )} +
+ + {/* Payment reference */} + {paymentReference && ( +
+

Betalningsreferens: {paymentReference}

+
+ )} +
+ ) +} diff --git a/components/ui/account-number.tsx b/components/ui/account-number.tsx new file mode 100644 index 00000000..adc63dac --- /dev/null +++ b/components/ui/account-number.tsx @@ -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 = { + asset: 'bg-green-500', + liability: 'bg-orange-500', + equity: 'bg-blue-500', + revenue: 'bg-purple-500', + expense: 'bg-red-500', +} + +const TYPE_LABELS: Record = { + 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 = ( + + {number} + + ) + + if (!desc) { + return ( + <> + {numberElement} + {showName && displayName && ( + {displayName} + )} + + ) + } + + return ( + + + + + + {number} + + {showName && displayName && ( + + {displayName} + + )} + + + +
+
+ + + {desc.classLabel} · {TYPE_LABELS[desc.type]} + +
+
+ {number} + {desc.name} +
+

+ {desc.explanation} +

+
+
+
+
+ ) +} diff --git a/components/ui/confirmation-dialog.tsx b/components/ui/confirmation-dialog.tsx new file mode 100644 index 00000000..a4544583 --- /dev/null +++ b/components/ui/confirmation-dialog.tsx @@ -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 ( + + + +
+
+ +
+
+ {title} + Granska uppgifterna innan du bekräftar +
+
+
+ +
+ {children} +
+ +
+
+ +

{warningText}

+
+ + + + + +
+
+
+ ) +} diff --git a/extensions/ne-bilaga/NEDeclarationView.tsx b/extensions/ne-bilaga/NEDeclarationView.tsx index 1aaba82d..63bd8e0a 100644 --- a/extensions/ne-bilaga/NEDeclarationView.tsx +++ b/extensions/ne-bilaga/NEDeclarationView.tsx @@ -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({ {accounts.map((acc) => ( - {acc.accountNumber} + {acc.accountName} {isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr diff --git a/extensions/sru-export/SRUExportView.tsx b/extensions/sru-export/SRUExportView.tsx index f4fdb6a5..ac37ddde 100644 --- a/extensions/sru-export/SRUExportView.tsx +++ b/extensions/sru-export/SRUExportView.tsx @@ -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({ {balance.accounts.map((acc) => ( - {acc.accountNumber} + {acc.accountName} {formatAmount(acc.amount)} kr diff --git a/lib/bookkeeping/__tests__/account-descriptions.test.ts b/lib/bookkeeping/__tests__/account-descriptions.test.ts new file mode 100644 index 00000000..dfbf0b09 --- /dev/null +++ b/lib/bookkeeping/__tests__/account-descriptions.test.ts @@ -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) + } + }) + }) +}) diff --git a/lib/bookkeeping/account-descriptions.ts b/lib/bookkeeping/account-descriptions.ts new file mode 100644 index 00000000..b5b2c97b --- /dev/null +++ b/lib/bookkeeping/account-descriptions.ts @@ -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 = { + // 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 } diff --git a/lib/reports/__tests__/ar-ledger.test.ts b/lib/reports/__tests__/ar-ledger.test.ts new file mode 100644 index 00000000..83a7feb0 --- /dev/null +++ b/lib/reports/__tests__/ar-ledger.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/lib/reports/__tests__/ar-reconciliation.test.ts b/lib/reports/__tests__/ar-reconciliation.test.ts new file mode 100644 index 00000000..2a2d6d7f --- /dev/null +++ b/lib/reports/__tests__/ar-reconciliation.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/reports/__tests__/balance-sheet.test.ts b/lib/reports/__tests__/balance-sheet.test.ts new file mode 100644 index 00000000..31888595 --- /dev/null +++ b/lib/reports/__tests__/balance-sheet.test.ts @@ -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 { + 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) + }) +}) diff --git a/lib/reports/__tests__/general-ledger.test.ts b/lib/reports/__tests__/general-ledger.test.ts new file mode 100644 index 00000000..f521bd21 --- /dev/null +++ b/lib/reports/__tests__/general-ledger.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/reports/__tests__/income-statement.test.ts b/lib/reports/__tests__/income-statement.test.ts new file mode 100644 index 00000000..11821c01 --- /dev/null +++ b/lib/reports/__tests__/income-statement.test.ts @@ -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 { + 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) + }) +}) diff --git a/lib/reports/__tests__/journal-register.test.ts b/lib/reports/__tests__/journal-register.test.ts new file mode 100644 index 00000000..0c59ad60 --- /dev/null +++ b/lib/reports/__tests__/journal-register.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/lib/reports/__tests__/sie-export.test.ts b/lib/reports/__tests__/sie-export.test.ts new file mode 100644 index 00000000..50ae342f --- /dev/null +++ b/lib/reports/__tests__/sie-export.test.ts @@ -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 = {} + 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') + }) +}) diff --git a/lib/reports/__tests__/supplier-ledger.test.ts b/lib/reports/__tests__/supplier-ledger.test.ts new file mode 100644 index 00000000..5dfccbd9 --- /dev/null +++ b/lib/reports/__tests__/supplier-ledger.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/reports/__tests__/supplier-reconciliation.test.ts b/lib/reports/__tests__/supplier-reconciliation.test.ts new file mode 100644 index 00000000..7a9c12b4 --- /dev/null +++ b/lib/reports/__tests__/supplier-reconciliation.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts new file mode 100644 index 00000000..3d531c25 --- /dev/null +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/reports/__tests__/vat-declaration.test.ts b/lib/reports/__tests__/vat-declaration.test.ts new file mode 100644 index 00000000..01cf2641 --- /dev/null +++ b/lib/reports/__tests__/vat-declaration.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/lib/reports/ar-ledger.ts b/lib/reports/ar-ledger.ts new file mode 100644 index 00000000..7875cb9f --- /dev/null +++ b/lib/reports/ar-ledger.ts @@ -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 { + 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() + + 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, + } +} diff --git a/lib/reports/ar-reconciliation.ts b/lib/reports/ar-reconciliation.ts new file mode 100644 index 00000000..b01ca329 --- /dev/null +++ b/lib/reports/ar-reconciliation.ts @@ -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 { + 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, + } +} diff --git a/lib/reports/general-ledger.ts b/lib/reports/general-ledger.ts new file mode 100644 index 00000000..1ac25a1b --- /dev/null +++ b/lib/reports/general-ledger.ts @@ -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 { + 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() + 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() + + 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() + + 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 }, + } +} diff --git a/lib/reports/journal-register.ts b/lib/reports/journal-register.ts new file mode 100644 index 00000000..1de1fbfb --- /dev/null +++ b/lib/reports/journal-register.ts @@ -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 { + 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() + for (const acc of accounts || []) { + accountNameMap.set(acc.account_number, acc.account_name) + } + + // Group lines by entry + const linesByEntry = new Map() + 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 }, + } +}