feat: per-line VAT, invoice document types, ledger-based VAT declaration, bank reconciliation, and pagination
Per-line VAT rates: - Add generatePerRateLines() to group invoice items by vat_rate with separate revenue + VAT lines per rate group (invoice-entries.ts) - Add getAvailableVatRates() and getVatTreatmentForRate() (vat-rules.ts) - PDF template shows per-line VAT column and per-rate totals for mixed-rate invoices - Invoice create/review UI supports per-line rate selection - Types: add vat_rate/vat_amount to InvoiceItem, vat_rate to CreateInvoiceItemInput Invoice document types (proforma, delivery note): - Add InvoiceDocumentType, document_type and converted_from_id to Invoice type - PDF hides prices for delivery notes, adds proforma notice - Email templates support all document types - mark-paid skips journal entries for non-invoice document types - Migration 031: invoice_document_type Accounting method support: - Add AccountingMethod type (accrual/cash) - Migration 032: add_accounting_method column to company_settings VAT declaration rewrite: - Rewrite to read directly from general ledger (26xx/3xxx account lines) instead of aggregating invoices/transactions/receipts - ACCOUNT_RUTA mapping drives momsdeklaration boxes from GL balances Bank reconciliation: - Transaction ingest now pre-fetches unlinked GL lines and attempts auto-reconciliation during import - Add transaction.reconciled event type - Add ReconciliationMethod type and reconciliation_method on Transaction - Migration 030: bank_reconciliation - New reconciliation engine, API routes, and BankReconciliationView component Pagination (fetchAllRows): - New lib/supabase/fetch-all.ts overcomes PostgREST 1000-row limit - Adopted in all report generators, SIE/SRU export, account list APIs Fiscal period validation: - New validate-period-duration.ts enforces max 18 months per BFL 3 kap. - Applied in period-service.ts and fiscal-periods API Account mapper simplification: - Remove Levenshtein/fuzzy matching, use exact account number match only Swedbank parser improvements: - Support abbreviated headers (Clnr, Bokfdag, Radnr) - Use Referens column as counterparty Chart of accounts management: - Add DELETE endpoint with system account and usage protection - PUT uses partial updates - New AccountCombobox, AddAccountDialog, EditAccountDialog, ChartOfAccountsManager Tax deadline corrections: - Rewrite inkomstdeklaration_ab using Skatteverket lookup table - Rewrite arsredovisning deadline to 7 months after FY end per ÅRL 8:3 Onboarding first fiscal year: - Add first fiscal year toggle with date pickers and 18-month validation UI terminology: - Change "okategoriserad/kategorisera" to "obokförd/bokföra" throughout Report column fix: - Fix start_date/end_date to period_start/period_end in report queries Supplier invoice input: - CreateSupplierInvoiceItemInput uses amount field (legacy quantity/unit_price kept) Misc: - SIE import uses upsert for idempotent account creation - account-descriptions.ts falls back to BAS reference data - Add invoice_default_notes to CompanySettings - Update CLAUDE.md to reflect current project state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f1d187c005
commit
91e2c1705a
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'order']) {
|
||||
for (const m of ['select', 'eq', 'in', 'lt', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -51,7 +51,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('returns empty report when no entries in period', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries (empty)
|
||||
{ data: [], error: null },
|
||||
]
|
||||
@@ -64,7 +64,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('groups lines by account with correct totals and running balance', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries for this period
|
||||
{
|
||||
data: [
|
||||
@@ -122,7 +122,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('computes opening balance from prior period entries', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2025-01-01', end_date: '2025-12-31' }, error: null },
|
||||
{ data: { period_start: '2025-01-01', period_end: '2025-12-31' }, error: null },
|
||||
// 1: journal_entries for this period
|
||||
{
|
||||
data: [
|
||||
@@ -168,7 +168,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('filters accounts by account_from and account_to', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries
|
||||
{
|
||||
data: [
|
||||
@@ -200,7 +200,7 @@ describe('generateGeneralLedger', () => {
|
||||
it('sorts lines within account by date then voucher number', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: entries out of order
|
||||
{
|
||||
data: [
|
||||
@@ -236,7 +236,7 @@ describe('generateGeneralLedger', () => {
|
||||
|
||||
it('uses Math.round for monetary precision', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Precision', source_type: 'manual' },
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order']) {
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -49,7 +49,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('returns empty report when no entries in period', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('generateJournalRegister', () => {
|
||||
it('produces entries in registration order with correct totals', async () => {
|
||||
results = [
|
||||
// 0: fiscal_periods.single()
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
// 1: journal_entries (already ordered by series/number)
|
||||
{
|
||||
data: [
|
||||
@@ -117,7 +117,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('includes reversed entries with correct status', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Original', source_type: 'manual', status: 'reversed' },
|
||||
@@ -146,7 +146,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('resolves account names from chart_of_accounts', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: 'A', description: 'Test', source_type: 'manual', status: 'posted' },
|
||||
@@ -180,7 +180,7 @@ describe('generateJournalRegister', () => {
|
||||
|
||||
it('defaults voucher_series to A when null', async () => {
|
||||
results = [
|
||||
{ data: { start_date: '2024-01-01', end_date: '2024-12-31' }, error: null },
|
||||
{ data: { period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-01-15', voucher_number: 1, voucher_series: null, description: 'No series', source_type: 'manual', status: 'posted' },
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('generateMonthlyBreakdown', () => {
|
||||
it('returns empty months when no journal entries exist', async () => {
|
||||
// First call: fiscal period
|
||||
mockResult({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('generateMonthlyBreakdown', () => {
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-12-31' },
|
||||
data: { period_start: '2024-01-01', period_end: '2024-12-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
@@ -88,7 +88,7 @@ describe('generateMonthlyBreakdown', () => {
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-03-31' },
|
||||
data: { period_start: '2024-01-01', period_end: '2024-03-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
@@ -167,7 +167,7 @@ describe('generateMonthlyBreakdown', () => {
|
||||
eq: () => ({
|
||||
single: () =>
|
||||
Promise.resolve({
|
||||
data: { start_date: '2024-01-01', end_date: '2024-01-31' },
|
||||
data: { period_start: '2024-01-01', period_end: '2024-01-31' },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order']) {
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in']) {
|
||||
for (const m of ['select', 'eq', 'in', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
|
||||
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not']) {
|
||||
for (const m of ['select', 'eq', 'in', 'gte', 'lte', 'lt', 'or', 'not', 'range']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
formatPeriodLabel,
|
||||
getVatDeclarationSummary,
|
||||
calculateVatDeclaration,
|
||||
calculateVatDeclarationFromTaxCodes,
|
||||
} from '../vat-declaration'
|
||||
import type { VatDeclaration } from '@/types'
|
||||
|
||||
@@ -112,8 +111,8 @@ describe('getVatDeclarationSummary', () => {
|
||||
transactionCount: 10,
|
||||
breakdown: {
|
||||
invoices: { ruta05: 2500, ruta06: 0, ruta07: 0, ruta10: 10000, ruta11: 0, ruta12: 0, ruta39: 0, ruta40: 0 },
|
||||
transactions: { ruta48: 800 },
|
||||
receipts: { ruta48: 200 },
|
||||
transactions: { ruta48: 1000 },
|
||||
receipts: { ruta48: 0 },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -155,17 +154,17 @@ describe('getVatDeclarationSummary', () => {
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Async tests — require Supabase mocks
|
||||
// Ledger-based VAT declaration tests
|
||||
//
|
||||
// Mock queue order per call:
|
||||
// [0] fetchAllRows: journal_entry_lines (VAT-relevant accounts)
|
||||
// [1] entry counts: journal_entries source_type
|
||||
// ============================================================
|
||||
|
||||
describe('calculateVatDeclaration', () => {
|
||||
it('returns all zeros when no data exists', async () => {
|
||||
it('returns all zeros when no ledger lines exist', async () => {
|
||||
results = [
|
||||
// 0: invoices
|
||||
{ data: [], error: null },
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
@@ -180,23 +179,20 @@ describe('calculateVatDeclaration', () => {
|
||||
expect(result.transactionCount).toBe(0)
|
||||
})
|
||||
|
||||
it('maps invoice VAT to correct rutor by moms_ruta', async () => {
|
||||
it('sums output VAT from 2611/2621/2631 credit balances', async () => {
|
||||
results = [
|
||||
// 0: invoices — various moms_ruta values
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 5000, vat_amount: 600, moms_ruta: '06', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 3000, vat_amount: 180, moms_ruta: '07', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 8000, vat_amount: 0, moms_ruta: '39', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ subtotal: 12000, vat_amount: 0, moms_ruta: '40', subtotal_sek: null, vat_amount_sek: null },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
|
||||
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
|
||||
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions (none)
|
||||
{ data: [], error: null },
|
||||
// 2: receipts (none)
|
||||
{ data: [], error: null },
|
||||
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
@@ -207,224 +203,166 @@ describe('calculateVatDeclaration', () => {
|
||||
expect(result.rutor.ruta10).toBe(10000)
|
||||
expect(result.rutor.ruta11).toBe(5000)
|
||||
expect(result.rutor.ruta12).toBe(3000)
|
||||
expect(result.invoiceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('sums input VAT from 2641 debit balance', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '2641', debit_amount: 250, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 120, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta48).toBe(370)
|
||||
expect(result.transactionCount).toBe(2)
|
||||
})
|
||||
|
||||
it('includes calculated input VAT (2645) from EU reverse charge in ruta48', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '2645', debit_amount: 500, credit_amount: 0 },
|
||||
{ account_number: '2641', debit_amount: 200, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// Both 2641 and 2645 debit balances sum into ruta48
|
||||
expect(result.rutor.ruta48).toBe(700)
|
||||
})
|
||||
|
||||
it('maps EU/export revenue to ruta39/ruta40', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
{ account_number: '3308', debit_amount: 0, credit_amount: 8000 },
|
||||
{ account_number: '3305', debit_amount: 0, credit_amount: 12000 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta39).toBe(8000)
|
||||
expect(result.rutor.ruta40).toBe(12000)
|
||||
})
|
||||
|
||||
it('prefers subtotal_sek/vat_amount_sek for foreign currency invoices', async () => {
|
||||
it('handles credit notes as net reduction on revenue/VAT accounts', async () => {
|
||||
results = [
|
||||
// 0: invoices — foreign currency with SEK conversion
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 1000, vat_amount: 250, moms_ruta: '05', subtotal_sek: 11000, vat_amount_sek: 2750 },
|
||||
// Invoice: C2611 2500, C3001 10000
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
// Credit note reversal: D2611 625, D3001 2500
|
||||
{ account_number: '2611', debit_amount: 625, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 2500, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// Should use _sek values
|
||||
expect(result.rutor.ruta05).toBe(2750)
|
||||
expect(result.rutor.ruta10).toBe(11000)
|
||||
})
|
||||
|
||||
it('defaults to ruta05 when moms_ruta is null but VAT > 0', async () => {
|
||||
results = [
|
||||
// 0: invoices — null moms_ruta with VAT
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 4000, vat_amount: 1000, moms_ruta: null, subtotal_sek: null, vat_amount_sek: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(1000)
|
||||
expect(result.rutor.ruta10).toBe(4000)
|
||||
})
|
||||
|
||||
it('calculates input VAT from transaction categories', async () => {
|
||||
results = [
|
||||
// 0: invoices
|
||||
{ data: [], error: null },
|
||||
// 1: transactions — business expenses with categories
|
||||
{
|
||||
data: [
|
||||
// 25% category: expense_software, amount -1250 → VAT = 1250 * 0.25/1.25 = 250
|
||||
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
|
||||
// 12% category: expense_travel, amount -1120 → VAT = 1120 * 0.12/1.12 = 120
|
||||
{ amount: -1120, amount_sek: null, is_business: true, category: 'expense_travel' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 2: receipts
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// 250 + 120 = 370
|
||||
expect(result.rutor.ruta48).toBe(370)
|
||||
})
|
||||
|
||||
it('sums VAT from confirmed receipts', async () => {
|
||||
results = [
|
||||
// 0: invoices
|
||||
{ data: [], error: null },
|
||||
// 1: transactions
|
||||
{ data: [], error: null },
|
||||
// 2: receipts — confirmed with vat_amount
|
||||
{
|
||||
data: [
|
||||
{ status: 'confirmed', vat_amount: 59.8 },
|
||||
{ status: 'confirmed', vat_amount: 125 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta48).toBe(184.8)
|
||||
// Net: 2500 - 625 = 1875 output VAT, 10000 - 2500 = 7500 revenue
|
||||
expect(result.rutor.ruta05).toBe(1875)
|
||||
expect(result.rutor.ruta10).toBe(7500)
|
||||
expect(result.invoiceCount).toBe(2)
|
||||
})
|
||||
|
||||
it('calculates ruta49 as output minus input VAT', async () => {
|
||||
results = [
|
||||
// 0: invoices — 25% VAT
|
||||
{
|
||||
data: [
|
||||
{ subtotal: 10000, vat_amount: 2500, moms_ruta: '05', subtotal_sek: null, vat_amount_sek: null },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: transactions — 25% expense
|
||||
{
|
||||
data: [
|
||||
{ amount: -1250, amount_sek: null, is_business: true, category: 'expense_software' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 2: receipts
|
||||
{
|
||||
data: [
|
||||
{ status: 'confirmed', vat_amount: 100 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '2641', debit_amount: 350, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// Output: 2500, Input: 250 + 100 = 350
|
||||
expect(result.rutor.ruta49).toBe(2150)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateVatDeclarationFromTaxCodes', () => {
|
||||
it('maps journal lines to boxes via tax codes', async () => {
|
||||
results = [
|
||||
// 0: tax_codes
|
||||
{
|
||||
data: [
|
||||
{
|
||||
code: 'MP1',
|
||||
user_id: null,
|
||||
moms_basis_boxes: ['10'],
|
||||
moms_tax_boxes: ['05'],
|
||||
moms_input_boxes: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: journal_entry_lines with tax_code
|
||||
{
|
||||
data: [
|
||||
{
|
||||
tax_code: 'MP1',
|
||||
debit_amount: 0,
|
||||
credit_amount: 2500,
|
||||
journal_entry_id: 'e1',
|
||||
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(2500)
|
||||
expect(result.rutor.ruta10).toBe(2500)
|
||||
expect(result.rutor.ruta48).toBe(350)
|
||||
expect(result.rutor.ruta49).toBe(2150) // 2500 - 350
|
||||
})
|
||||
|
||||
it('user tax codes override system codes', async () => {
|
||||
it('detects refund when input VAT exceeds output VAT', async () => {
|
||||
results = [
|
||||
// 0: tax_codes — system and user with same code
|
||||
{
|
||||
data: [
|
||||
{
|
||||
code: 'MP1',
|
||||
user_id: null,
|
||||
moms_basis_boxes: ['10'],
|
||||
moms_tax_boxes: ['05'],
|
||||
moms_input_boxes: [],
|
||||
},
|
||||
{
|
||||
code: 'MP1',
|
||||
user_id: 'user-1',
|
||||
moms_basis_boxes: ['11'],
|
||||
moms_tax_boxes: ['06'],
|
||||
moms_input_boxes: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
// 1: journal_entry_lines
|
||||
{
|
||||
data: [
|
||||
{
|
||||
tax_code: 'MP1',
|
||||
debit_amount: 0,
|
||||
credit_amount: 600,
|
||||
journal_entry_id: 'e1',
|
||||
journal_entries: { user_id: 'user-1', entry_date: '2024-01-15', status: 'posted' },
|
||||
},
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 500 },
|
||||
{ account_number: '2641', debit_amount: 3000, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
|
||||
|
||||
// User override maps to ruta06/ruta11 instead of ruta05/ruta10
|
||||
expect(result.rutor.ruta05).toBe(0)
|
||||
expect(result.rutor.ruta06).toBe(600)
|
||||
expect(result.rutor.ruta11).toBe(600)
|
||||
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
|
||||
})
|
||||
|
||||
it('returns all zeros when no lines have tax codes', async () => {
|
||||
it('accepts accountingMethod parameter for backward compatibility', async () => {
|
||||
results = [
|
||||
// 0: tax_codes
|
||||
{ data: [], error: null },
|
||||
// 1: journal_entry_lines — empty
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclarationFromTaxCodes('user-1', 'monthly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(0)
|
||||
expect(result.rutor.ruta48).toBe(0)
|
||||
// Should not throw — parameter accepted but not used
|
||||
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1, 'cash')
|
||||
expect(result.rutor.ruta49).toBe(0)
|
||||
})
|
||||
|
||||
it('handles all three VAT rates in a single period', async () => {
|
||||
results = [
|
||||
{
|
||||
data: [
|
||||
// 25% rate: 10,000 revenue, 2,500 VAT
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 10000 },
|
||||
{ account_number: '2611', debit_amount: 0, credit_amount: 2500 },
|
||||
// 12% rate: 5,000 revenue, 600 VAT
|
||||
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
|
||||
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
|
||||
// 6% rate: 3,000 revenue, 180 VAT
|
||||
{ account_number: '3003', debit_amount: 0, credit_amount: 3000 },
|
||||
{ account_number: '2631', debit_amount: 0, credit_amount: 180 },
|
||||
// Input VAT from purchases
|
||||
{ account_number: '2641', debit_amount: 1000, credit_amount: 0 },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateVatDeclaration('user-1', 'quarterly', 2024, 1)
|
||||
|
||||
expect(result.rutor.ruta05).toBe(2500)
|
||||
expect(result.rutor.ruta06).toBe(600)
|
||||
expect(result.rutor.ruta07).toBe(180)
|
||||
expect(result.rutor.ruta10).toBe(10000)
|
||||
expect(result.rutor.ruta11).toBe(5000)
|
||||
expect(result.rutor.ruta12).toBe(3000)
|
||||
expect(result.rutor.ruta48).toBe(1000)
|
||||
// Output: 2500 + 600 + 180 = 3280, Input: 1000 → Pay: 2280
|
||||
expect(result.rutor.ruta49).toBe(2280)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
export interface GeneralLedgerLine {
|
||||
date: string
|
||||
@@ -41,7 +42,7 @@ export async function generateGeneralLedger(
|
||||
// Get fiscal period dates
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('start_date, end_date')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
@@ -59,7 +60,7 @@ export async function generateGeneralLedger(
|
||||
.in('status', ['posted', 'reversed'])
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
|
||||
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
@@ -72,17 +73,20 @@ export async function generateGeneralLedger(
|
||||
.in('journal_entry_id', entryIds)
|
||||
|
||||
if (!lines) {
|
||||
return { accounts: [], period: { start: period.start_date, end: period.end_date } }
|
||||
return { accounts: [], period: { start: period.period_start, end: period.period_end } }
|
||||
}
|
||||
|
||||
// Fetch account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts || []) {
|
||||
for (const acc of accounts) {
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
@@ -92,7 +96,7 @@ export async function generateGeneralLedger(
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['posted', 'reversed'])
|
||||
.lt('entry_date', period.start_date)
|
||||
.lt('entry_date', period.period_start)
|
||||
|
||||
const openingBalances = new Map<string, number>()
|
||||
|
||||
@@ -178,6 +182,6 @@ export async function generateGeneralLedger(
|
||||
|
||||
return {
|
||||
accounts: result,
|
||||
period: { start: period.start_date, end: period.end_date },
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
|
||||
export interface JournalRegisterLine {
|
||||
account_number: string
|
||||
@@ -40,7 +41,7 @@ export async function generateJournalRegister(
|
||||
// Get fiscal period dates
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('start_date, end_date')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
@@ -60,7 +61,7 @@ export async function generateJournalRegister(
|
||||
.order('voucher_number', { ascending: true })
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.start_date, end: period.end_date } }
|
||||
return { entries: [], total_entries: 0, total_debit: 0, total_credit: 0, period: { start: period.period_start, end: period.period_end } }
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
@@ -72,13 +73,16 @@ export async function generateJournalRegister(
|
||||
.in('journal_entry_id', entryIds)
|
||||
|
||||
// Fetch account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts || []) {
|
||||
for (const acc of accounts) {
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
@@ -126,6 +130,6 @@ export async function generateJournalRegister(
|
||||
total_entries: result.length,
|
||||
total_debit: Math.round(grandTotalDebit * 100) / 100,
|
||||
total_credit: Math.round(grandTotalCredit * 100) / 100,
|
||||
period: { start: period.start_date, end: period.end_date },
|
||||
period: { start: period.period_start, end: period.period_end },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function generateMonthlyBreakdown(
|
||||
// Get the fiscal period date range
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('start_date, end_date')
|
||||
.select('period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
@@ -67,8 +67,8 @@ export async function generateMonthlyBreakdown(
|
||||
const monthMap = new Map<number, { income: number; expenses: number }>()
|
||||
|
||||
// Initialize all months in the period range
|
||||
const startDate = new Date(period.start_date)
|
||||
const endDate = new Date(period.end_date)
|
||||
const startDate = new Date(period.period_start)
|
||||
const endDate = new Date(period.period_end)
|
||||
const startMonth = startDate.getMonth()
|
||||
const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -29,12 +30,15 @@ export async function generateSIEExport(
|
||||
}
|
||||
|
||||
// Fetch all accounts
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
const accounts = await fetchAllRows(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
// Fetch all posted journal entries with lines
|
||||
const { data: entries } = await supabase
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type { TrialBalanceRow } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -83,13 +84,16 @@ async function generateTrialBalanceManual(
|
||||
}
|
||||
|
||||
// Get account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('user_id', userId)
|
||||
const accounts = await fetchAllRows<{ account_number: string; account_name: string; account_class: number }>(({ from, to }) =>
|
||||
supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('user_id', userId)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountMap = new Map<string, { name: string; class: number }>()
|
||||
for (const acc of accounts || []) {
|
||||
for (const acc of accounts) {
|
||||
accountMap.set(acc.account_number, {
|
||||
name: acc.account_name,
|
||||
class: acc.account_class,
|
||||
|
||||
+120
-400
@@ -1,25 +1,46 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import type {
|
||||
VatDeclaration,
|
||||
VatDeclarationRutor,
|
||||
VatPeriodType,
|
||||
Invoice,
|
||||
Transaction,
|
||||
Receipt,
|
||||
TaxCode,
|
||||
AccountingMethod,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Calculate VAT declaration (Momsdeklaration) for a given period
|
||||
* Calculate VAT declaration (Momsdeklaration) for a given period.
|
||||
*
|
||||
* Aggregates data from:
|
||||
* - Invoices: Utgående moms (output VAT) based on moms_ruta
|
||||
* - Transactions: Ingående moms (input VAT) from categorized expenses
|
||||
* - Receipts: Ingående moms from confirmed receipts
|
||||
* Reads directly from the general ledger — sums posted journal entry lines
|
||||
* on 26xx (VAT) and 3xxx (revenue) accounts for the period. This makes the
|
||||
* momsdeklaration a pure projection from the double-entry bookkeeping ledger.
|
||||
*
|
||||
* Returns VAT rutor according to Swedish tax authority format.
|
||||
* The accounting method (accrual vs cash) is already reflected in when
|
||||
* journal entries were created by the entry generators, so no separate
|
||||
* filtering logic is needed here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Account-to-ruta mapping for the Swedish momsdeklaration.
|
||||
*
|
||||
* Output VAT (26xx): net credit balance feeds output VAT boxes.
|
||||
* Input VAT (2641/2645): net debit balance feeds ruta 48.
|
||||
* Revenue (3xxx): net credit balance feeds underlag (basis) boxes.
|
||||
*/
|
||||
const ACCOUNT_RUTA: Record<string, { box: keyof VatDeclarationRutor; side: 'credit' | 'debit' }> = {
|
||||
'2611': { box: 'ruta05', side: 'credit' },
|
||||
'2621': { box: 'ruta06', side: 'credit' },
|
||||
'2631': { box: 'ruta07', side: 'credit' },
|
||||
'2641': { box: 'ruta48', side: 'debit' },
|
||||
'2645': { box: 'ruta48', side: 'debit' },
|
||||
'3001': { box: 'ruta10', side: 'credit' },
|
||||
'3002': { box: 'ruta11', side: 'credit' },
|
||||
'3003': { box: 'ruta12', side: 'credit' },
|
||||
'3305': { box: 'ruta40', side: 'credit' },
|
||||
'3308': { box: 'ruta39', side: 'credit' },
|
||||
}
|
||||
|
||||
const VAT_ACCOUNTS = Object.keys(ACCOUNT_RUTA)
|
||||
|
||||
/**
|
||||
* Calculate period start and end dates
|
||||
*/
|
||||
@@ -79,298 +100,119 @@ function round(value: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to calculate VAT declaration
|
||||
* Calculate VAT declaration from the general ledger.
|
||||
*
|
||||
* Sums posted journal entry lines on 26xx and 3xxx accounts:
|
||||
* - 2611/2621/2631 credit balance -> ruta 05/06/07 (output VAT)
|
||||
* - 2641/2645 debit balance -> ruta 48 (input VAT)
|
||||
* - 3001/3002/3003 credit balance -> ruta 10/11/12 (revenue basis)
|
||||
* - 3308/3305 credit balance -> ruta 39/40 (EU/export)
|
||||
* - ruta 49 = (05 + 06 + 07) - 48
|
||||
*
|
||||
* The accounting method parameter is accepted for backward compatibility
|
||||
* but not used — the method is already baked into journal entry timing.
|
||||
*/
|
||||
export async function calculateVatDeclaration(
|
||||
userId: string,
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number
|
||||
period: number,
|
||||
_accountingMethod: AccountingMethod = 'accrual'
|
||||
): Promise<VatDeclaration> {
|
||||
const supabase = await createClient()
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
|
||||
// Fetch invoices for the period
|
||||
const { data: invoices, error: invoicesError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.gte('invoice_date', start)
|
||||
.lte('invoice_date', end)
|
||||
.in('status', ['sent', 'paid', 'overdue'])
|
||||
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
|
||||
const lines = await fetchAllRows<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
account_number,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entries!inner (user_id, entry_date, status)
|
||||
`)
|
||||
.in('account_number', VAT_ACCOUNTS)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('journal_entries.entry_date', start)
|
||||
.lte('journal_entries.entry_date', end)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
if (invoicesError) {
|
||||
console.error('Error fetching invoices:', invoicesError)
|
||||
// Aggregate debit/credit totals per account
|
||||
const totals = new Map<string, { debit: number; credit: number }>()
|
||||
for (const line of lines) {
|
||||
const t = totals.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
t.debit += Number(line.debit_amount) || 0
|
||||
t.credit += Number(line.credit_amount) || 0
|
||||
totals.set(line.account_number, t)
|
||||
}
|
||||
|
||||
// Fetch transactions with business expenses for the period
|
||||
const { data: transactions, error: transactionsError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.gte('date', start)
|
||||
.lte('date', end)
|
||||
.eq('is_business', true)
|
||||
.lt('amount', 0) // Expenses are negative
|
||||
|
||||
if (transactionsError) {
|
||||
console.error('Error fetching transactions:', transactionsError)
|
||||
}
|
||||
|
||||
// Fetch confirmed receipts for the period
|
||||
const { data: receipts, error: receiptsError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.gte('receipt_date', start)
|
||||
.lte('receipt_date', end)
|
||||
.eq('status', 'confirmed')
|
||||
|
||||
if (receiptsError) {
|
||||
console.error('Error fetching receipts:', receiptsError)
|
||||
}
|
||||
|
||||
// Calculate invoice-based VAT (utgående moms)
|
||||
const invoiceVat = calculateInvoiceVat(invoices as Invoice[] || [])
|
||||
|
||||
// Calculate input VAT from transactions
|
||||
const transactionVat = calculateTransactionInputVat(transactions as Transaction[] || [])
|
||||
|
||||
// Calculate input VAT from receipts
|
||||
const receiptVat = calculateReceiptInputVat(receipts as Receipt[] || [])
|
||||
|
||||
// Total ingående moms (input VAT to deduct)
|
||||
const totalInputVat = round(transactionVat + receiptVat)
|
||||
|
||||
// Total utgående moms (output VAT to pay)
|
||||
const totalOutputVat = round(invoiceVat.ruta05 + invoiceVat.ruta06 + invoiceVat.ruta07)
|
||||
|
||||
// Moms att betala/återfå (VAT to pay or receive back)
|
||||
const vatToPay = round(totalOutputVat - totalInputVat)
|
||||
|
||||
// Map account balances to momsdeklaration boxes
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: invoiceVat.ruta05,
|
||||
ruta06: invoiceVat.ruta06,
|
||||
ruta07: invoiceVat.ruta07,
|
||||
ruta10: invoiceVat.ruta10,
|
||||
ruta11: invoiceVat.ruta11,
|
||||
ruta12: invoiceVat.ruta12,
|
||||
ruta39: invoiceVat.ruta39,
|
||||
ruta40: invoiceVat.ruta40,
|
||||
ruta48: totalInputVat,
|
||||
ruta49: vatToPay,
|
||||
ruta05: 0, ruta06: 0, ruta07: 0,
|
||||
ruta10: 0, ruta11: 0, ruta12: 0,
|
||||
ruta39: 0, ruta40: 0,
|
||||
ruta48: 0, ruta49: 0,
|
||||
}
|
||||
|
||||
for (const [account, mapping] of Object.entries(ACCOUNT_RUTA)) {
|
||||
const t = totals.get(account)
|
||||
if (!t) continue
|
||||
const balance = mapping.side === 'credit'
|
||||
? t.credit - t.debit
|
||||
: t.debit - t.credit
|
||||
rutor[mapping.box] = round(rutor[mapping.box] + balance)
|
||||
}
|
||||
|
||||
rutor.ruta49 = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07 - rutor.ruta48)
|
||||
|
||||
// Count journal entries by source type for metadata
|
||||
const { data: entryCounts } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('source_type')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.gte('entry_date', start)
|
||||
.lte('entry_date', end)
|
||||
|
||||
const invoiceSources = new Set([
|
||||
'invoice_created', 'invoice_paid', 'invoice_cash_payment', 'credit_note',
|
||||
])
|
||||
let invoiceCount = 0
|
||||
let transactionCount = 0
|
||||
for (const e of entryCounts || []) {
|
||||
if (invoiceSources.has(e.source_type)) invoiceCount++
|
||||
else if (e.source_type === 'bank_transaction') transactionCount++
|
||||
}
|
||||
|
||||
return {
|
||||
period: {
|
||||
type: periodType,
|
||||
year,
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
period: { type: periodType, year, period, start, end },
|
||||
rutor,
|
||||
invoiceCount: (invoices || []).length,
|
||||
transactionCount: (transactions || []).length,
|
||||
invoiceCount,
|
||||
transactionCount,
|
||||
breakdown: {
|
||||
invoices: {
|
||||
ruta05: invoiceVat.ruta05,
|
||||
ruta06: invoiceVat.ruta06,
|
||||
ruta07: invoiceVat.ruta07,
|
||||
ruta10: invoiceVat.ruta10,
|
||||
ruta11: invoiceVat.ruta11,
|
||||
ruta12: invoiceVat.ruta12,
|
||||
ruta39: invoiceVat.ruta39,
|
||||
ruta40: invoiceVat.ruta40,
|
||||
},
|
||||
transactions: {
|
||||
ruta48: round(transactionVat),
|
||||
},
|
||||
receipts: {
|
||||
ruta48: round(receiptVat),
|
||||
ruta05: rutor.ruta05,
|
||||
ruta06: rutor.ruta06,
|
||||
ruta07: rutor.ruta07,
|
||||
ruta10: rutor.ruta10,
|
||||
ruta11: rutor.ruta11,
|
||||
ruta12: rutor.ruta12,
|
||||
ruta39: rutor.ruta39,
|
||||
ruta40: rutor.ruta40,
|
||||
},
|
||||
transactions: { ruta48: rutor.ruta48 },
|
||||
receipts: { ruta48: 0 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate VAT from invoices
|
||||
*/
|
||||
function calculateInvoiceVat(invoices: Invoice[]): {
|
||||
ruta05: number
|
||||
ruta06: number
|
||||
ruta07: number
|
||||
ruta10: number
|
||||
ruta11: number
|
||||
ruta12: number
|
||||
ruta39: number
|
||||
ruta40: number
|
||||
} {
|
||||
let ruta05 = 0 // Utgående moms 25%
|
||||
let ruta06 = 0 // Utgående moms 12%
|
||||
let ruta07 = 0 // Utgående moms 6%
|
||||
let ruta10 = 0 // Underlag 25%
|
||||
let ruta11 = 0 // Underlag 12%
|
||||
let ruta12 = 0 // Underlag 6%
|
||||
let ruta39 = 0 // EU tjänster
|
||||
let ruta40 = 0 // Export
|
||||
|
||||
for (const invoice of invoices) {
|
||||
// Use subtotal_sek if available (for foreign currency invoices), otherwise subtotal
|
||||
const subtotal = invoice.subtotal_sek ?? invoice.subtotal
|
||||
const vatAmount = invoice.vat_amount_sek ?? invoice.vat_amount
|
||||
|
||||
switch (invoice.moms_ruta) {
|
||||
case '05':
|
||||
// Standard 25% VAT
|
||||
ruta05 += vatAmount
|
||||
ruta10 += subtotal
|
||||
break
|
||||
case '06':
|
||||
// Reduced 12% VAT
|
||||
ruta06 += vatAmount
|
||||
ruta11 += subtotal
|
||||
break
|
||||
case '07':
|
||||
// Reduced 6% VAT
|
||||
ruta07 += vatAmount
|
||||
ruta12 += subtotal
|
||||
break
|
||||
case '39':
|
||||
// EU reverse charge - no VAT charged, but report the value
|
||||
ruta39 += subtotal
|
||||
break
|
||||
case '40':
|
||||
// Export outside EU - no VAT charged, but report the value
|
||||
ruta40 += subtotal
|
||||
break
|
||||
default:
|
||||
// Default to 25% if moms_ruta is not set but there's VAT
|
||||
if (vatAmount > 0) {
|
||||
ruta05 += vatAmount
|
||||
ruta10 += subtotal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ruta05: round(ruta05),
|
||||
ruta06: round(ruta06),
|
||||
ruta07: round(ruta07),
|
||||
ruta10: round(ruta10),
|
||||
ruta11: round(ruta11),
|
||||
ruta12: round(ruta12),
|
||||
ruta39: round(ruta39),
|
||||
ruta40: round(ruta40),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate input VAT from business expense transactions
|
||||
*
|
||||
* For Swedish business expenses with 25% VAT, we can deduct the VAT.
|
||||
* This is a simplified calculation - in reality, the journal entry
|
||||
* would have the exact VAT amounts.
|
||||
*/
|
||||
function calculateTransactionInputVat(transactions: Transaction[]): number {
|
||||
let inputVat = 0
|
||||
|
||||
for (const transaction of transactions) {
|
||||
// Only process business expenses (amount is negative)
|
||||
if (!transaction.is_business || transaction.amount >= 0) continue
|
||||
|
||||
// Use amount_sek if available, otherwise amount
|
||||
const expenseAmount = Math.abs(transaction.amount_sek ?? transaction.amount)
|
||||
|
||||
// Estimate VAT based on category
|
||||
// Most Swedish business expenses have 25% VAT
|
||||
// Some categories might have reduced rates or no VAT
|
||||
const vatRate = getVatRateForCategory(transaction.category)
|
||||
|
||||
if (vatRate > 0) {
|
||||
// Extract VAT from total (VAT-inclusive) amount
|
||||
// VAT = total * rate / (1 + rate)
|
||||
const vat = (expenseAmount * vatRate) / (1 + vatRate)
|
||||
inputVat += vat
|
||||
}
|
||||
}
|
||||
|
||||
return inputVat
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VAT rate for expense category
|
||||
*/
|
||||
function getVatRateForCategory(category: string | null): number {
|
||||
// Categories that typically have 25% VAT
|
||||
const standard25Categories = [
|
||||
'expense_equipment',
|
||||
'expense_software',
|
||||
'expense_office',
|
||||
'expense_marketing',
|
||||
'expense_professional_services',
|
||||
'expense_education',
|
||||
'expense_other',
|
||||
]
|
||||
|
||||
// Categories with 12% VAT (e.g., food/restaurants, but only 50% deductible for representation)
|
||||
const reduced12Categories = [
|
||||
'expense_travel', // Hotels, some transport
|
||||
]
|
||||
|
||||
// Categories with 6% VAT
|
||||
const reduced6Categories: string[] = [
|
||||
// Books, newspapers, etc.
|
||||
]
|
||||
|
||||
// No VAT deduction
|
||||
const noVatCategories = [
|
||||
'private',
|
||||
'uncategorized',
|
||||
'income_services',
|
||||
'income_products',
|
||||
'income_other',
|
||||
]
|
||||
|
||||
if (!category || noVatCategories.includes(category)) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (standard25Categories.includes(category)) {
|
||||
return 0.25
|
||||
}
|
||||
|
||||
if (reduced12Categories.includes(category)) {
|
||||
return 0.12
|
||||
}
|
||||
|
||||
if (reduced6Categories.includes(category)) {
|
||||
return 0.06
|
||||
}
|
||||
|
||||
// Default to 25% for unrecognized expense categories
|
||||
return 0.25
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate input VAT from confirmed receipts
|
||||
*/
|
||||
function calculateReceiptInputVat(receipts: Receipt[]): number {
|
||||
let inputVat = 0
|
||||
|
||||
for (const receipt of receipts) {
|
||||
// Only confirmed receipts
|
||||
if (receipt.status !== 'confirmed') continue
|
||||
|
||||
// Use the extracted VAT amount if available
|
||||
if (receipt.vat_amount && receipt.vat_amount > 0) {
|
||||
inputVat += receipt.vat_amount
|
||||
}
|
||||
}
|
||||
|
||||
return inputVat
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of the VAT declaration for display
|
||||
*/
|
||||
@@ -420,125 +262,3 @@ export function formatPeriodLabel(
|
||||
return `${year}`
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tax-code-driven VAT declaration (new approach)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Calculate VAT declaration using tax codes from journal entry lines.
|
||||
*
|
||||
* This is the new, tax-code-driven approach that sums journal_entry_lines
|
||||
* grouped by tax_code, then maps via the tax_codes table to moms boxes.
|
||||
* Falls back to the legacy invoice/transaction/receipt approach for
|
||||
* lines without tax codes.
|
||||
*/
|
||||
export async function calculateVatDeclarationFromTaxCodes(
|
||||
userId: string,
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number
|
||||
): Promise<VatDeclaration> {
|
||||
const supabase = await createClient()
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
|
||||
// Fetch tax codes for this user (including system codes)
|
||||
const { data: taxCodesData } = await supabase
|
||||
.from('tax_codes')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${userId},user_id.is.null`)
|
||||
|
||||
const taxCodes = (taxCodesData as TaxCode[]) || []
|
||||
const taxCodeMap = new Map<string, TaxCode>()
|
||||
for (const tc of taxCodes) {
|
||||
if (!taxCodeMap.has(tc.code) || tc.user_id) {
|
||||
taxCodeMap.set(tc.code, tc)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch posted journal entry lines with tax_code in the period
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
tax_code,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry_id,
|
||||
journal_entries!inner (
|
||||
user_id,
|
||||
entry_date,
|
||||
status
|
||||
)
|
||||
`)
|
||||
.not('tax_code', 'is', null)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('journal_entries.entry_date', start)
|
||||
.lte('journal_entries.entry_date', end)
|
||||
|
||||
// Aggregate amounts by moms box
|
||||
const boxTotals = new Map<string, number>()
|
||||
|
||||
for (const line of lines || []) {
|
||||
if (!line.tax_code) continue
|
||||
|
||||
const taxCode = taxCodeMap.get(line.tax_code)
|
||||
if (!taxCode) continue
|
||||
|
||||
const amount = Math.abs(Number(line.debit_amount || 0) - Number(line.credit_amount || 0))
|
||||
|
||||
// Map to all relevant boxes
|
||||
for (const box of [...taxCode.moms_basis_boxes, ...taxCode.moms_tax_boxes, ...taxCode.moms_input_boxes]) {
|
||||
const current = boxTotals.get(box) || 0
|
||||
boxTotals.set(box, current + amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Build rutor from box totals
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: round(boxTotals.get('05') || 0),
|
||||
ruta06: round(boxTotals.get('06') || 0),
|
||||
ruta07: round(boxTotals.get('07') || 0),
|
||||
ruta10: round(boxTotals.get('10') || 0),
|
||||
ruta11: round(boxTotals.get('11') || 0),
|
||||
ruta12: round(boxTotals.get('12') || 0),
|
||||
ruta39: round(boxTotals.get('39') || 0),
|
||||
ruta40: round(boxTotals.get('40') || 0),
|
||||
ruta48: round(boxTotals.get('48') || 0),
|
||||
ruta49: 0,
|
||||
}
|
||||
|
||||
const totalOutputVat = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07)
|
||||
rutor.ruta49 = round(totalOutputVat - rutor.ruta48)
|
||||
|
||||
return {
|
||||
period: {
|
||||
type: periodType,
|
||||
year,
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
rutor,
|
||||
invoiceCount: 0,
|
||||
transactionCount: (lines || []).length,
|
||||
breakdown: {
|
||||
invoices: {
|
||||
ruta05: 0,
|
||||
ruta06: 0,
|
||||
ruta07: 0,
|
||||
ruta10: 0,
|
||||
ruta11: 0,
|
||||
ruta12: 0,
|
||||
ruta39: 0,
|
||||
ruta40: 0,
|
||||
},
|
||||
transactions: {
|
||||
ruta48: 0,
|
||||
},
|
||||
receipts: {
|
||||
ruta48: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user