feat: add review confirmation dialogs and BAS account number tooltips

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-20 15:27:45 +01:00
co-authored by Claude Opus 4.6
parent 885f362a29
commit ba94f60d06
40 changed files with 5633 additions and 64 deletions
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { getAccountDescription, ACCOUNT_DESCRIPTIONS } from '../account-descriptions'
describe('account-descriptions', () => {
describe('getAccountDescription', () => {
it('returns description for known accounts', () => {
const desc = getAccountDescription('1510')
expect(desc).toBeDefined()
expect(desc!.name).toBe('Kundfordringar')
expect(desc!.type).toBe('asset')
})
it('returns undefined for unknown accounts', () => {
expect(getAccountDescription('9999')).toBeUndefined()
expect(getAccountDescription('')).toBeUndefined()
})
})
describe('data completeness', () => {
const KEY_ACCOUNTS = [
'1510', '1930', '2013', '2440', '2611', '2621', '2631',
'2614', '2641', '2645', '2893', '3001', '3002', '3003',
'3305', '3308', '3960', '7960',
]
it.each(KEY_ACCOUNTS)('includes key BAS account %s', (account) => {
expect(getAccountDescription(account)).toBeDefined()
})
it('all entries have non-empty required fields', () => {
for (const [number, desc] of Object.entries(ACCOUNT_DESCRIPTIONS)) {
expect(desc.name, `${number} missing name`).toBeTruthy()
expect(desc.classLabel, `${number} missing classLabel`).toBeTruthy()
expect(desc.type, `${number} missing type`).toBeTruthy()
expect(desc.explanation, `${number} missing explanation`).toBeTruthy()
}
})
it('type values are valid', () => {
const validTypes = ['asset', 'liability', 'equity', 'revenue', 'expense']
for (const [number, desc] of Object.entries(ACCOUNT_DESCRIPTIONS)) {
expect(validTypes, `${number} has invalid type: ${desc.type}`).toContain(desc.type)
}
})
})
})
+332
View File
@@ -0,0 +1,332 @@
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
export interface AccountDescription {
name: string
classLabel: string
type: AccountType
explanation: string
}
const ACCOUNT_DESCRIPTIONS: Record<string, AccountDescription> = {
// Class 1: Assets (Tillgångar)
'1510': {
name: 'Kundfordringar',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Pengar som kunder är skyldiga dig för skickade fakturor som inte betalats ännu.',
},
'1580': {
name: 'Fordran för skatt',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Pengar du har att fordra från Skatteverket, t.ex. överskjutande moms.',
},
'1630': {
name: 'Skattekonto',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Ditt skattekonto hos Skatteverket, visar saldo för inbetalda skatter och avgifter.',
},
'1710': {
name: 'Förutbetalda hyreskostnader',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Hyra som betalats i förskott men som avser kommande perioder.',
},
'1910': {
name: 'Kassa',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Kontanta pengar i företagets kassa.',
},
'1920': {
name: 'PlusGiro',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Pengar på företagets PlusGiro-konto.',
},
'1930': {
name: 'Företagskonto',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Företagets huvudsakliga bankkonto. Hit kommer inbetalningar och härifrån görs utbetalningar.',
},
'1940': {
name: 'Övriga bankkonton',
classLabel: 'Tillgångar',
type: 'asset',
explanation: 'Ytterligare bankkonton utöver huvudkontot.',
},
// Class 2: Equity & Liabilities (Eget kapital och skulder)
'2010': {
name: 'Eget kapital',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Ägarens insatta kapital i enskild firma. Visar vad ägaren har investerat i verksamheten.',
},
'2013': {
name: 'Egna uttag',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Pengar som ägaren av en enskild firma tar ut privat ur företaget.',
},
'2018': {
name: 'Egna insättningar',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Pengar som ägaren sätter in privat i företaget (enskild firma).',
},
'2081': {
name: 'Aktiekapital',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Det registrerade aktiekapitalet i ett aktiebolag.',
},
'2091': {
name: 'Balanserat resultat',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Ackumulerade vinster/förluster från tidigare år som inte delats ut.',
},
'2099': {
name: 'Årets resultat',
classLabel: 'Eget kapital och skulder',
type: 'equity',
explanation: 'Vinst eller förlust för innevarande räkenskapsår.',
},
'2440': {
name: 'Leverantörsskulder',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Pengar du är skyldig leverantörer för mottagna fakturor som inte betalats ännu.',
},
'2510': {
name: 'Skatteskulder',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Skulder till Skatteverket för preliminär skatt och andra skattebetalningar.',
},
'2611': {
name: 'Utgående moms 25%',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du tar ut på försäljning med 25% momssats. Ska betalas in till Skatteverket.',
},
'2614': {
name: 'Utgående moms omvänd skattskyldighet',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Utgående moms vid omvänd skattskyldighet (reverse charge). Köparen redovisar momsen.',
},
'2621': {
name: 'Utgående moms 12%',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du tar ut på försäljning med 12% momssats (t.ex. livsmedel, hotell).',
},
'2631': {
name: 'Utgående moms 6%',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du tar ut på försäljning med 6% momssats (t.ex. böcker, kollektivtrafik).',
},
'2641': {
name: 'Ingående moms',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Moms du betalat på inköp som du har rätt att dra av. Minskar din momsskuld.',
},
'2645': {
name: 'Beräknad ingående moms på EU-förvärv',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Ingående moms du beräknar själv vid inköp från andra EU-länder (omvänd skattskyldighet).',
},
'2893': {
name: 'Lån från aktieägare',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Pengar som aktiebolaget lånat av sina ägare. Vanligt i mindre AB.',
},
'2898': {
name: 'Outtagen vinstutdelning',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Beslutad men ännu ej utbetald aktieutdelning.',
},
'2920': {
name: 'Upplupna semesterlöner',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Skuld för intjänade men ej uttagna semesterdagar.',
},
'2940': {
name: 'Upplupna arbetsgivaravgifter',
classLabel: 'Eget kapital och skulder',
type: 'liability',
explanation: 'Arbetsgivaravgifter som hänför sig till redovisade löner men ännu inte betalats.',
},
// Class 3: Revenue (Intäkter)
'3001': {
name: 'Försäljning varor/tjänster 25%',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning med 25% moms — den vanligaste intäktsraden.',
},
'3002': {
name: 'Försäljning varor/tjänster 12%',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning med 12% moms (t.ex. livsmedel).',
},
'3003': {
name: 'Försäljning varor/tjänster 6%',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning med 6% moms (t.ex. böcker, tidningar).',
},
'3305': {
name: 'Försäljning export utanför EU',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning till kunder utanför EU. Momsfritt.',
},
'3308': {
name: 'Försäljning tjänster EU',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Intäkter från försäljning av tjänster till företag i andra EU-länder. Omvänd skattskyldighet.',
},
'3900': {
name: 'Övriga rörelseintäkter',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Andra intäkter som inte hör till kärnverksamheten, t.ex. uthyrning av lokaler.',
},
'3960': {
name: 'Valutakursvinster',
classLabel: 'Intäkter',
type: 'revenue',
explanation: 'Vinster som uppstår vid valutaväxling eller betalningar i utländsk valuta.',
},
// Class 4: Cost of goods (Varor och material)
'4010': {
name: 'Varuinköp',
classLabel: 'Varor och material',
type: 'expense',
explanation: 'Kostnader för inköp av varor som säljs vidare.',
},
// Class 5: External expenses (Övriga externa kostnader)
'5010': {
name: 'Lokalkostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Kostnader för kontorslokal, lager eller annan arbetsplats (hyra, el, städning).',
},
'5410': {
name: 'Förbrukningsinventarier',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Inköp av mindre inventarier, t.ex. kontorsmöbler, dator (under halva prisbasbeloppet).',
},
'5420': {
name: 'Programvaror',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Kostnader för mjukvara, prenumerationer och licenser.',
},
'5800': {
name: 'Resekostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Tjänsteresor: tåg, flyg, hotell, taxi.',
},
'5910': {
name: 'Annonsering och reklam',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Kostnader för marknadsföring, annonser och reklamkampanjer.',
},
// Class 6: Other external expenses
'6530': {
name: 'Redovisningstjänster',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Avgifter till bokföringsbyrå eller redovisningskonsult.',
},
'6570': {
name: 'Bankkostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Avgifter för banktjänster, betalförmedling och transaktionsavgifter.',
},
'6991': {
name: 'Övriga externa kostnader',
classLabel: 'Övriga externa kostnader',
type: 'expense',
explanation: 'Diverse externa kostnader som inte passar in under andra konton.',
},
// Class 7: Personnel & depreciation
'7010': {
name: 'Löner tjänstemän',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Bruttolöner (före skatt) till anställda tjänstemän.',
},
'7210': {
name: 'Löner kollektiv',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Bruttolöner till arbetare och kollektivanställda.',
},
'7510': {
name: 'Arbetsgivaravgifter',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Lagstadgade sociala avgifter som arbetsgivaren betalar (ca 31% av bruttolönen).',
},
'7820': {
name: 'Avskrivningar inventarier',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Årlig värdeminskning på inventarier och maskiner. Fördelas över tillgångens livslängd.',
},
'7960': {
name: 'Valutakursförluster',
classLabel: 'Personal och avskrivningar',
type: 'expense',
explanation: 'Förluster som uppstår vid valutaväxling eller betalningar i utländsk valuta.',
},
// Class 8: Financial items
'8310': {
name: 'Ränteintäkter',
classLabel: 'Finansiella poster',
type: 'revenue',
explanation: 'Ränta du får på bankkontosaldo eller utlånade pengar.',
},
'8410': {
name: 'Räntekostnader',
classLabel: 'Finansiella poster',
type: 'expense',
explanation: 'Ränta du betalar på lån och krediter.',
},
'8999': {
name: 'Årets resultat',
classLabel: 'Finansiella poster',
type: 'equity',
explanation: 'Slutresultatkonto som visar vinst eller förlust efter alla intäkter och kostnader.',
},
}
export function getAccountDescription(accountNumber: string): AccountDescription | undefined {
return ACCOUNT_DESCRIPTIONS[accountNumber]
}
export { ACCOUNT_DESCRIPTIONS }
+249
View File
@@ -0,0 +1,249 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateARLedger } from '../ar-ledger'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateARLedger', () => {
it('returns empty report when no invoices found', async () => {
results = [
{ data: [], error: null },
]
const report = await generateARLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.unpaid_count).toBe(0)
})
it('returns empty report on query error', async () => {
results = [
{ data: null, error: { message: 'DB error' } },
]
const report = await generateARLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
it('groups invoices by customer with correct aging buckets', async () => {
// Reference date: 2024-06-15
const asOfDate = '2024-06-15'
results = [
{
data: [
// Customer A: one current, one 1-30 days overdue
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Acme AB' },
invoice_number: 'F001',
invoice_date: '2024-05-01',
due_date: '2024-06-20', // not yet due
total: 5000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
{
id: 'inv-2',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Acme AB' },
invoice_number: 'F002',
invoice_date: '2024-04-01',
due_date: '2024-06-01', // 14 days overdue
total: 3000,
paid_amount: 1000,
currency: 'SEK',
status: 'overdue',
},
// Customer B: 90+ days overdue
{
id: 'inv-3',
customer_id: 'cust-b',
customer: { id: 'cust-b', name: 'Beta Corp' },
invoice_number: 'F003',
invoice_date: '2024-01-01',
due_date: '2024-02-01', // 135 days overdue
total: 10000,
paid_amount: 0,
currency: 'SEK',
status: 'overdue',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', asOfDate)
expect(report.unpaid_count).toBe(3)
expect(report.entries).toHaveLength(2)
// Sorted by total outstanding descending: Beta Corp (10000), then Acme (7000)
expect(report.entries[0].customer_name).toBe('Beta Corp')
expect(report.entries[0].total_outstanding).toBe(10000)
expect(report.entries[0].days_90_plus).toBe(10000)
expect(report.entries[1].customer_name).toBe('Acme AB')
expect(report.entries[1].total_outstanding).toBe(7000)
expect(report.entries[1].current).toBe(5000) // inv-1
expect(report.entries[1].days_1_30).toBe(2000) // inv-2 (3000 - 1000 paid)
expect(report.entries[1].invoices).toHaveLength(2)
// Totals
expect(report.total_outstanding).toBe(17000)
expect(report.total_current).toBe(5000)
expect(report.total_overdue).toBe(12000)
})
it('computes outstanding as total minus paid_amount', async () => {
results = [
{
data: [
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test AB' },
invoice_number: 'F001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
total: 10000,
paid_amount: 7500,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(2500)
expect(report.total_outstanding).toBe(2500)
})
it('sorts invoices within customer by due_date', async () => {
results = [
{
data: [
{
id: 'inv-2',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test AB' },
invoice_number: 'F002',
invoice_date: '2024-05-01',
due_date: '2024-07-01',
total: 1000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test AB' },
invoice_number: 'F001',
invoice_date: '2024-04-01',
due_date: '2024-06-01',
total: 2000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-05-15')
// Sorted by due_date: F001 (June 1) before F002 (July 1)
expect(report.entries[0].invoices[0].invoice_number).toBe('F001')
expect(report.entries[0].invoices[1].invoice_number).toBe('F002')
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{
id: 'inv-1',
customer_id: 'cust-a',
customer: { id: 'cust-a', name: 'Test' },
invoice_number: 'F001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
total: 100.1,
paid_amount: 33.33,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(66.77)
expect(report.total_outstanding).toBe(66.77)
})
it('handles missing customer name gracefully', async () => {
results = [
{
data: [
{
id: 'inv-1',
customer_id: 'cust-a',
customer: null,
invoice_number: 'F001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
total: 1000,
paid_amount: 0,
currency: 'SEK',
status: 'sent',
},
],
error: null,
},
]
const report = await generateARLedger('user-1', '2024-06-15')
expect(report.entries[0].customer_name).toBe('Okänd kund')
})
})
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateARReconciliation } from '../ar-reconciliation'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateARReconciliation', () => {
it('returns reconciled when AR ledger matches account 1510', async () => {
results = [
// 0: invoices
{
data: [
{ total: 5000, paid_amount: 2000 },
{ total: 3000, paid_amount: 0 },
],
error: null,
},
// 1: journal_entry_lines for account 1510
{
data: [
{ debit_amount: 8000, credit_amount: 0, journal_entry_id: 'e1' },
{ debit_amount: 0, credit_amount: 2000, journal_entry_id: 'e2' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
// AR: (5000-2000) + (3000-0) = 6000
expect(result.ar_ledger_total).toBe(6000)
// 1510: 8000 - 2000 = 6000
expect(result.account_1510_balance).toBe(6000)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('detects difference when AR ledger does not match account 1510', async () => {
results = [
// 0: invoices
{
data: [
{ total: 5000, paid_amount: 0 },
],
error: null,
},
// 1: journal_entry_lines — manual debit on 1510 creates mismatch
{
data: [
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'e1' },
{ debit_amount: 1000, credit_amount: 0, journal_entry_id: 'e2' }, // manual entry
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(5000)
expect(result.account_1510_balance).toBe(6000)
expect(result.difference).toBe(-1000)
expect(result.is_reconciled).toBe(false)
})
it('returns zero balances when no data exists', async () => {
results = [
{ data: [], error: null },
{ data: [], error: null },
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(0)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('handles null invoice data gracefully', async () => {
results = [
{ data: null, error: null },
{
data: [
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(3000)
expect(result.difference).toBe(-3000)
expect(result.is_reconciled).toBe(false)
})
it('uses correct debit-normal balance for account 1510 (asset)', async () => {
results = [
{ data: [], error: null },
{
data: [
{ debit_amount: 10000, credit_amount: 0, journal_entry_id: 'e1' },
{ debit_amount: 0, credit_amount: 4000, journal_entry_id: 'e2' },
{ debit_amount: 0, credit_amount: 3000, journal_entry_id: 'e3' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
// Balance = debits - credits = 10000 - 4000 - 3000 = 3000
expect(result.account_1510_balance).toBe(3000)
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{ total: 100.1, paid_amount: 33.33 },
],
error: null,
},
{
data: [
{ debit_amount: 66.77, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateARReconciliation('user-1', 'period-1')
expect(result.ar_ledger_total).toBe(66.77)
expect(result.account_1510_balance).toBe(66.77)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
})
+186
View File
@@ -0,0 +1,186 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { generateBalanceSheet } from '../balance-sheet'
import { generateTrialBalance } from '../trial-balance'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
beforeEach(() => {
vi.clearAllMocks()
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
account_number: '1930',
account_name: 'Test Account',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
}
describe('generateBalanceSheet', () => {
it('returns empty report when no rows', async () => {
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toEqual([])
expect(report.equity_liability_sections).toEqual([])
expect(report.total_assets).toBe(0)
expect(report.total_equity_liabilities).toBe(0)
})
it('calculates assets (class 1) with debit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 15000, closing_credit: 0 }),
makeRow({ account_number: '1930', account_name: 'Företagskonto', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
],
totalDebit: 65000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(2)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
expect(report.asset_sections[0].rows[0].amount).toBe(15000) // debit - credit
expect(report.asset_sections[1].title).toBe('Kassa och bank')
expect(report.asset_sections[1].rows[0].amount).toBe(50000)
expect(report.total_assets).toBe(65000)
})
it('calculates equity/liabilities (class 2) with credit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '2010', account_name: 'Eget kapital', account_class: 2, closing_credit: 30000, closing_debit: 0 }),
makeRow({ account_number: '2440', account_name: 'Leverantörsskulder', account_class: 2, closing_credit: 10000, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 40000,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.equity_liability_sections).toHaveLength(2)
expect(report.equity_liability_sections[0].title).toBe('Eget kapital')
expect(report.equity_liability_sections[0].rows[0].amount).toBe(30000) // credit - debit
expect(report.equity_liability_sections[1].title).toBe('Kortfristiga skulder')
expect(report.total_equity_liabilities).toBe(40000)
})
it('filters rows with |amount| < 0.005', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// This row has amount = 0.003, should be filtered
makeRow({ account_number: '1940', account_name: 'Tiny', account_class: 1, closing_debit: 0.003, closing_credit: 0 }),
],
totalDebit: 50000.003,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(bankSection.rows).toHaveLength(1)
expect(bankSection.rows[0].account_number).toBe('1930')
})
it('filters empty sections after zero-filtering', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// Section 10 (Immateriella) will be empty after filtering
makeRow({ account_number: '1010', account_name: 'Goodwill', account_class: 1, closing_debit: 0.001, closing_credit: 0 }),
],
totalDebit: 50000.001,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kassa och bank')
})
it('groups by two-digit prefix (1510 + 1520 under 15)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 10000, closing_credit: 0 }),
makeRow({ account_number: '1520', account_name: 'Osäkra kundfordringar', account_class: 1, closing_debit: 2000, closing_credit: 0 }),
],
totalDebit: 12000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
expect(report.asset_sections[0].rows).toHaveLength(2)
expect(report.asset_sections[0].subtotal).toBe(12000)
})
it('ignores class 3-8 accounts (income statement)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 40000, closing_debit: 0 }),
makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '8310', account_name: 'Interest', account_class: 8, closing_credit: 500, closing_debit: 0 }),
],
totalDebit: 58000,
totalCredit: 40500,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1) // Only 1930
expect(report.equity_liability_sections).toEqual([])
expect(report.total_assets).toBe(50000)
expect(report.total_equity_liabilities).toBe(0)
})
it('uses Math.round for monetary precision on subtotals', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 33.33, closing_credit: 0 }),
makeRow({ account_number: '1940', account_name: 'Kassa', account_class: 1, closing_debit: 33.33, closing_credit: 0 }),
makeRow({ account_number: '1950', account_name: 'Annan bank', account_class: 1, closing_debit: 33.34, closing_credit: 0 }),
],
totalDebit: 100,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
// All three are in group '19' (Kassa och bank)
const section = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(section.subtotal).toBe(100)
expect(report.total_assets).toBe(100)
})
})
@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'lt', 'order']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateGeneralLedger } from '../general-ledger'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateGeneralLedger', () => {
it('returns empty report when no fiscal period found', async () => {
results = [
// 0: fiscal_periods.single() → null
{ data: null, error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '', end: '' })
})
it('returns empty report when no entries in period', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
// 1: journal_entries (empty)
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
})
it('groups lines by account with correct totals and running balance', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
// 1: journal_entries for this period
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale', source_type: 'invoice' },
{ id: 'e2', entry_date: '2024-02-10', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction' },
],
error: null,
},
// 2: journal_entry_lines
{
data: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entry_id: 'e2' },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2611', account_name: 'Utgående moms 25%' },
{ account_number: '3001', account_name: 'Försäljning 25%' },
],
error: null,
},
// 4: prior entries (none)
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
expect(report.accounts).toHaveLength(4)
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930', '2611', '3001'])
// Account 1510: debit 1250, credit 1250 → closing 0
const acc1510 = report.accounts.find((a) => a.account_number === '1510')!
expect(acc1510.total_debit).toBe(1250)
expect(acc1510.total_credit).toBe(1250)
expect(acc1510.closing_balance).toBe(0)
expect(acc1510.lines).toHaveLength(2)
expect(acc1510.lines[0].balance).toBe(1250)
expect(acc1510.lines[1].balance).toBe(0)
// Account 1930: debit 1250, credit 0 → closing 1250
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.total_debit).toBe(1250)
expect(acc1930.total_credit).toBe(0)
expect(acc1930.closing_balance).toBe(1250)
})
it('computes opening balance from prior period entries', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2025-01-01', end_date: '2025-12-31' }, error: null },
// 1: journal_entries for this period
{
data: [
{ id: 'e1', entry_date: '2025-03-01', voucher_number: 1, voucher_series: 'A', description: 'Purchase', source_type: 'manual' },
],
error: null,
},
// 2: journal_entry_lines
{
data: [
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
{ account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '5410', account_name: 'Förbrukningsinventarier' },
],
error: null,
},
// 4: prior entries
{ data: [{ id: 'prior-1' }], error: null },
// 5: prior lines
{
data: [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
],
error: null,
},
]
const report = await generateGeneralLedger('user-1', 'period-2')
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.opening_balance).toBe(10000)
expect(acc1930.closing_balance).toBe(9500) // 10000 - 500
expect(acc1930.lines[0].balance).toBe(9500)
})
it('filters accounts by account_from and account_to', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
// 1: journal_entries
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual' },
],
error: null,
},
// 2: lines across multiple accounts
{
data: [
{ account_number: '1510', debit_amount: 1000, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
],
error: null,
},
// 3: chart_of_accounts
{ data: [], error: null },
// 4: prior entries (none)
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1', '1500', '1999')
// Only accounts in 1500–1999 range
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930'])
})
it('sorts lines within account by date then voucher number', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
// 1: entries out of order
{
data: [
{ id: 'e2', entry_date: '2024-01-10', voucher_number: 2, voucher_series: 'A', description: 'Second', source_type: 'manual' },
{ id: 'e1', entry_date: '2024-01-10', voucher_number: 1, voucher_series: 'A', description: 'First', source_type: 'manual' },
{ id: 'e3', entry_date: '2024-01-05', voucher_number: 3, voucher_series: 'A', description: 'Earlier date', source_type: 'manual' },
],
error: null,
},
// 2: lines all on same account
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1930', debit_amount: 200, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 300, credit_amount: 0, journal_entry_id: 'e3' },
],
error: null,
},
// 3: chart_of_accounts
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
// 4: prior entries
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const acc = report.accounts[0]
// e3 (Jan 5) first, then e1 (Jan 10, #1), then e2 (Jan 10, #2)
expect(acc.lines[0].description).toBe('Earlier date')
expect(acc.lines[1].description).toBe('First')
expect(acc.lines[2].description).toBe('Second')
})
it('uses Math.round for monetary precision', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0, journal_entry_id: 'e1' },
],
error: null,
},
{ data: [{ account_number: '1930', account_name: 'Företagskonto' }], error: null },
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const acc = report.accounts[0]
expect(acc.total_debit).toBe(33.33)
expect(acc.closing_balance).toBe(33.33)
})
})
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
import { generateIncomeStatement } from '../income-statement'
import { generateTrialBalance } from '../trial-balance'
import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
beforeEach(() => {
vi.clearAllMocks()
})
function makeRow(overrides: Partial<TrialBalanceRow>): TrialBalanceRow {
return {
account_number: '3001',
account_name: 'Test Account',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 0,
closing_debit: 0,
closing_credit: 0,
...overrides,
}
}
describe('generateIncomeStatement', () => {
it('returns empty report when no rows', async () => {
mockTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.revenue_sections).toEqual([])
expect(report.expense_sections).toEqual([])
expect(report.financial_sections).toEqual([])
expect(report.net_result).toBe(0)
})
it('calculates revenue (class 3) with credit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
makeRow({ account_number: '3002', account_name: 'Försäljning 12%', account_class: 3, closing_credit: 5000, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 15000,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.revenue_sections).toHaveLength(1)
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
expect(report.revenue_sections[0].rows).toHaveLength(2)
expect(report.revenue_sections[0].rows[0].amount).toBe(10000)
expect(report.revenue_sections[0].rows[1].amount).toBe(5000)
expect(report.total_revenue).toBe(15000)
})
it('calculates expenses (class 4-7) with debit-normal balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '6200', account_name: 'Telefon', account_class: 6, closing_debit: 2000, closing_credit: 0 }),
],
totalDebit: 10000,
totalCredit: 0,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.expense_sections).toHaveLength(2)
expect(report.expense_sections[0].title).toBe('Lokalkostnader')
expect(report.expense_sections[0].rows[0].amount).toBe(8000)
expect(report.expense_sections[1].title).toBe('Tele och post')
expect(report.total_expenses).toBe(10000)
})
it('calculates financial items (class 8) with mixed balance', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '8310', account_name: 'Ränteintäkter', account_class: 8, closing_credit: 500, closing_debit: 0 }),
makeRow({ account_number: '8410', account_name: 'Räntekostnader', account_class: 8, closing_debit: 300, closing_credit: 0 }),
],
totalDebit: 300,
totalCredit: 500,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.financial_sections).toHaveLength(2)
// Financial uses credit - debit
const interestIncome = report.financial_sections.find(s => s.title === 'Ränteintäkter')!
expect(interestIncome.rows[0].amount).toBe(500) // credit 500 - debit 0
const interestExpense = report.financial_sections.find(s => s.title === 'Räntekostnader')!
expect(interestExpense.rows[0].amount).toBe(-300) // credit 0 - debit 300
})
it('computes net_result = revenue - expenses + financial', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 20000, closing_debit: 0 }),
makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 8000, closing_credit: 0 }),
makeRow({ account_number: '8310', account_name: 'Interest', account_class: 8, closing_credit: 1000, closing_debit: 0 }),
],
totalDebit: 8000,
totalCredit: 21000,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
expect(report.total_revenue).toBe(20000)
expect(report.total_expenses).toBe(8000)
expect(report.total_financial).toBe(1000)
expect(report.net_result).toBe(13000) // 20000 - 8000 + 1000
})
it('filters rows with |amount| < 0.005', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
// This row has amount = 0.004, should be filtered
makeRow({ account_number: '3002', account_name: 'Tiny', account_class: 3, closing_credit: 0.004, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 10000.004,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
// Only the significant revenue row should appear
const revenueSection = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
expect(revenueSection.rows).toHaveLength(1)
expect(revenueSection.rows[0].account_number).toBe('3001')
})
it('filters empty sections (no rows after zero-filtering)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 5000, closing_debit: 0 }),
// Section 36 will be empty after filtering
makeRow({ account_number: '3601', account_name: 'Tiny side income', account_class: 3, closing_credit: 0.001, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 5000.001,
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
// Only the non-empty section should remain
expect(report.revenue_sections).toHaveLength(1)
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
})
it('ignores class 1-2 accounts (balance sheet)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
makeRow({ account_number: '2440', account_name: 'Leverantörsskulder', account_class: 2, closing_credit: 10000, closing_debit: 0 }),
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 40000, closing_debit: 0 }),
],
totalDebit: 50000,
totalCredit: 50000,
isBalanced: true,
})
const report = await generateIncomeStatement('user-1', 'period-1')
// Only class 3 should appear
expect(report.revenue_sections).toHaveLength(1)
expect(report.expense_sections).toEqual([])
expect(report.financial_sections).toEqual([])
expect(report.total_revenue).toBe(40000)
})
})
@@ -0,0 +1,203 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateJournalRegister } from '../journal-register'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateJournalRegister', () => {
it('returns empty report when no fiscal period found', async () => {
results = [
{ data: null, error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '', end: '' })
})
it('returns empty report when no entries in period', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
})
it('produces entries in registration order with correct totals', async () => {
results = [
// 0: fiscal_periods.single()
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
// 1: journal_entries (already ordered by series/number)
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Sale invoice', source_type: 'invoice', status: 'posted' },
{ id: 'e2', entry_date: '2024-02-01', voucher_number: 2, voucher_series: 'A', description: 'Payment', source_type: 'transaction', status: 'posted' },
],
error: null,
},
// 2: journal_entry_lines
{
data: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, journal_entry_id: 'e1' },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, journal_entry_id: 'e1' },
{ account_number: '1930', debit_amount: 1250, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1510', debit_amount: 0, credit_amount: 1250, journal_entry_id: 'e2' },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1510', account_name: 'Kundfordringar' },
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2611', account_name: 'Utgående moms 25%' },
{ account_number: '3001', account_name: 'Försäljning 25%' },
],
error: null,
},
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.total_entries).toBe(2)
expect(report.entries[0].voucher_number).toBe(1)
expect(report.entries[1].voucher_number).toBe(2)
// Entry 1: sale invoice
expect(report.entries[0].total_debit).toBe(1250)
expect(report.entries[0].total_credit).toBe(1250)
expect(report.entries[0].lines).toHaveLength(3)
// Lines sorted by account number
expect(report.entries[0].lines[0].account_number).toBe('1510')
expect(report.entries[0].lines[1].account_number).toBe('2611')
expect(report.entries[0].lines[2].account_number).toBe('3001')
// Grand totals
expect(report.total_debit).toBe(2500)
expect(report.total_credit).toBe(2500)
})
it('includes reversed entries with correct status', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' },
{ id: 'e2', entry_date: '2024-01-16', voucher_number: 2, voucher_series: 'A', description: 'Reversal', source_type: 'manual', status: 'posted' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '5410', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e1' },
{ account_number: '5410', debit_amount: 500, credit_amount: 0, journal_entry_id: 'e2' },
{ account_number: '1930', debit_amount: 0, credit_amount: 500, journal_entry_id: 'e2' },
],
error: null,
},
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries[0].status).toBe('reversed')
expect(report.entries[1].status).toBe('posted')
expect(report.total_entries).toBe(2)
})
it('resolves account names from chart_of_accounts', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '9999', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1' },
],
error: null,
},
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
],
error: null,
},
]
const report = await generateJournalRegister('user-1', 'period-1')
const line1930 = report.entries[0].lines.find((l) => l.account_number === '1930')!
expect(line1930.account_name).toBe('Företagskonto')
// Unknown account gets fallback name
const line9999 = report.entries[0].lines.find((l) => l.account_number === '9999')!
expect(line9999.account_name).toBe('Konto 9999')
})
it('defaults voucher_series to A when null', async () => {
results = [
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
{
data: [
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' },
],
error: null,
},
{
data: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, journal_entry_id: 'e1' },
{ account_number: '3001', debit_amount: 0, credit_amount: 100, journal_entry_id: 'e1' },
],
error: null,
},
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
expect(report.entries[0].voucher_series).toBe('A')
})
})
+345
View File
@@ -0,0 +1,345 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'order']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateSIEExport } from '../sie-export'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
const baseOptions = {
fiscal_period_id: 'period-1',
company_name: 'Test AB',
org_number: '556677-8899',
program_name: 'ERPBase',
}
describe('generateSIEExport', () => {
it('throws when fiscal period not found', async () => {
results = [
// 0: fiscal_periods.single() → null
{ data: null, error: null },
]
await expect(generateSIEExport('user-1', baseOptions))
.rejects.toThrow('Fiscal period not found')
})
it('generates correct header format', async () => {
results = [
// 0: fiscal_periods
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: chart_of_accounts (empty)
{ data: [], error: null },
// 2: journal_entries (empty)
{ data: [], error: null },
// 3: cost_centers (empty)
{ data: [], error: null },
// 4: projects (empty)
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const lines = output.split('\r\n')
expect(lines[0]).toBe('#FLAGGA 0')
expect(lines[1]).toBe('#FORMAT PC8')
expect(lines[2]).toBe('#SIETYP 4')
expect(lines[3]).toMatch(/^#PROGRAM "ERPBase" "1\.0"$/)
expect(lines[4]).toMatch(/^#GEN \d{8}$/)
expect(lines[5]).toBe('#ORGNR 556677-8899')
expect(lines[6]).toBe('#FNAMN "Test AB"')
expect(lines[7]).toBe('#RAR 0 20240101 20241231')
})
it('omits #ORGNR when org_number is null', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', {
...baseOptions,
org_number: undefined,
})
expect(output).not.toContain('#ORGNR')
})
it('generates #KONTO and #SRU for accounts', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', sru_code: '7301', is_active: true },
{ account_number: '3001', account_name: 'Försäljning', sru_code: null, is_active: true },
],
error: null,
},
// 2: journal_entries (empty)
{ data: [], error: null },
// 3: cost_centers
{ data: [], error: null },
// 4: projects
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#KONTO 1930 "Företagskonto"')
expect(output).toContain('#SRU 1930 7301')
expect(output).toContain('#KONTO 3001 "Försäljning"')
// No SRU for 3001 since sru_code is null
expect(output).not.toContain('#SRU 3001')
})
it('generates #VER and #TRANS for journal entries', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
// 1: accounts
{ data: [], error: null },
// 2: journal_entries with lines
{
data: [
{
id: 'e1',
entry_date: '2024-03-15',
voucher_number: 1,
voucher_series: 'A',
description: 'Sale invoice',
status: 'posted',
lines: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', cost_center: null, project: null },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
// 3: cost_centers
{ data: [], error: null },
// 4: projects
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240315 "Sale invoice"')
expect(output).toContain('{')
expect(output).toContain('\t#TRANS 1510 {} 1250.00 20240315')
expect(output).toContain('\t#TRANS 3001 {} -1000.00 20240315 "Revenue"')
expect(output).toContain('\t#TRANS 2611 {} -250.00 20240315')
expect(output).toContain('}')
})
it('generates #DIM and #OBJEKT for dimensions', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
// 3: cost_centers
{
data: [
{ code: 'CC1', name: 'Avdelning 1', is_active: true },
],
error: null,
},
// 4: projects
{
data: [
{ code: 'P001', name: 'Projekt Alpha', is_active: true },
],
error: null,
},
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#DIM 1 "Kostnadsställe"')
expect(output).toContain('#DIM 6 "Projekt"')
expect(output).toContain('#OBJEKT 1 "CC1" "Avdelning 1"')
expect(output).toContain('#OBJEKT 6 "P001" "Projekt Alpha"')
})
it('includes dimension objects in #TRANS lines', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{
data: [
{
id: 'e1',
entry_date: '2024-03-15',
voucher_number: 1,
voucher_series: 'A',
description: 'With dimensions',
status: 'posted',
lines: [
{ account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, cost_center: 'CC1', project: 'P001' },
{ account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
{ data: [{ code: 'CC1', name: 'Avdelning 1', is_active: true }], error: null },
{ data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('\t#TRANS 5010 {1 "CC1" 6 "P001"} 8000.00 20240315')
expect(output).toContain('\t#TRANS 1930 {} -8000.00 20240315')
})
it('generates #UB for class 1-2 and #RES for class 3-8', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{
data: [
{
id: 'e1',
entry_date: '2024-01-15',
voucher_number: 1,
voucher_series: 'A',
description: 'Sale',
status: 'posted',
lines: [
{ account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, cost_center: null, project: null },
{ account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
// Account 1510 (class 1) → #UB, balance = 1250 - 0 = 1250
expect(output).toContain('#UB 0 1510 1250.00')
// Account 2611 (class 2) → #UB, balance = 0 - 250 = -250
expect(output).toContain('#UB 0 2611 -250.00')
// Account 3001 (class 3) → #RES, balance = 0 - 1000 = -1000
expect(output).toContain('#RES 0 3001 -1000.00')
})
it('escapes quotes in descriptions', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{
data: [
{
id: 'e1',
entry_date: '2024-01-15',
voucher_number: 1,
voucher_series: 'A',
description: 'Invoice for "consulting"',
status: 'posted',
lines: [
{ account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
{ account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
],
},
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240115 "Invoice for \\"consulting\\""')
})
it('uses \\r\\n line endings', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
// Every line should end with \r\n
expect(output).toContain('\r\n')
// Should not have bare \n (that isn't preceded by \r)
const lines = output.split('\r\n')
for (const line of lines.slice(0, -1)) {
expect(line).not.toContain('\n')
}
// File should end with \r\n
expect(output.endsWith('\r\n')).toBe(true)
})
it('produces no #VER lines when no entries exist', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).not.toContain('#VER')
expect(output).not.toContain('#TRANS')
})
it('produces no #DIM lines when no dimensions exist', async () => {
results = [
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
expect(output).not.toContain('#DIM')
expect(output).not.toContain('#OBJEKT')
})
})
@@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateSupplierLedger } from '../supplier-ledger'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateSupplierLedger', () => {
it('returns empty report when no invoices found', async () => {
results = [
{ data: [], error: null },
]
const report = await generateSupplierLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.total_current).toBe(0)
expect(report.total_overdue).toBe(0)
expect(report.unpaid_count).toBe(0)
})
it('returns empty report on query error', async () => {
results = [
{ data: null, error: { message: 'DB error' } },
]
const report = await generateSupplierLedger('user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
it('places invoices in correct aging buckets', async () => {
// Reference date: 2024-06-15
const asOfDate = '2024-06-15'
results = [
{
data: [
// Current: due in the future (days overdue <= 0)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-06-20',
remaining_amount: 5000,
},
// 1-30 days overdue: due_date 2024-06-01 (14 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-06-01',
remaining_amount: 3000,
},
// 31-60 days overdue: due_date 2024-05-01 (45 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-05-01',
remaining_amount: 2000,
},
// 61-90 days overdue: due_date 2024-04-01 (75 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-04-01',
remaining_amount: 1500,
},
// 90+ days overdue: due_date 2024-02-01 (135 days overdue)
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-02-01',
remaining_amount: 1000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', asOfDate)
expect(report.entries).toHaveLength(1)
const entry = report.entries[0]
expect(entry.current).toBe(5000)
expect(entry.days_1_30).toBe(3000)
expect(entry.days_31_60).toBe(2000)
expect(entry.days_61_90).toBe(1500)
expect(entry.days_90_plus).toBe(1000)
expect(entry.total_outstanding).toBe(12500)
})
it('groups by supplier and uses fallback name for missing supplier', async () => {
results = [
{
data: [
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Leverantör A' },
due_date: '2024-07-01',
remaining_amount: 5000,
},
{
supplier_id: 'sup-2',
supplier: null,
due_date: '2024-07-01',
remaining_amount: 3000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.entries).toHaveLength(2)
const names = report.entries.map(e => e.supplier_name)
expect(names).toContain('Leverantör A')
expect(names).toContain('Okänd leverantör')
})
it('sorts entries by outstanding descending', async () => {
results = [
{
data: [
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Small' },
due_date: '2024-07-01',
remaining_amount: 1000,
},
{
supplier_id: 'sup-2',
supplier: { id: 'sup-2', name: 'Large' },
due_date: '2024-07-01',
remaining_amount: 10000,
},
{
supplier_id: 'sup-3',
supplier: { id: 'sup-3', name: 'Medium' },
due_date: '2024-07-01',
remaining_amount: 5000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.entries[0].supplier_name).toBe('Large')
expect(report.entries[1].supplier_name).toBe('Medium')
expect(report.entries[2].supplier_name).toBe('Small')
})
it('calculates grand totals correctly', async () => {
results = [
{
data: [
// Supplier A: current 5000
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'A' },
due_date: '2024-07-01',
remaining_amount: 5000,
},
// Supplier B: 1-30 days overdue 3000
{
supplier_id: 'sup-2',
supplier: { id: 'sup-2', name: 'B' },
due_date: '2024-06-01',
remaining_amount: 3000,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.total_outstanding).toBe(8000)
expect(report.total_current).toBe(5000)
expect(report.total_overdue).toBe(3000) // outstanding - current
expect(report.unpaid_count).toBe(2)
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Test' },
due_date: '2024-07-01',
remaining_amount: 33.33,
},
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Test' },
due_date: '2024-07-02',
remaining_amount: 33.33,
},
{
supplier_id: 'sup-1',
supplier: { id: 'sup-1', name: 'Test' },
due_date: '2024-07-03',
remaining_amount: 33.34,
},
],
error: null,
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
expect(report.total_outstanding).toBe(100)
expect(report.total_current).toBe(100)
})
})
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateReconciliation } from '../supplier-reconciliation'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateReconciliation', () => {
it('returns reconciled when supplier total matches account 2440 balance', async () => {
results = [
// 0: supplier_invoices
{
data: [
{ remaining_amount: 5000 },
{ remaining_amount: 3000 },
],
error: null,
},
// 1: journal_entry_lines for account 2440
{
data: [
{ debit_amount: 0, credit_amount: 10000, journal_entry_id: 'e1' },
{ debit_amount: 2000, credit_amount: 0, journal_entry_id: 'e2' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
// Supplier total: 5000 + 3000 = 8000
expect(result.supplier_ledger_total).toBe(8000)
// Account 2440 (credit-normal): credits - debits = 10000 - 2000 = 8000
expect(result.account_2440_balance).toBe(8000)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('detects mismatch when difference != 0', async () => {
results = [
// 0: supplier_invoices — total 5000
{
data: [
{ remaining_amount: 5000 },
],
error: null,
},
// 1: journal_entry_lines — balance 7000
{
data: [
{ debit_amount: 0, credit_amount: 7000, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(5000)
expect(result.account_2440_balance).toBe(7000)
expect(result.difference).toBe(-2000)
expect(result.is_reconciled).toBe(false)
})
it('returns reconciled when both are zero/empty', async () => {
results = [
{ data: [], error: null },
{ data: [], error: null },
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(0)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
it('handles null invoice data gracefully', async () => {
results = [
{ data: null, error: null },
{
data: [
{ debit_amount: 0, credit_amount: 3000, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(3000)
expect(result.difference).toBe(-3000)
expect(result.is_reconciled).toBe(false)
})
it('computes credit-normal balance for account 2440 (liability)', async () => {
results = [
{ data: [], error: null },
{
data: [
{ debit_amount: 0, credit_amount: 15000, journal_entry_id: 'e1' },
{ debit_amount: 5000, credit_amount: 0, journal_entry_id: 'e2' },
{ debit_amount: 3000, credit_amount: 0, journal_entry_id: 'e3' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
// Balance = credits - debits = 15000 - 5000 - 3000 = 7000
expect(result.account_2440_balance).toBe(7000)
})
it('uses Math.round for monetary precision', async () => {
results = [
{
data: [
{ remaining_amount: 33.33 },
{ remaining_amount: 33.34 },
],
error: null,
},
{
data: [
{ debit_amount: 0, credit_amount: 66.67, journal_entry_id: 'e1' },
],
error: null,
},
]
const result = await generateReconciliation('user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(66.67)
expect(result.account_2440_balance).toBe(66.67)
expect(result.difference).toBe(0)
expect(result.is_reconciled).toBe(true)
})
})
+315
View File
@@ -0,0 +1,315 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue with rpc support
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateTrialBalance } from '../trial-balance'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
describe('generateTrialBalance', () => {
it('returns rows directly from RPC when successful', async () => {
results = [
// 0: rpc('generate_trial_balance')
{
data: [
{
account_number: '1930',
account_name: 'Företagskonto',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 5000,
period_credit: 0,
closing_debit: 5000,
closing_credit: 0,
},
{
account_number: '3001',
account_name: 'Försäljning 25%',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 5000,
closing_debit: 0,
closing_credit: 5000,
},
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(5000)
expect(result.totalCredit).toBe(5000)
expect(result.isBalanced).toBe(true)
})
it('falls back to manual aggregation when RPC returns error', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'function not found' } },
// 1: manual — journal_entries
{
data: [{ id: 'e1' }],
error: null,
},
// 2: manual — journal_entry_lines
{
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
error: null,
},
// 3: manual — chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(1000)
expect(result.isBalanced).toBe(true)
})
it('falls back to manual aggregation when RPC returns null data', async () => {
results = [
// 0: rpc succeeds but data is null
{ data: null, error: null },
// 1: manual — journal_entries (empty)
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.isBalanced).toBe(true)
})
it('returns empty report when no entries exist (manual path)', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: manual — journal_entries empty
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.totalDebit).toBe(0)
expect(result.totalCredit).toBe(0)
expect(result.isBalanced).toBe(true)
})
it('aggregates lines by account and sorts by account_number', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }, { id: 'e2' }], error: null },
// 2: journal_entry_lines — multiple lines per account
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
{ account_number: '1930', debit_amount: 300, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 200 },
{ account_number: '1930', debit_amount: 450, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toHaveLength(2)
// Sorted by account number
expect(result.rows[0].account_number).toBe('1930')
expect(result.rows[1].account_number).toBe('3001')
// Aggregated correctly
expect(result.rows[0].closing_debit).toBe(750)
expect(result.rows[0].closing_credit).toBe(0)
expect(result.rows[1].closing_debit).toBe(0)
expect(result.rows[1].closing_credit).toBe(700)
})
it('falls back to "Konto {number}" when account not in chart_of_accounts', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines
{
data: [
{ account_number: '9999', debit_amount: 100, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts — empty
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows[0].account_name).toBe('Konto 9999')
})
it('derives account_class from first digit when account not in chart', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines
{
data: [
{ account_number: '5410', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts — empty
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows[0].account_class).toBe(5)
})
it('uses Math.round for monetary precision', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines — values that cause floating point issues
{
data: [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
{ account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
],
error: null,
},
// 3: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows[0].closing_debit).toBe(100)
expect(result.totalDebit).toBe(100)
expect(result.totalCredit).toBe(100)
expect(result.isBalanced).toBe(true)
})
it('detects unbalanced entries (isBalanced=false)', async () => {
results = [
// 0: rpc succeeds with unbalanced data
{
data: [
{
account_number: '1930',
account_name: 'Bank',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 1000,
period_credit: 0,
closing_debit: 1000,
closing_credit: 0,
},
{
account_number: '3001',
account_name: 'Revenue',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 999,
closing_debit: 0,
closing_credit: 999,
},
],
error: null,
},
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(999)
expect(result.isBalanced).toBe(false)
})
it('returns empty when entries query errors (manual path)', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries query errors
{ data: null, error: { message: 'DB error' } },
]
const result = await generateTrialBalance('user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.isBalanced).toBe(true)
})
})
@@ -0,0 +1,430 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue
// ============================================================
let resultIdx: number
let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not']) {
b[m] = vi.fn().mockReturnValue(b)
}
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
return b
}
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import {
calculatePeriodDates,
formatPeriodLabel,
getVatDeclarationSummary,
calculateVatDeclaration,
calculateVatDeclarationFromTaxCodes,
} from '../vat-declaration'
import type { VatDeclaration } from '@/types'
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
})
// ============================================================
// Pure function tests — no mocks needed
// ============================================================
describe('calculatePeriodDates', () => {
it('returns correct dates for monthly period', () => {
const { start, end } = calculatePeriodDates('monthly', 2024, 1)
expect(start).toBe('2024-01-01')
expect(end).toBe('2024-01-31')
})
it('returns correct dates for monthly period 12 (December)', () => {
const { start, end } = calculatePeriodDates('monthly', 2024, 12)
expect(start).toBe('2024-12-01')
expect(end).toBe('2024-12-31')
})
it('returns correct dates for quarterly period', () => {
const q1 = calculatePeriodDates('quarterly', 2024, 1)
expect(q1.start).toBe('2024-01-01')
expect(q1.end).toBe('2024-03-31')
const q4 = calculatePeriodDates('quarterly', 2024, 4)
expect(q4.start).toBe('2024-10-01')
expect(q4.end).toBe('2024-12-31')
})
it('returns full year for yearly period', () => {
const { start, end } = calculatePeriodDates('yearly', 2024, 1)
expect(start).toBe('2024-01-01')
expect(end).toBe('2024-12-31')
})
})
describe('formatPeriodLabel', () => {
it('formats monthly period', () => {
expect(formatPeriodLabel('monthly', 2024, 1)).toBe('Januari 2024')
expect(formatPeriodLabel('monthly', 2024, 6)).toBe('Juni 2024')
expect(formatPeriodLabel('monthly', 2024, 12)).toBe('December 2024')
})
it('formats quarterly period', () => {
expect(formatPeriodLabel('quarterly', 2024, 3)).toBe('Kvartal 3 2024')
})
it('formats yearly period', () => {
expect(formatPeriodLabel('yearly', 2024, 1)).toBe('Helår 2024')
})
})
describe('getVatDeclarationSummary', () => {
it('calculates totals and detects payment', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 2500,
ruta06: 0,
ruta07: 0,
ruta10: 10000,
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 1000,
ruta49: 1500,
},
invoiceCount: 5,
transactionCount: 10,
breakdown: {
invoices: { ruta05: 2500, ruta06: 0, ruta07: 0, ruta10: 10000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
transactions: { ruta48: 800 },
receipts: { ruta48: 200 },
},
}
const summary = getVatDeclarationSummary(declaration)
expect(summary.totalOutputVat).toBe(2500)
expect(summary.totalInputVat).toBe(1000)
expect(summary.vatToPay).toBe(1500)
expect(summary.isRefund).toBe(false)
})
it('identifies refund when ruta49 is negative', () => {
const declaration: VatDeclaration = {
period: { type: 'monthly', year: 2024, period: 1, start: '2024-01-01', end: '2024-01-31' },
rutor: {
ruta05: 500,
ruta06: 0,
ruta07: 0,
ruta10: 2000,
ruta11: 0,
ruta12: 0,
ruta39: 0,
ruta40: 0,
ruta48: 3000,
ruta49: -2500,
},
invoiceCount: 1,
transactionCount: 20,
breakdown: {
invoices: { ruta05: 500, ruta06: 0, ruta07: 0, ruta10: 2000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
transactions: { ruta48: 3000 },
receipts: { ruta48: 0 },
},
}
const summary = getVatDeclarationSummary(declaration)
expect(summary.isRefund).toBe(true)
expect(summary.vatToPay).toBe(-2500)
})
})
// ============================================================
// Async tests — require Supabase mocks
// ============================================================
describe('calculateVatDeclaration', () => {
it('returns all zeros when no data exists', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta06).toBe(0)
expect(result.rutor.ruta07).toBe(0)
expect(result.rutor.ruta48).toBe(0)
expect(result.rutor.ruta49).toBe(0)
expect(result.invoiceCount).toBe(0)
expect(result.transactionCount).toBe(0)
})
it('maps invoice VAT to correct rutor by moms_ruta', async () => {
results = [
// 0: invoices — various moms_ruta values
{
data: [
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 5000, vat_amount: 600, moms_ruta: '06', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 3000, vat_amount: 180, moms_ruta: '07', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 8000, vat_amount: 0, moms_ruta: '39', subtotal_sek: null, vat_amount_sek: null },
{ subtotal: 12000, vat_amount: 0, moms_ruta: '40', subtotal_sek: null, vat_amount_sek: null },
],
error: null,
},
// 1: transactions (none)
{ data: [], error: null },
// 2: receipts (none)
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta07).toBe(180)
expect(result.rutor.ruta10).toBe(10000)
expect(result.rutor.ruta11).toBe(5000)
expect(result.rutor.ruta12).toBe(3000)
expect(result.rutor.ruta39).toBe(8000)
expect(result.rutor.ruta40).toBe(12000)
})
it('prefers subtotal_sek/vat_amount_sek for foreign currency invoices', async () => {
results = [
// 0: invoices — foreign currency with SEK conversion
{
data: [
{ subtotal: 1000, vat_amount: 250, moms_ruta: '05', subtotal_sek: 11000, vat_amount_sek: 2750 },
],
error: null,
},
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Should use _sek values
expect(result.rutor.ruta05).toBe(2750)
expect(result.rutor.ruta10).toBe(11000)
})
it('defaults to ruta05 when moms_ruta is null but VAT > 0', async () => {
results = [
// 0: invoices — null moms_ruta with VAT
{
data: [
{ subtotal: 4000, vat_amount: 1000, moms_ruta: null, subtotal_sek: null, vat_amount_sek: null },
],
error: null,
},
// 1: transactions
{ data: [], error: null },
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(1000)
expect(result.rutor.ruta10).toBe(4000)
})
it('calculates input VAT from transaction categories', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions — business expenses with categories
{
data: [
// 25% category: expense_software, amount -1250 → VAT = 1250 * 0.25/1.25 = 250
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
// 12% category: expense_travel, amount -1120 → VAT = 1120 * 0.12/1.12 = 120
{ amount: -1120, amount_sek: null, is_business: true, category: 'expense_travel' },
],
error: null,
},
// 2: receipts
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// 250 + 120 = 370
expect(result.rutor.ruta48).toBe(370)
})
it('sums VAT from confirmed receipts', async () => {
results = [
// 0: invoices
{ data: [], error: null },
// 1: transactions
{ data: [], error: null },
// 2: receipts — confirmed with vat_amount
{
data: [
{ status: 'confirmed', vat_amount: 59.8 },
{ status: 'confirmed', vat_amount: 125 },
],
error: null,
},
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(184.8)
})
it('calculates ruta49 as output minus input VAT', async () => {
results = [
// 0: invoices — 25% VAT
{
data: [
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
],
error: null,
},
// 1: transactions — 25% expense
{
data: [
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
],
error: null,
},
// 2: receipts
{
data: [
{ status: 'confirmed', vat_amount: 100 },
],
error: null,
},
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
// Output: 2500, Input: 250 + 100 = 350
expect(result.rutor.ruta49).toBe(2150)
})
})
describe('calculateVatDeclarationFromTaxCodes', () => {
it('maps journal lines to boxes via tax codes', async () => {
results = [
// 0: tax_codes
{
data: [
{
code: 'MP1',
user_id: null,
moms_basis_boxes: ['10'],
moms_tax_boxes: ['05'],
moms_input_boxes: [],
},
],
error: null,
},
// 1: journal_entry_lines with tax_code
{
data: [
{
tax_code: 'MP1',
debit_amount: 0,
credit_amount: 2500,
journal_entry_id: 'e1',
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
},
],
error: null,
},
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta10).toBe(2500)
})
it('user tax codes override system codes', async () => {
results = [
// 0: tax_codes — system and user with same code
{
data: [
{
code: 'MP1',
user_id: null,
moms_basis_boxes: ['10'],
moms_tax_boxes: ['05'],
moms_input_boxes: [],
},
{
code: 'MP1',
user_id: 'user-1',
moms_basis_boxes: ['11'],
moms_tax_boxes: ['06'],
moms_input_boxes: [],
},
],
error: null,
},
// 1: journal_entry_lines
{
data: [
{
tax_code: 'MP1',
debit_amount: 0,
credit_amount: 600,
journal_entry_id: 'e1',
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
},
],
error: null,
},
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
// User override maps to ruta06/ruta11 instead of ruta05/ruta10
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta06).toBe(600)
expect(result.rutor.ruta11).toBe(600)
})
it('returns all zeros when no lines have tax codes', async () => {
results = [
// 0: tax_codes
{ data: [], error: null },
// 1: journal_entry_lines — empty
{ data: [], error: null },
]
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta48).toBe(0)
expect(result.rutor.ruta49).toBe(0)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { createClient } from '@/lib/supabase/server'
export interface ARInvoiceDetail {
invoice_id: string
invoice_number: string
invoice_date: string
due_date: string
total: number
paid_amount: number
outstanding: number
days_overdue: number
currency: string
}
export interface ARLedgerEntry {
customer_id: string
customer_name: string
invoices: ARInvoiceDetail[]
current: number
days_1_30: number
days_31_60: number
days_61_90: number
days_90_plus: number
total_outstanding: number
}
export interface ARLedgerReport {
entries: ARLedgerEntry[]
total_outstanding: number
total_current: number
total_overdue: number
unpaid_count: number
}
/**
* Generate AR ledger (kundreskontra) with aging analysis.
* BFL 5 kap. 4 § — sidoordnad bokföring: outstanding customer invoices with aging.
*/
export async function generateARLedger(
userId: string,
asOfDate?: string
): Promise<ARLedgerReport> {
const supabase = await createClient()
const refDate = asOfDate ? new Date(asOfDate) : new Date()
// Fetch all unpaid/sent/overdue invoices with customer info
const { data: invoices, error } = await supabase
.from('invoices')
.select('*, customer:customers(id, name)')
.eq('user_id', userId)
.in('status', ['sent', 'overdue'])
if (error || !invoices) {
return {
entries: [],
total_outstanding: 0,
total_current: 0,
total_overdue: 0,
unpaid_count: 0,
}
}
// Group by customer and calculate aging
const byCustomer = new Map<string, ARLedgerEntry>()
for (const inv of invoices) {
const customerId = inv.customer_id
const customerName = inv.customer?.name || 'Okänd kund'
if (!byCustomer.has(customerId)) {
byCustomer.set(customerId, {
customer_id: customerId,
customer_name: customerName,
invoices: [],
current: 0,
days_1_30: 0,
days_31_60: 0,
days_61_90: 0,
days_90_plus: 0,
total_outstanding: 0,
})
}
const entry = byCustomer.get(customerId)!
const dueDate = new Date(inv.due_date)
const daysOverdue = Math.floor((refDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
const paidAmount = Number(inv.paid_amount) || 0
const total = Number(inv.total) || 0
const outstanding = Math.round((total - paidAmount) * 100) / 100
// Add invoice detail
entry.invoices.push({
invoice_id: inv.id,
invoice_number: inv.invoice_number || '',
invoice_date: inv.invoice_date || '',
due_date: inv.due_date,
total,
paid_amount: paidAmount,
outstanding,
days_overdue: Math.max(0, daysOverdue),
currency: inv.currency || 'SEK',
})
// Bucket by aging
if (daysOverdue <= 0) {
entry.current += outstanding
} else if (daysOverdue <= 30) {
entry.days_1_30 += outstanding
} else if (daysOverdue <= 60) {
entry.days_31_60 += outstanding
} else if (daysOverdue <= 90) {
entry.days_61_90 += outstanding
} else {
entry.days_90_plus += outstanding
}
entry.total_outstanding += outstanding
}
// Round all amounts and sort invoices within each customer
const entries = Array.from(byCustomer.values()).map((entry) => ({
...entry,
invoices: entry.invoices.sort((a, b) => a.due_date.localeCompare(b.due_date)),
current: Math.round(entry.current * 100) / 100,
days_1_30: Math.round(entry.days_1_30 * 100) / 100,
days_31_60: Math.round(entry.days_31_60 * 100) / 100,
days_61_90: Math.round(entry.days_61_90 * 100) / 100,
days_90_plus: Math.round(entry.days_90_plus * 100) / 100,
total_outstanding: Math.round(entry.total_outstanding * 100) / 100,
}))
// Sort by total outstanding descending
entries.sort((a, b) => b.total_outstanding - a.total_outstanding)
const total_outstanding = entries.reduce((sum, e) => sum + e.total_outstanding, 0)
const total_current = entries.reduce((sum, e) => sum + e.current, 0)
const total_overdue = total_outstanding - total_current
return {
entries,
total_outstanding: Math.round(total_outstanding * 100) / 100,
total_current: Math.round(total_current * 100) / 100,
total_overdue: Math.round(total_overdue * 100) / 100,
unpaid_count: invoices.length,
}
}
+53
View File
@@ -0,0 +1,53 @@
import { createClient } from '@/lib/supabase/server'
export interface ARReconciliationResult {
ar_ledger_total: number
account_1510_balance: number
difference: number
is_reconciled: boolean
}
/**
* Compare sum of open customer invoices against account 1510 balance.
* Account 1510 is debit-normal (asset): balance = debits - credits.
*/
export async function generateARReconciliation(
userId: string,
_periodId: string
): Promise<ARReconciliationResult> {
const supabase = await createClient()
// Get total outstanding from customer invoices
const { data: invoices } = await supabase
.from('invoices')
.select('total, paid_amount')
.eq('user_id', userId)
.in('status', ['sent', 'overdue'])
const arLedgerTotal = (invoices || [])
.reduce((sum, inv) => sum + ((Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)), 0)
// Get account 1510 balance from journal entry lines
const { data: journalLines } = await supabase
.from('journal_entry_lines')
.select('debit_amount, credit_amount, journal_entry_id')
.eq('account_number', '1510')
// Account 1510 is an asset: debit normal balance
// Balance = debits - credits
let account1510Balance = 0
if (journalLines) {
for (const line of journalLines) {
account1510Balance += (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
}
}
const difference = Math.round((arLedgerTotal - account1510Balance) * 100) / 100
return {
ar_ledger_total: Math.round(arLedgerTotal * 100) / 100,
account_1510_balance: Math.round(account1510Balance * 100) / 100,
difference,
is_reconciled: Math.abs(difference) < 0.01,
}
}
+183
View File
@@ -0,0 +1,183 @@
import { createClient } from '@/lib/supabase/server'
export interface GeneralLedgerLine {
date: string
voucher_series: string
voucher_number: number
description: string
source_type: string
debit: number
credit: number
balance: number
}
export interface GeneralLedgerAccount {
account_number: string
account_name: string
opening_balance: number
lines: GeneralLedgerLine[]
closing_balance: number
total_debit: number
total_credit: number
}
export interface GeneralLedgerReport {
accounts: GeneralLedgerAccount[]
period: { start: string; end: string }
}
/**
* Generate general ledger (huvudbok) for a fiscal period.
* BFL 5 kap. 1 § — systematisk ordning: all transactions grouped by account.
*/
export async function generateGeneralLedger(
userId: string,
periodId: string,
accountFrom?: string,
accountTo?: string
): Promise<GeneralLedgerReport> {
const supabase = await createClient()
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) {
return { accounts: [], period: { start: '', end: '' } }
}
// Fetch posted entries for this period
const { data: entries } = await supabase
.from('journal_entries')
.select('id, entry_date, voucher_number, voucher_series, description, source_type')
.eq('user_id', userId)
.eq('fiscal_period_id', periodId)
.in('status', ['posted', 'reversed'])
if (!entries || entries.length === 0) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
}
const entryIds = entries.map((e) => e.id)
const entryMap = new Map(entries.map((e) => [e.id, e]))
// Fetch lines for these entries
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry_id')
.in('journal_entry_id', entryIds)
if (!lines) {
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
}
// Fetch account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Compute opening balances: sum all posted lines from entries before this period
const { data: priorEntries } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.in('status', ['posted', 'reversed'])
.lt('entry_date', period.start_date)
const openingBalances = new Map<string, number>()
if (priorEntries && priorEntries.length > 0) {
const priorIds = priorEntries.map((e) => e.id)
const { data: priorLines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount')
.in('journal_entry_id', priorIds)
for (const line of priorLines || []) {
const current = openingBalances.get(line.account_number) || 0
openingBalances.set(
line.account_number,
current + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
)
}
}
// Group lines by account
const accountLines = new Map<string, GeneralLedgerLine[]>()
for (const line of lines) {
const entry = entryMap.get(line.journal_entry_id)
if (!entry) continue
const accNum = line.account_number
if (!accountLines.has(accNum)) {
accountLines.set(accNum, [])
}
accountLines.get(accNum)!.push({
date: entry.entry_date,
voucher_series: entry.voucher_series || 'A',
voucher_number: entry.voucher_number,
description: entry.description || '',
source_type: entry.source_type || '',
debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
balance: 0, // computed below
})
}
// Build account summaries
const result: GeneralLedgerAccount[] = []
for (const [accNum, accLines] of accountLines) {
// Apply optional account range filter
if (accountFrom && accNum < accountFrom) continue
if (accountTo && accNum > accountTo) continue
// Sort by date, then voucher number
accLines.sort((a, b) => {
const dateCompare = a.date.localeCompare(b.date)
if (dateCompare !== 0) return dateCompare
return a.voucher_number - b.voucher_number
})
const opening = Math.round((openingBalances.get(accNum) || 0) * 100) / 100
let runningBalance = opening
for (const line of accLines) {
runningBalance += line.debit - line.credit
line.balance = Math.round(runningBalance * 100) / 100
}
const totalDebit = accLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = accLines.reduce((sum, l) => sum + l.credit, 0)
result.push({
account_number: accNum,
account_name: accountNameMap.get(accNum) || `Konto ${accNum}`,
opening_balance: opening,
lines: accLines,
closing_balance: Math.round((opening + totalDebit - totalCredit) * 100) / 100,
total_debit: Math.round(totalDebit * 100) / 100,
total_credit: Math.round(totalCredit * 100) / 100,
})
}
// Sort by account number
result.sort((a, b) => a.account_number.localeCompare(b.account_number))
return {
accounts: result,
period: { start: period.start_date, end: period.end_date },
}
}
+131
View File
@@ -0,0 +1,131 @@
import { createClient } from '@/lib/supabase/server'
export interface JournalRegisterLine {
account_number: string
account_name: string
debit: number
credit: number
}
export interface JournalRegisterEntry {
voucher_series: string
voucher_number: number
date: string
description: string
source_type: string
status: string
lines: JournalRegisterLine[]
total_debit: number
total_credit: number
}
export interface JournalRegisterReport {
entries: JournalRegisterEntry[]
total_entries: number
total_debit: number
total_credit: number
period: { start: string; end: string }
}
/**
* Generate journal register (grundbok) for a fiscal period.
* BFL 5 kap. 1 § — registreringsordning: all vouchers in chronological registration order.
*/
export async function generateJournalRegister(
userId: string,
periodId: string
): Promise<JournalRegisterReport> {
const supabase = await createClient()
// Get fiscal period dates
const { data: period } = await supabase
.from('fiscal_periods')
.select('start_date, end_date')
.eq('id', periodId)
.eq('user_id', userId)
.single()
if (!period) {
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: '', end: '' } }
}
// Fetch posted/reversed entries ordered by voucher series then number (registration order)
const { data: entries } = await supabase
.from('journal_entries')
.select('id, entry_date, voucher_number, voucher_series, description, source_type, status')
.eq('user_id', userId)
.eq('fiscal_period_id', periodId)
.in('status', ['posted', 'reversed'])
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
if (!entries || entries.length === 0) {
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.start_date, end: period.end_date } }
}
const entryIds = entries.map((e) => e.id)
// Fetch lines for these entries
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entry_id')
.in('journal_entry_id', entryIds)
// Fetch account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Group lines by entry
const linesByEntry = new Map<string, JournalRegisterLine[]>()
for (const line of lines || []) {
if (!linesByEntry.has(line.journal_entry_id)) {
linesByEntry.set(line.journal_entry_id, [])
}
linesByEntry.get(line.journal_entry_id)!.push({
account_number: line.account_number,
account_name: accountNameMap.get(line.account_number) || `Konto ${line.account_number}`,
debit: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
credit: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
})
}
// Build result
const result: JournalRegisterEntry[] = entries.map((entry) => {
const entryLines = linesByEntry.get(entry.id) || []
// Sort lines by account number within each entry
entryLines.sort((a, b) => a.account_number.localeCompare(b.account_number))
const totalDebit = entryLines.reduce((sum, l) => sum + l.debit, 0)
const totalCredit = entryLines.reduce((sum, l) => sum + l.credit, 0)
return {
voucher_series: entry.voucher_series || 'A',
voucher_number: entry.voucher_number,
date: entry.entry_date,
description: entry.description || '',
source_type: entry.source_type || '',
status: entry.status,
lines: entryLines,
total_debit: Math.round(totalDebit * 100) / 100,
total_credit: Math.round(totalCredit * 100) / 100,
}
})
const grandTotalDebit = result.reduce((sum, e) => sum + e.total_debit, 0)
const grandTotalCredit = result.reduce((sum, e) => sum + e.total_credit, 0)
return {
entries: result,
total_entries: result.length,
total_debit: Math.round(grandTotalDebit * 100) / 100,
total_credit: Math.round(grandTotalCredit * 100) / 100,
period: { start: period.start_date, end: period.end_date },
}
}