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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-21 14:57:15 +01:00
co-authored by Claude Opus 4.6
parent f1d187c005
commit 91e2c1705a
106 changed files with 9950 additions and 1641 deletions
+7 -7
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'order']) {
for (const m of ['select', 'eq', 'in', 'lt', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -51,7 +51,7 @@ describe('generateGeneralLedger', () => {
it('returns empty report when no entries in period', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries (empty)
{ data: [], error: null },
]
@@ -64,7 +64,7 @@ describe('generateGeneralLedger', () => {
it('groups lines by account with correct totals and running balance', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries for this period
{
data: [
@@ -122,7 +122,7 @@ describe('generateGeneralLedger', () => {
it('computes opening balance from prior period entries', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2025-01-01', end_date: '2025-12-31' }, error: null },
{ data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null },
// 1: journal_entries for this period
{
data: [
@@ -168,7 +168,7 @@ describe('generateGeneralLedger', () => {
it('filters accounts by account_from and account_to', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries
{
data: [
@@ -200,7 +200,7 @@ describe('generateGeneralLedger', () => {
it('sorts lines within account by date then voucher number', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: entries out of order
{
data: [
@@ -236,7 +236,7 @@ describe('generateGeneralLedger', () => {
it('uses Math.round for monetary precision', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' },
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -49,7 +49,7 @@ describe('generateJournalRegister', () => {
it('returns empty report when no entries in period', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
]
@@ -62,7 +62,7 @@ describe('generateJournalRegister', () => {
it('produces entries in registration order with correct totals', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: journal_entries (already ordered by series/number)
{
data: [
@@ -117,7 +117,7 @@ describe('generateJournalRegister', () => {
it('includes reversed entries with correct status', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' },
@@ -146,7 +146,7 @@ describe('generateJournalRegister', () => {
it('resolves account names from chart_of_accounts', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' },
@@ -180,7 +180,7 @@ describe('generateJournalRegister', () => {
it('defaults voucher_series to A when null', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' },
@@ -24,7 +24,7 @@ describe('generateMonthlyBreakdown', () => {
it('returns empty months when no journal entries exist', async () => {
// First call: fiscal period
mockResult({
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
error: null,
})
@@ -45,7 +45,7 @@ describe('generateMonthlyBreakdown', () => {
eq: () => ({
single: () =>
Promise.resolve({
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
error: null,
}),
}),
@@ -88,7 +88,7 @@ describe('generateMonthlyBreakdown', () => {
eq: () => ({
single: () =>
Promise.resolve({
data: { start_date: '2024-01-01', end_date: '2024-03-31' },
data: { period_start: '2024-01-01', period_end: '2024-03-31' },
error: null,
}),
}),
@@ -167,7 +167,7 @@ describe('generateMonthlyBreakdown', () => {
eq: () => ({
single: () =>
Promise.resolve({
data: { start_date: '2024-01-01', end_date: '2024-01-31' },
data: { period_start: '2024-01-01', period_end: '2024-01-31' },
error: null,
}),
}),
+1 -1
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+1 -1
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
for (const m of ['select', 'eq', 'in', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+131 -193
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not']) {
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
@@ -32,7 +32,6 @@ import {
formatPeriodLabel,
getVatDeclarationSummary,
calculateVatDeclaration,
calculateVatDeclarationFromTaxCodes,
} from '../vat-declaration'
import type { VatDeclaration } from '@/types'
@@ -112,8 +111,8 @@ describe('getVatDeclarationSummary', () => {
transactionCount: 10,
breakdown: {
invoices: { ruta05: 2500, ruta06: 0, ruta07: 0, ruta10: 10000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
transactions: { ruta48: 800 },
receipts: { ruta48: 200 },
transactions: { ruta48: 1000 },
receipts: { ruta48: 0 },
},
}
@@ -155,17 +154,17 @@ describe('getVatDeclarationSummary', () => {
})
// ============================================================
// Async tests — require Supabase mocks
// Ledger-based VAT declaration tests
//
// Mock queue order per call:
// [0] fetchAllRows: journal_entry_lines (VAT-relevant accounts)
// [1] entry counts: journal_entries source_type
// ============================================================
describe('calculateVatDeclaration', () => {
it('returns all zeros when no data exists', async () => {
it('returns all zeros when no ledger lines exist', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
]
@@ -180,23 +179,20 @@ describe('calculateVatDeclaration', () => {
expect(result.transactionCount).toBe(0)
})
it('maps invoice VAT to correct rutor by moms_ruta', async () => {
it('sums output VAT from 2611/2621/2631 credit balances', async () => {
results = [
// 0: invoices — various moms_ruta values
{
data: [
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 5000, vat_amount: 600, moms_ruta: '06', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 3000, vat_amount: 180, moms_ruta: '07', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 8000, vat_amount: 0, moms_ruta: '39', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 12000, vat_amount: 0, moms_ruta: '40', subtotal_sek: null, vat_amount_sek: null },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
],
error: null,
},
// 1: transactions (none)
{ data: [], error: null },
// 2: receipts (none)
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
@@ -207,224 +203,166 @@ describe('calculateVatDeclaration', () => {
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
expect(result.invoiceCount).toBe(2)
})
it('sums input VAT from 2641 debit balance', async () => {
results = [
{
data: [
{ account_number: '2641', debit_amount: 250, credit_amount: 0 },
{ account_number: '2641', debit_amount: 120, credit_amount: 0 },
],
error: null,
},
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(370)
expect(result.transactionCount).toBe(2)
})
it('includes calculated input VAT (2645) from EU reverse charge in ruta48', async () => {
results = [
{
data: [
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
{ account_number: '2641', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Both 2641 and 2645 debit balances sum into ruta48
expect(result.rutor.ruta48).toBe(700)
})
it('maps EU/export revenue to ruta39/ruta40', async () => {
results = [
{
data: [
{ account_number: '3308', debit_amount: 0, credit_amount: 8000 },
{ account_number: '3305', debit_amount: 0, credit_amount: 12000 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta39).toBe(8000)
expect(result.rutor.ruta40).toBe(12000)
})
it('prefers subtotal_sek/vat_amount_sek for foreign currency invoices', async () => {
it('handles credit notes as net reduction on revenue/VAT accounts', async () => {
results = [
// 0: invoices — foreign currency with SEK conversion
{
data: [
{ subtotal: 1000, vat_amount: 250, moms_ruta: '05', subtotal_sek: 11000, vat_amount_sek: 2750 },
// Invoice: C2611 2500, C3001 10000
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
// Credit note reversal: D2611 625, D3001 2500
{ account_number: '2611', debit_amount: 625, credit_amount: 0 },
{ account_number: '3001', debit_amount: 2500, credit_amount: 0 },
],
error: null,
},
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Should use _sek values
expect(result.rutor.ruta05).toBe(2750)
expect(result.rutor.ruta10).toBe(11000)
})
it('defaults to ruta05 when moms_ruta is null but VAT > 0', async () => {
results = [
// 0: invoices — null moms_ruta with VAT
{
data: [
{ subtotal: 4000, vat_amount: 1000, moms_ruta: null, subtotal_sek: null, vat_amount_sek: null },
],
error: null,
},
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(1000)
expect(result.rutor.ruta10).toBe(4000)
})
it('calculates input VAT from transaction categories', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions — business expenses with categories
{
data: [
// 25% category: expense_software, amount -1250 → VAT = 1250 * 0.25/1.25 = 250
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
// 12% category: expense_travel, amount -1120 → VAT = 1120 * 0.12/1.12 = 120
{ amount: -1120, amount_sek: null, is_business: true, category: 'expense_travel' },
],
error: null,
},
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// 250 + 120 = 370
expect(result.rutor.ruta48).toBe(370)
})
it('sums VAT from confirmed receipts', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions
{ data: [], error: null },
// 2: receipts — confirmed with vat_amount
{
data: [
{ status: 'confirmed', vat_amount: 59.8 },
{ status: 'confirmed', vat_amount: 125 },
],
error: null,
},
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(184.8)
// Net: 2500 - 625 = 1875 output VAT, 10000 - 2500 = 7500 revenue
expect(result.rutor.ruta05).toBe(1875)
expect(result.rutor.ruta10).toBe(7500)
expect(result.invoiceCount).toBe(2)
})
it('calculates ruta49 as output minus input VAT', async () => {
results = [
// 0: invoices — 25% VAT
{
data: [
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
],
error: null,
},
// 1: transactions — 25% expense
{
data: [
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
],
error: null,
},
// 2: receipts
{
data: [
{ status: 'confirmed', vat_amount: 100 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2641', debit_amount: 350, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Output: 2500, Input: 250 + 100 = 350
expect(result.rutor.ruta49).toBe(2150)
})
})
describe('calculateVatDeclarationFromTaxCodes', () => {
it('maps journal lines to boxes via tax codes', async () => {
results = [
// 0: tax_codes
{
data: [
{
code: 'MP1',
user_id: null,
moms_basis_boxes: ['10'],
moms_tax_boxes: ['05'],
moms_input_boxes: [],
},
],
error: null,
},
// 1: journal_entry_lines with tax_code
{
data: [
{
tax_code: 'MP1',
debit_amount: 0,
credit_amount: 2500,
journal_entry_id: 'e1',
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
},
],
error: null,
},
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta10).toBe(2500)
expect(result.rutor.ruta48).toBe(350)
expect(result.rutor.ruta49).toBe(2150) // 2500 - 350
})
it('user tax codes override system codes', async () => {
it('detects refund when input VAT exceeds output VAT', async () => {
results = [
// 0: tax_codes — system and user with same code
{
data: [
{
code: 'MP1',
user_id: null,
moms_basis_boxes: ['10'],
moms_tax_boxes: ['05'],
moms_input_boxes: [],
},
{
code: 'MP1',
user_id: 'user-1',
moms_basis_boxes: ['11'],
moms_tax_boxes: ['06'],
moms_input_boxes: [],
},
],
error: null,
},
// 1: journal_entry_lines
{
data: [
{
tax_code: 'MP1',
debit_amount: 0,
credit_amount: 600,
journal_entry_id: 'e1',
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
},
{ account_number: '2611', debit_amount: 0, credit_amount: 500 },
{ account_number: '2641', debit_amount: 3000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// User override maps to ruta06/ruta11 instead of ruta05/ruta10
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta11).toBe(600)
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
})
it('returns all zeros when no lines have tax codes', async () => {
it('accepts accountingMethod parameter for backward compatibility', async () => {
results = [
// 0: tax_codes
{ data: [], error: null },
// 1: journal_entry_lines — empty
{ data: [], error: null },
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta48).toBe(0)
// Should not throw — parameter accepted but not used
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1, 'cash')
expect(result.rutor.ruta49).toBe(0)
})
it('handles all three VAT rates in a single period', async () => {
results = [
{
data: [
// 25% rate: 10,000 revenue, 2,500 VAT
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
// 12% rate: 5,000 revenue, 600 VAT
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
// 6% rate: 3,000 revenue, 180 VAT
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
// Input VAT from purchases
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'quarterly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta07).toBe(180)
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
expect(result.rutor.ruta48).toBe(1000)
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
expect(result.rutor.ruta49).toBe(2280)
})
})
+14 -10
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface GeneralLedgerLine {
date: string
@@ -41,7 +42,7 @@ export async function generateGeneralLedger(
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.single()
@@ -59,7 +60,7 @@ export async function generateGeneralLedger(
.in('status', ['posted', 'reversed'])
if (!entries || entries.length === 0) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
}
const entryIds = entries.map((e) => e.id)
@@ -72,17 +73,20 @@ export async function generateGeneralLedger(
.in('journal_entry_id', entryIds)
if (!lines) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
}
// Fetch account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
@@ -92,7 +96,7 @@ export async function generateGeneralLedger(
.select('id')
.eq('user_id', userId)
.in('status', ['posted', 'reversed'])
.lt('entry_date', period.start_date)
.lt('entry_date', period.period_start)
const openingBalances = new Map<string, number>()
@@ -178,6 +182,6 @@ export async function generateGeneralLedger(
return {
accounts: result,
period: { start: period.start_date, end: period.end_date },
period: { start: period.period_start, end: period.period_end },
}
}
+12 -8
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface JournalRegisterLine {
account_number: string
@@ -40,7 +41,7 @@ export async function generateJournalRegister(
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.select('period_start, period_end')
.eq('id', periodId)
.eq('user_id', userId)
.single()
@@ -60,7 +61,7 @@ export async function generateJournalRegister(
.order('voucher_number', { ascending: true })
if (!entries || entries.length === 0) {
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.start_date, end: period.end_date } }
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.period_start, end: period.period_end } }
}
const entryIds = entries.map((e) => e.id)
@@ -72,13 +73,16 @@ export async function generateJournalRegister(
.in('journal_entry_id', entryIds)
// Fetch account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
@@ -126,6 +130,6 @@ export async function generateJournalRegister(
total_entries: result.length,
total_debit: Math.round(grandTotalDebit * 100) / 100,
total_credit: Math.round(grandTotalCredit * 100) / 100,
period: { start: period.start_date, end: period.end_date },
period: { start: period.period_start, end: period.period_end },
}
}
+3 -3
View File
@@ -32,7 +32,7 @@ export async function generateMonthlyBreakdown(
// Get the fiscal period date range
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.select('period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
@@ -67,8 +67,8 @@ export async function generateMonthlyBreakdown(
const monthMap = new Map<number, { income: number; expenses: number }>()
// Initialize all months in the period range
const startDate = new Date(period.start_date)
const endDate = new Date(period.end_date)
const startDate = new Date(period.period_start)
const endDate = new Date(period.period_end)
const startMonth = startDate.getMonth()
const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12
+10 -6
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
/**
@@ -29,12 +30,15 @@ export async function generateSIEExport(
}
// Fetch all accounts
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
const accounts = await fetchAllRows(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('*')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
// Fetch all posted journal entries with lines
const { data: entries } = await supabase
+9 -5
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { TrialBalanceRow } from '@/types'
/**
@@ -83,13 +84,16 @@ async function generateTrialBalanceManual(
}
// Get account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('user_id', userId)
const accounts = await fetchAllRows<{ account_number: string; account_name: string; account_class: number }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('user_id', userId)
.range(from, to)
)
const accountMap = new Map<string, { name: string; class: number }>()
for (const acc of accounts || []) {
for (const acc of accounts) {
accountMap.set(acc.account_number, {
name: acc.account_name,
class: acc.account_class,
+120 -400
View File
@@ -1,25 +1,46 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
VatDeclaration,
VatDeclarationRutor,
VatPeriodType,
Invoice,
Transaction,
Receipt,
TaxCode,
AccountingMethod,
} from '@/types'
/**
* Calculate VAT declaration (Momsdeklaration) for a given period
* Calculate VAT declaration (Momsdeklaration) for a given period.
*
* Aggregates data from:
* - Invoices: Utgående moms (output VAT) based on moms_ruta
* - Transactions: Ingående moms (input VAT) from categorized expenses
* - Receipts: Ingående moms from confirmed receipts
* Reads directly from the general ledger — sums posted journal entry lines
* on 26xx (VAT) and 3xxx (revenue) accounts for the period. This makes the
* momsdeklaration a pure projection from the double-entry bookkeeping ledger.
*
* Returns VAT rutor according to Swedish tax authority format.
* The accounting method (accrual vs cash) is already reflected in when
* journal entries were created by the entry generators, so no separate
* filtering logic is needed here.
*/
/**
* Account-to-ruta mapping for the Swedish momsdeklaration.
*
* Output VAT (26xx): net credit balance feeds output VAT boxes.
* Input VAT (2641/2645): net debit balance feeds ruta 48.
* Revenue (3xxx): net credit balance feeds underlag (basis) boxes.
*/
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
'2611': { box: 'ruta05', side: 'credit' },
'2621': { box: 'ruta06', side: 'credit' },
'2631': { box: 'ruta07', side: 'credit' },
'2641': { box: 'ruta48', side: 'debit' },
'2645': { box: 'ruta48', side: 'debit' },
'3001': { box: 'ruta10', side: 'credit' },
'3002': { box: 'ruta11', side: 'credit' },
'3003': { box: 'ruta12', side: 'credit' },
'3305': { box: 'ruta40', side: 'credit' },
'3308': { box: 'ruta39', side: 'credit' },
}
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
/**
* Calculate period start and end dates
*/
@@ -79,298 +100,119 @@ function round(value: number): number {
}
/**
* Main function to calculate VAT declaration
* Calculate VAT declaration from the general ledger.
*
* Sums posted journal entry lines on 26xx and 3xxx accounts:
* - 2611/2621/2631 credit balance -> ruta 05/06/07 (output VAT)
* - 2641/2645 debit balance -> ruta 48 (input VAT)
* - 3001/3002/3003 credit balance -> ruta 10/11/12 (revenue basis)
* - 3308/3305 credit balance -> ruta 39/40 (EU/export)
* - ruta 49 = (05 + 06 + 07) - 48
*
* The accounting method parameter is accepted for backward compatibility
* but not used — the method is already baked into journal entry timing.
*/
export async function calculateVatDeclaration(
userId: string,
periodType: VatPeriodType,
year: number,
period: number
period: number,
_accountingMethod: AccountingMethod = 'accrual'
): Promise<VatDeclaration> {
const supabase = await createClient()
const { start, end } = calculatePeriodDates(periodType, year, period)
// Fetch invoices for the period
const { data: invoices, error: invoicesError } = await supabase
.from('invoices')
.select('*')
.eq('user_id', userId)
.gte('invoice_date', start)
.lte('invoice_date', end)
.in('status', ['sent', 'paid', 'overdue'])
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
const lines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select(`
account_number,
debit_amount,
credit_amount,
journal_entries!inner (user_id, entry_date, status)
`)
.in('account_number', VAT_ACCOUNTS)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
.range(from, to)
)
if (invoicesError) {
console.error('Error fetching invoices:', invoicesError)
// Aggregate debit/credit totals per account
const totals = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
t.debit += Number(line.debit_amount) || 0
t.credit += Number(line.credit_amount) || 0
totals.set(line.account_number, t)
}
// Fetch transactions with business expenses for the period
const { data: transactions, error: transactionsError } = await supabase
.from('transactions')
.select('*')
.eq('user_id', userId)
.gte('date', start)
.lte('date', end)
.eq('is_business', true)
.lt('amount', 0) // Expenses are negative
if (transactionsError) {
console.error('Error fetching transactions:', transactionsError)
}
// Fetch confirmed receipts for the period
const { data: receipts, error: receiptsError } = await supabase
.from('receipts')
.select('*')
.eq('user_id', userId)
.gte('receipt_date', start)
.lte('receipt_date', end)
.eq('status', 'confirmed')
if (receiptsError) {
console.error('Error fetching receipts:', receiptsError)
}
// Calculate invoice-based VAT (utgående moms)
const invoiceVat = calculateInvoiceVat(invoices as Invoice[] || [])
// Calculate input VAT from transactions
const transactionVat = calculateTransactionInputVat(transactions as Transaction[] || [])
// Calculate input VAT from receipts
const receiptVat = calculateReceiptInputVat(receipts as Receipt[] || [])
// Total ingående moms (input VAT to deduct)
const totalInputVat = round(transactionVat + receiptVat)
// Total utgående moms (output VAT to pay)
const totalOutputVat = round(invoiceVat.ruta05 + invoiceVat.ruta06 + invoiceVat.ruta07)
// Moms att betala/återfå (VAT to pay or receive back)
const vatToPay = round(totalOutputVat - totalInputVat)
// Map account balances to momsdeklaration boxes
const rutor: VatDeclarationRutor = {
ruta05: invoiceVat.ruta05,
ruta06: invoiceVat.ruta06,
ruta07: invoiceVat.ruta07,
ruta10: invoiceVat.ruta10,
ruta11: invoiceVat.ruta11,
ruta12: invoiceVat.ruta12,
ruta39: invoiceVat.ruta39,
ruta40: invoiceVat.ruta40,
ruta48: totalInputVat,
ruta49: vatToPay,
ruta05: 0, ruta06: 0, ruta07: 0,
ruta10: 0, ruta11: 0, ruta12: 0,
ruta39: 0, ruta40: 0,
ruta48: 0, ruta49: 0,
}
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
const t = totals.get(account)
if (!t) continue
const balance = mapping.side === 'credit'
? t.credit - t.debit
: t.debit - t.credit
rutor[mapping.box] = round(rutor[mapping.box] + balance)
}
rutor.ruta49 = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07 - rutor.ruta48)
// Count journal entries by source type for metadata
const { data: entryCounts } = await supabase
.from('journal_entries')
.select('source_type')
.eq('user_id', userId)
.eq('status', 'posted')
.gte('entry_date', start)
.lte('entry_date', end)
const invoiceSources = new Set([
'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note',
])
let invoiceCount = 0
let transactionCount = 0
for (const e of entryCounts || []) {
if (invoiceSources.has(e.source_type)) invoiceCount++
else if (e.source_type === 'bank_transaction') transactionCount++
}
return {
period: {
type: periodType,
year,
period,
start,
end,
},
period: { type: periodType, year, period, start, end },
rutor,
invoiceCount: (invoices || []).length,
transactionCount: (transactions || []).length,
invoiceCount,
transactionCount,
breakdown: {
invoices: {
ruta05: invoiceVat.ruta05,
ruta06: invoiceVat.ruta06,
ruta07: invoiceVat.ruta07,
ruta10: invoiceVat.ruta10,
ruta11: invoiceVat.ruta11,
ruta12: invoiceVat.ruta12,
ruta39: invoiceVat.ruta39,
ruta40: invoiceVat.ruta40,
},
transactions: {
ruta48: round(transactionVat),
},
receipts: {
ruta48: round(receiptVat),
ruta05: rutor.ruta05,
ruta06: rutor.ruta06,
ruta07: rutor.ruta07,
ruta10: rutor.ruta10,
ruta11: rutor.ruta11,
ruta12: rutor.ruta12,
ruta39: rutor.ruta39,
ruta40: rutor.ruta40,
},
transactions: { ruta48: rutor.ruta48 },
receipts: { ruta48: 0 },
},
}
}
/**
* Calculate VAT from invoices
*/
function calculateInvoiceVat(invoices: Invoice[]): {
ruta05: number
ruta06: number
ruta07: number
ruta10: number
ruta11: number
ruta12: number
ruta39: number
ruta40: number
} {
let ruta05 = 0 // Utgående moms 25%
let ruta06 = 0 // Utgående moms 12%
let ruta07 = 0 // Utgående moms 6%
let ruta10 = 0 // Underlag 25%
let ruta11 = 0 // Underlag 12%
let ruta12 = 0 // Underlag 6%
let ruta39 = 0 // EU tjänster
let ruta40 = 0 // Export
for (const invoice of invoices) {
// Use subtotal_sek if available (for foreign currency invoices), otherwise subtotal
const subtotal = invoice.subtotal_sek ?? invoice.subtotal
const vatAmount = invoice.vat_amount_sek ?? invoice.vat_amount
switch (invoice.moms_ruta) {
case '05':
// Standard 25% VAT
ruta05 += vatAmount
ruta10 += subtotal
break
case '06':
// Reduced 12% VAT
ruta06 += vatAmount
ruta11 += subtotal
break
case '07':
// Reduced 6% VAT
ruta07 += vatAmount
ruta12 += subtotal
break
case '39':
// EU reverse charge - no VAT charged, but report the value
ruta39 += subtotal
break
case '40':
// Export outside EU - no VAT charged, but report the value
ruta40 += subtotal
break
default:
// Default to 25% if moms_ruta is not set but there's VAT
if (vatAmount > 0) {
ruta05 += vatAmount
ruta10 += subtotal
}
}
}
return {
ruta05: round(ruta05),
ruta06: round(ruta06),
ruta07: round(ruta07),
ruta10: round(ruta10),
ruta11: round(ruta11),
ruta12: round(ruta12),
ruta39: round(ruta39),
ruta40: round(ruta40),
}
}
/**
* Calculate input VAT from business expense transactions
*
* For Swedish business expenses with 25% VAT, we can deduct the VAT.
* This is a simplified calculation - in reality, the journal entry
* would have the exact VAT amounts.
*/
function calculateTransactionInputVat(transactions: Transaction[]): number {
let inputVat = 0
for (const transaction of transactions) {
// Only process business expenses (amount is negative)
if (!transaction.is_business || transaction.amount >= 0) continue
// Use amount_sek if available, otherwise amount
const expenseAmount = Math.abs(transaction.amount_sek ?? transaction.amount)
// Estimate VAT based on category
// Most Swedish business expenses have 25% VAT
// Some categories might have reduced rates or no VAT
const vatRate = getVatRateForCategory(transaction.category)
if (vatRate > 0) {
// Extract VAT from total (VAT-inclusive) amount
// VAT = total * rate / (1 + rate)
const vat = (expenseAmount * vatRate) / (1 + vatRate)
inputVat += vat
}
}
return inputVat
}
/**
* Get VAT rate for expense category
*/
function getVatRateForCategory(category: string | null): number {
// Categories that typically have 25% VAT
const standard25Categories = [
'expense_equipment',
'expense_software',
'expense_office',
'expense_marketing',
'expense_professional_services',
'expense_education',
'expense_other',
]
// Categories with 12% VAT (e.g., food/restaurants, but only 50% deductible for representation)
const reduced12Categories = [
'expense_travel', // Hotels, some transport
]
// Categories with 6% VAT
const reduced6Categories: string[] = [
// Books, newspapers, etc.
]
// No VAT deduction
const noVatCategories = [
'private',
'uncategorized',
'income_services',
'income_products',
'income_other',
]
if (!category || noVatCategories.includes(category)) {
return 0
}
if (standard25Categories.includes(category)) {
return 0.25
}
if (reduced12Categories.includes(category)) {
return 0.12
}
if (reduced6Categories.includes(category)) {
return 0.06
}
// Default to 25% for unrecognized expense categories
return 0.25
}
/**
* Calculate input VAT from confirmed receipts
*/
function calculateReceiptInputVat(receipts: Receipt[]): number {
let inputVat = 0
for (const receipt of receipts) {
// Only confirmed receipts
if (receipt.status !== 'confirmed') continue
// Use the extracted VAT amount if available
if (receipt.vat_amount && receipt.vat_amount > 0) {
inputVat += receipt.vat_amount
}
}
return inputVat
}
/**
* Get a summary of the VAT declaration for display
*/
@@ -420,125 +262,3 @@ export function formatPeriodLabel(
return `${year}`
}
}
// ============================================================
// Tax-code-driven VAT declaration (new approach)
// ============================================================
/**
* Calculate VAT declaration using tax codes from journal entry lines.
*
* This is the new, tax-code-driven approach that sums journal_entry_lines
* grouped by tax_code, then maps via the tax_codes table to moms boxes.
* Falls back to the legacy invoice/transaction/receipt approach for
* lines without tax codes.
*/
export async function calculateVatDeclarationFromTaxCodes(
userId: string,
periodType: VatPeriodType,
year: number,
period: number
): Promise<VatDeclaration> {
const supabase = await createClient()
const { start, end } = calculatePeriodDates(periodType, year, period)
// Fetch tax codes for this user (including system codes)
const { data: taxCodesData } = await supabase
.from('tax_codes')
.select('*')
.or(`user_id.eq.${userId},user_id.is.null`)
const taxCodes = (taxCodesData as TaxCode[]) || []
const taxCodeMap = new Map<string, TaxCode>()
for (const tc of taxCodes) {
if (!taxCodeMap.has(tc.code) || tc.user_id) {
taxCodeMap.set(tc.code, tc)
}
}
// Fetch posted journal entry lines with tax_code in the period
const { data: lines } = await supabase
.from('journal_entry_lines')
.select(`
tax_code,
debit_amount,
credit_amount,
journal_entry_id,
journal_entries!inner (
user_id,
entry_date,
status
)
`)
.not('tax_code', 'is', null)
.eq('journal_entries.user_id', userId)
.eq('journal_entries.status', 'posted')
.gte('journal_entries.entry_date', start)
.lte('journal_entries.entry_date', end)
// Aggregate amounts by moms box
const boxTotals = new Map<string, number>()
for (const line of lines || []) {
if (!line.tax_code) continue
const taxCode = taxCodeMap.get(line.tax_code)
if (!taxCode) continue
const amount = Math.abs(Number(line.debit_amount || 0) - Number(line.credit_amount || 0))
// Map to all relevant boxes
for (const box of [...taxCode.moms_basis_boxes, ...taxCode.moms_tax_boxes, ...taxCode.moms_input_boxes]) {
const current = boxTotals.get(box) || 0
boxTotals.set(box, current + amount)
}
}
// Build rutor from box totals
const rutor: VatDeclarationRutor = {
ruta05: round(boxTotals.get('05') || 0),
ruta06: round(boxTotals.get('06') || 0),
ruta07: round(boxTotals.get('07') || 0),
ruta10: round(boxTotals.get('10') || 0),
ruta11: round(boxTotals.get('11') || 0),
ruta12: round(boxTotals.get('12') || 0),
ruta39: round(boxTotals.get('39') || 0),
ruta40: round(boxTotals.get('40') || 0),
ruta48: round(boxTotals.get('48') || 0),
ruta49: 0,
}
const totalOutputVat = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07)
rutor.ruta49 = round(totalOutputVat - rutor.ruta48)
return {
period: {
type: periodType,
year,
period,
start,
end,
},
rutor,
invoiceCount: 0,
transactionCount: (lines || []).length,
breakdown: {
invoices: {
ruta05: 0,
ruta06: 0,
ruta07: 0,
ruta10: 0,
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
},
transactions: {
ruta48: 0,
},
receipts: {
ruta48: 0,
},
},
}
}