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