chore: remove Sentry, consolidate migrations, add test coverage (#244)

* chore: remove Sentry, consolidate migrations, add test coverage

Remove @sentry/nextjs and all Sentry integration code — error tracking
now handled by Recapt. Consolidate 22 incremental migrations into a
single schema sync migration. Add 6 new test suites (auth, invoice
matching, VAT rules, opening balances) and extend report tests with
edge cases. Update Docker image name to gnubok, sync crontabs and
extension presets, fix CSP missing space, simplify journal entry
missing-document dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove viewer bank import migration never applied to production

20260413150000_viewer_bank_import_permissions.sql (PR #234) was merged
to main but never applied to the production database. It references
current_active_company_id() which does not exist in production either.
This breaks fresh installs and Supabase preview branches because the
migration runs before the consolidated schema sync.

Remove it so the migration chain matches production. The viewer bank
import RLS policies should be re-added in a future migration alongside
the helper functions they depend on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct delete policies for tables without company_id column

Seven tables in the generic delete-policy loop don't have a direct
company_id column, causing fresh installs to fail with "column
company_id does not exist". Fix by moving them out of the loop:

- invoice_items, journal_entry_lines, receipt_line_items,
  supplier_invoice_items → join through parent table
- extension_toggles, notification_settings, push_subscriptions →
  user-scoped (auth.uid() = user_id)

All policies match their existing production definitions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-15 10:52:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a3fea6fb7c
commit b387a77bfd
62 changed files with 2461 additions and 5486 deletions
@@ -172,6 +172,49 @@ describe('generateBalanceSheet', () => {
expect(report.total_equity_liabilities).toBe(32500)
})
it('handles negative asset balance (net credit on class 1 account)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000, closing_credit: 0 }),
// Receivables with net credit (customer overpayment)
makeRow({ account_number: '1510', account_name: 'Kundfordringar', account_class: 1, closing_debit: 0, closing_credit: 5000 }),
],
totalDebit: 50000,
totalCredit: 5000,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const receivables = report.asset_sections.find(s => s.rows.some(r => r.account_number === '1510'))
expect(receivables).toBeDefined()
expect(receivables!.rows.find(r => r.account_number === '1510')!.amount).toBe(-5000)
expect(report.total_assets).toBe(45000) // 50000 - 5000
})
it('rounding boundary: 0.004 rounds to 0 and is excluded, 0.005 rounds to 0.01 and is included', async () => {
// Amounts go through Math.round(x * 100) / 100 before the > 0.005 filter.
// Due to IEEE 754, 0.005 * 100 is slightly above 0.5, so Math.round rounds UP to 1,
// giving 0.01 which passes > 0.005. Meanwhile 0.004 rounds to 0.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 1000, closing_credit: 0 }),
makeRow({ account_number: '1940', account_name: 'Excluded', account_class: 1, closing_debit: 0.004, closing_credit: 0 }),
makeRow({ account_number: '1950', account_name: 'Included', account_class: 1, closing_debit: 0.005, closing_credit: 0 }),
],
totalDebit: 1000.009,
totalCredit: 0,
isBalanced: false,
})
const report = await generateBalanceSheet(supabase, 'company-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
// 1930 (1000) and 1950 (0.005 → rounds to 0.01) are included; 1940 (0.004 → rounds to 0) is excluded
expect(bankSection.rows).toHaveLength(2)
expect(bankSection.rows.map(r => r.account_number)).toEqual(['1930', '1950'])
})
it('uses Math.round for monetary precision on subtotals', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
@@ -171,6 +171,49 @@ describe('generateIncomeStatement', () => {
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
})
it('rounding boundary: 0.004 rounds to 0 and is excluded, 0.005 rounds to 0.01 and is included', async () => {
// Amounts go through Math.round(x * 100) / 100 before the > 0.005 filter.
// 0.004 → Math.round(0.4) = 0 → excluded. 0.005 → Math.round(0.5+ε) = 1 → 0.01 → included.
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
makeRow({ account_number: '3002', account_name: 'Excluded', account_class: 3, closing_credit: 0.004, closing_debit: 0 }),
makeRow({ account_number: '3003', account_name: 'Included', account_class: 3, closing_credit: 0.005, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 10000.009,
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
const section = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
// 3001 and 3003 included; 3002 excluded
expect(section.rows).toHaveLength(2)
expect(section.rows.map(r => r.account_number)).toEqual(['3001', '3003'])
})
it('subtotal includes sub-threshold rows that are filtered from visible rows', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 10000, closing_debit: 0 }),
// Amount 0.004 — below threshold, filtered from rows but included in subtotal
makeRow({ account_number: '3002', account_name: 'Micro', account_class: 3, closing_credit: 0.004, closing_debit: 0 }),
],
totalDebit: 0,
totalCredit: 10000.004,
isBalanced: false,
})
const report = await generateIncomeStatement(supabase, 'company-1', 'period-1')
const section = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
// Only the 10000 row is visible
expect(section.rows).toHaveLength(1)
// But subtotal includes both rows (Math.round((10000 + 0.004) * 100) / 100 = 10000)
expect(section.subtotal).toBe(10000)
})
it('ignores class 1-2 accounts (balance sheet)', async () => {
mockTrialBalance.mockResolvedValue({
rows: [
@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/supabase/fetch-all', () => ({
fetchAllRows: vi.fn(),
}))
import { getOpeningBalances } from '../opening-balances'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
const mockFetchAllRows = vi.mocked(fetchAllRows)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = {} as any
beforeEach(() => {
vi.clearAllMocks()
})
describe('getOpeningBalances', () => {
it('returns empty map and null obEntryId when period is null', async () => {
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', null)
expect(balances.size).toBe(0)
expect(obEntryId).toBeNull()
})
describe('with opening_balance_entry_id (OB entry path)', () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: 'ob-entry-123',
}
it('returns balances from the OB entry lines', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: 50000, credit_amount: 0 },
{ account_number: '2440', debit_amount: 0, credit_amount: 10000 },
])
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 50000, credit: 0 })
expect(balances.get('2440')).toEqual({ debit: 0, credit: 10000 })
expect(obEntryId).toBe('ob-entry-123')
})
it('aggregates multiple lines for the same account', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: 30000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 20000, credit_amount: 0 },
])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 50000, credit: 0 })
})
it('returns the obEntryId string', async () => {
mockFetchAllRows.mockResolvedValue([])
const { obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(obEntryId).toBe('ob-entry-123')
})
})
describe('without opening_balance_entry_id (fallback path)', () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: null,
}
it('computes balances from all prior entries', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: 100000, credit_amount: 5000 },
{ account_number: '3001', debit_amount: 0, credit_amount: 80000 },
])
const { balances, obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 100000, credit: 5000 })
expect(balances.get('3001')).toEqual({ debit: 0, credit: 80000 })
expect(obEntryId).toBeNull()
})
it('aggregates multiple lines per account', async () => {
mockFetchAllRows.mockResolvedValue([
{ account_number: '1510', debit_amount: 5000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 3000, credit_amount: 1000 },
])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1510')).toEqual({ debit: 8000, credit: 1000 })
})
it('returns null obEntryId', async () => {
mockFetchAllRows.mockResolvedValue([])
const { obEntryId } = await getOpeningBalances(supabase, 'company-1', period)
expect(obEntryId).toBeNull()
})
})
it('coerces null/undefined debit/credit to 0', async () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: 'ob-entry-1',
}
mockFetchAllRows.mockResolvedValue([
{ account_number: '1930', debit_amount: null, credit_amount: undefined },
])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.get('1930')).toEqual({ debit: 0, credit: 0 })
})
it('returns empty map when no lines found', async () => {
const period = {
period_start: '2025-01-01',
opening_balance_entry_id: null,
}
mockFetchAllRows.mockResolvedValue([])
const { balances } = await getOpeningBalances(supabase, 'company-1', period)
expect(balances.size).toBe(0)
})
})
@@ -817,4 +817,50 @@ describe('calculateVatDeclaration — reverse charge', () => {
// Only the posted entry's invoice (5000) should count
expect(result.rutor.ruta21).toBe(5000)
})
it('handles zero output VAT on some rates but non-zero on others', async () => {
// Only 12% sales in period — no 25% or 6% activity
results = [
{
data: [
{ account_number: '2621', debit_amount: 0, credit_amount: 600 },
{ account_number: '3002', debit_amount: 0, credit_amount: 5000 },
{ account_number: '2641', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
{ data: [], error: null },
{ data: [{ source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
expect(result.rutor.ruta10).toBe(0) // no 25% output VAT
expect(result.rutor.ruta11).toBe(600) // 12% output VAT
expect(result.rutor.ruta12).toBe(0) // no 6% output VAT
expect(result.rutor.ruta48).toBe(200)
// ruta49 = (0 + 600 + 0 + 0 + 0 + 0 + 0 + 0 + 0) - 200 = 400
expect(result.rutor.ruta49).toBe(400)
})
it('includes sub-öre ledger amounts in ruta sums (no threshold filtering)', async () => {
results = [
{
data: [
// Very small amount — VAT declaration uses raw summation, no 0.005 filtering
{ account_number: '2611', debit_amount: 0, credit_amount: 0.001 },
{ account_number: '3001', debit_amount: 0, credit_amount: 0.004 },
],
error: null,
},
{ data: [], error: null },
{ data: [], error: null },
]
const result = await calculateVatDeclaration(supabase, 'company-1', 'monthly', 2024, 1)
// Sub-öre amounts still included in ruta sums
expect(result.rutor.ruta10).toBe(0) // rounded: Math.round(0.001 * 100) / 100 = 0
expect(result.rutor.ruta05).toBe(0) // rounded: Math.round(0.004 * 100) / 100 = 0
})
})