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
+237
View File
@@ -0,0 +1,237 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
import {
generateApiKey,
hashApiKey,
extractBearerToken,
validateScopes,
hasScope,
validateApiKey,
DEFAULT_SCOPES,
} from '../api-keys'
import { createClient } from '@supabase/supabase-js'
const mockCreateClient = vi.mocked(createClient)
beforeEach(() => {
vi.clearAllMocks()
})
// ============================================================
// generateApiKey
// ============================================================
describe('generateApiKey', () => {
it('returns key starting with "gnubok_sk_"', () => {
const { key } = generateApiKey()
expect(key.startsWith('gnubok_sk_')).toBe(true)
})
it('returns 64-char hex SHA-256 hash', () => {
const { hash } = generateApiKey()
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('returns prefix of KEY_PREFIX + 8 chars', () => {
const { key, prefix } = generateApiKey()
expect(prefix).toBe(key.slice(0, 'gnubok_sk_'.length + 8))
})
it('generates unique keys on successive calls', () => {
const a = generateApiKey()
const b = generateApiKey()
expect(a.key).not.toBe(b.key)
expect(a.hash).not.toBe(b.hash)
})
it('hash matches hashApiKey(key)', () => {
const { key, hash } = generateApiKey()
expect(hashApiKey(key)).toBe(hash)
})
})
// ============================================================
// hashApiKey
// ============================================================
describe('hashApiKey', () => {
it('returns 64-char hex string', () => {
const hash = hashApiKey('gnubok_sk_test-key')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashApiKey('gnubok_sk_deterministic')
const hash2 = hashApiKey('gnubok_sk_deterministic')
expect(hash1).toBe(hash2)
})
it('produces different hashes for different inputs', () => {
const hash1 = hashApiKey('gnubok_sk_key-a')
const hash2 = hashApiKey('gnubok_sk_key-b')
expect(hash1).not.toBe(hash2)
})
})
// ============================================================
// extractBearerToken
// ============================================================
describe('extractBearerToken', () => {
it('extracts token from valid Bearer header', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer my-secret-token' },
})
expect(extractBearerToken(request)).toBe('my-secret-token')
})
it('returns null when no authorization header', () => {
const request = new Request('http://localhost')
expect(extractBearerToken(request)).toBeNull()
})
it('returns null when header is not Bearer scheme', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Basic dXNlcjpwYXNz' },
})
expect(extractBearerToken(request)).toBeNull()
})
it('handles token with special characters', () => {
const request = new Request('http://localhost', {
headers: { authorization: 'Bearer gnubok_sk_abc+def/ghi=jkl' },
})
expect(extractBearerToken(request)).toBe('gnubok_sk_abc+def/ghi=jkl')
})
})
// ============================================================
// validateScopes
// ============================================================
describe('validateScopes', () => {
it('returns null for null input', () => {
expect(validateScopes(null)).toBeNull()
})
it('returns null for undefined input', () => {
expect(validateScopes(undefined)).toBeNull()
})
it('returns null for non-array input', () => {
expect(validateScopes('transactions:read')).toBeNull()
expect(validateScopes(42)).toBeNull()
expect(validateScopes({ scope: 'transactions:read' })).toBeNull()
})
it('filters to only valid API_KEY_SCOPES', () => {
const result = validateScopes(['transactions:read', 'invalid:scope', 'reports:read'])
expect(result).toEqual(['transactions:read', 'reports:read'])
})
it('returns null when no valid scopes remain after filter', () => {
expect(validateScopes(['invalid:scope', 'also:invalid'])).toBeNull()
})
it('preserves valid scopes from mixed input', () => {
const result = validateScopes(['customers:write', 'bogus', 'invoices:read'])
expect(result).toEqual(['customers:write', 'invoices:read'])
})
})
// ============================================================
// hasScope
// ============================================================
describe('hasScope', () => {
it('returns true when scope present in array', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'transactions:read')).toBe(true)
})
it('returns false when scope absent', () => {
expect(hasScope(['transactions:read', 'reports:read'], 'invoices:write')).toBe(false)
})
})
// ============================================================
// validateApiKey
// ============================================================
describe('validateApiKey', () => {
function setupMockRpc(response: { data: unknown; error: unknown }) {
const mockRpc = vi.fn().mockResolvedValue(response)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockCreateClient.mockReturnValue({ rpc: mockRpc } as any)
}
it('rejects keys not starting with "gnubok_sk_"', async () => {
const result = await validateApiKey('invalid-key-format')
expect(result).toEqual({ error: 'Invalid API key format', status: 401 })
})
it('rejects when RPC returns error', async () => {
setupMockRpc({ data: null, error: { message: 'db error' } })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('rejects when RPC returns empty data array', async () => {
setupMockRpc({ data: [], error: null })
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Invalid API key', status: 401 })
})
it('returns rate limit error when rate_limited is true', async () => {
setupMockRpc({
data: [{ user_id: 'u1', company_id: 'c1', scopes: null, rate_limited: true }],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({ error: 'Rate limit exceeded', status: 429 })
})
it('returns userId, companyId, scopes on success', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: ['transactions:read', 'reports:read'],
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
scopes: ['transactions:read', 'reports:read'],
})
})
it('falls back to DEFAULT_SCOPES when row.scopes is null', async () => {
setupMockRpc({
data: [{
user_id: 'user-123',
company_id: 'company-456',
scopes: null,
rate_limited: false,
}],
error: null,
})
const result = await validateApiKey('gnubok_sk_test-key-value')
expect(result).toEqual({
userId: 'user-123',
companyId: 'company-456',
scopes: DEFAULT_SCOPES,
})
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { generateInviteToken, hashInviteToken, getInviteExpiry } from '../invite-tokens'
describe('generateInviteToken', () => {
it('returns token starting with "gnubok_inv_"', () => {
const { token } = generateInviteToken()
expect(token.startsWith('gnubok_inv_')).toBe(true)
})
it('returns a 64-char hex SHA-256 hash', () => {
const { hash } = generateInviteToken()
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('hash matches hashInviteToken(token)', () => {
const { token, hash } = generateInviteToken()
expect(hashInviteToken(token)).toBe(hash)
})
it('generates unique tokens on successive calls', () => {
const a = generateInviteToken()
const b = generateInviteToken()
expect(a.token).not.toBe(b.token)
expect(a.hash).not.toBe(b.hash)
})
})
describe('hashInviteToken', () => {
it('returns 64-char hex string', () => {
const hash = hashInviteToken('gnubok_inv_test-token')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashInviteToken('gnubok_inv_deterministic')
const hash2 = hashInviteToken('gnubok_inv_deterministic')
expect(hash1).toBe(hash2)
})
})
describe('getInviteExpiry', () => {
afterEach(() => {
vi.useRealTimers()
})
it('returns a Date exactly 7 days in the future', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const expiry = getInviteExpiry()
expect(expiry.toISOString()).toBe('2026-04-21T12:00:00.000Z')
})
})
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createAuthCode, decryptAuthCode, verifyPkce, hashAuthCode } from '../oauth-codes'
beforeEach(() => {
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-secret-for-oauth-tests')
})
afterEach(() => {
vi.unstubAllEnvs()
vi.useRealTimers()
})
// ============================================================
// createAuthCode + decryptAuthCode round-trip
// ============================================================
describe('createAuthCode + decryptAuthCode round-trip', () => {
it('encrypts and decrypts preserving userId, codeChallenge, redirectUri', () => {
const payload = {
userId: 'user-123',
codeChallenge: 'challenge-abc',
redirectUri: 'https://claude.ai/api/callback',
}
const code = createAuthCode(payload)
const decrypted = decryptAuthCode(code)
expect(decrypted).not.toBeNull()
expect(decrypted!.userId).toBe('user-123')
expect(decrypted!.codeChallenge).toBe('challenge-abc')
expect(decrypted!.redirectUri).toBe('https://claude.ai/api/callback')
})
it('sets exp approximately 5 minutes in future', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
const decrypted = decryptAuthCode(code)
expect(decrypted).not.toBeNull()
// exp should be Date.now() + 5 * 60 * 1000
const expectedExp = new Date('2026-04-14T12:00:00Z').getTime() + 5 * 60 * 1000
expect(decrypted!.exp).toBe(expectedExp)
})
it('returns null for expired code', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
// Advance past 5 minute TTL
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
const decrypted = decryptAuthCode(code)
expect(decrypted).toBeNull()
})
it('returns null for tampered ciphertext', () => {
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
// Flip a character in the middle of the encrypted string
const chars = code.split('')
const mid = Math.floor(chars.length / 2)
chars[mid] = chars[mid] === 'A' ? 'B' : 'A'
const tampered = chars.join('')
expect(decryptAuthCode(tampered)).toBeNull()
})
it('returns null for completely invalid base64url', () => {
expect(decryptAuthCode('not-a-valid-code!!!')).toBeNull()
})
it('throws when SUPABASE_SERVICE_ROLE_KEY is not set', () => {
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
expect(() =>
createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
})
})
// ============================================================
// verifyPkce
// ============================================================
describe('verifyPkce', () => {
it('returns true when SHA256(verifier) matches challenge', () => {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
// Compute expected challenge using base64url(SHA-256(verifier))
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
expect(verifyPkce(verifier, challenge)).toBe(true)
})
it('returns false when verifier does not match challenge', () => {
expect(verifyPkce('correct-verifier', 'wrong-challenge')).toBe(false)
})
})
// ============================================================
// hashAuthCode
// ============================================================
describe('hashAuthCode', () => {
it('returns 64-char hex string', () => {
const hash = hashAuthCode('some-auth-code')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashAuthCode('deterministic-code')
const hash2 = hashAuthCode('deterministic-code')
expect(hash1).toBe(hash2)
})
})
-1
View File
@@ -25,7 +25,6 @@ const REQUIRED_EXTENSION_VARS = [
] as const
const OPTIONAL_VARS = [
'SENTRY_DSN',
'LANGFUSE_SECRET_KEY',
'LANGFUSE_PUBLIC_KEY',
] as const
@@ -0,0 +1,355 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
amountsMatchExact,
amountsMatchFuzzy,
customerNameMatches,
calculateMatchScore,
findMatchingInvoices,
getBestInvoiceMatch,
} from '../invoice-matching'
import type { Transaction, Invoice, Customer } from '@/types'
import { makeTransaction, makeInvoice, makeCustomer, createMockSupabase } from '@/tests/helpers'
// ============================================================
// amountsMatchExact
// ============================================================
describe('amountsMatchExact', () => {
it('matches identical amounts', () => {
expect(amountsMatchExact(1000, 1000)).toBe(true)
})
it('matches amounts differing only in floating-point noise', () => {
// 1000.004 rounds to 1000.00, same as 1000.00
expect(amountsMatchExact(1000.004, 1000)).toBe(true)
})
it('rejects amounts differing by 0.01', () => {
expect(amountsMatchExact(1000.01, 1000)).toBe(false)
})
})
// ============================================================
// amountsMatchFuzzy
// ============================================================
describe('amountsMatchFuzzy', () => {
it('matches amounts within 1% tolerance', () => {
// 990 vs 1000 → diff=10, tolerance=min(10,500)=10 → 10 <= 10
expect(amountsMatchFuzzy(990, 1000)).toBe(true)
})
it('rejects amounts outside 1% tolerance', () => {
// 980 vs 1000 → diff=20, tolerance=min(10,500)=10 → 20 > 10
expect(amountsMatchFuzzy(980, 1000)).toBe(false)
})
it('returns false when invoiceTotal is 0', () => {
expect(amountsMatchFuzzy(100, 0)).toBe(false)
})
it('caps tolerance at 500 SEK for large invoices', () => {
// 100000 vs 100600 → diff=600, tolerance=min(100000*0.01=1000, 500)=500 → 600 > 500
expect(amountsMatchFuzzy(100600, 100000)).toBe(false)
// 100000 vs 100400 → diff=400, tolerance=500 → 400 <= 500
expect(amountsMatchFuzzy(100400, 100000)).toBe(true)
})
})
// ============================================================
// customerNameMatches
// ============================================================
describe('customerNameMatches', () => {
it('matches when significant word from customer name appears in description', () => {
expect(customerNameMatches('Kontorsbolaget AB', 'Betalning Kontorsbolaget', null)).toBe(true)
})
it('ignores words shorter than 3 characters', () => {
// "AB" is 2 chars, filtered out
expect(customerNameMatches('AB', 'AB payment', null)).toBe(false)
})
it('matches against merchant_name', () => {
expect(customerNameMatches('Kontorsbolaget', 'Random description', 'Kontorsbolaget AB')).toBe(true)
})
it('returns false when customerName is undefined', () => {
expect(customerNameMatches(undefined as unknown as string, 'Description', null)).toBe(false)
})
it('is case-insensitive', () => {
expect(customerNameMatches('KONTORSBOLAGET', 'betalning kontorsbolaget', null)).toBe(true)
})
})
// ============================================================
// calculateMatchScore
// ============================================================
describe('calculateMatchScore', () => {
function makeTx(overrides: Partial<Transaction> = {}): Transaction {
return makeTransaction({ amount: 12500, description: 'Betalning Kundnamn AB', merchant_name: null, ...overrides })
}
function makeInv(overrides: Partial<Invoice & { customer?: Customer }> = {}): Invoice & { customer?: Customer } {
return {
...makeInvoice({ total: 12500 }),
customer: makeCustomer({ name: 'Kundnamn AB' }),
...overrides,
}
}
it('returns 0.95 for exact amount + customer name match', () => {
const { confidence } = calculateMatchScore(makeTx(), makeInv())
expect(confidence).toBe(0.95)
})
it('returns 0.80 for exact amount without customer match', () => {
const { confidence } = calculateMatchScore(
makeTx({ description: 'Random payment', merchant_name: null }),
makeInv({ customer: makeCustomer({ name: 'Completely Different Co' }) })
)
expect(confidence).toBe(0.80)
})
it('returns 0.70 for fuzzy amount + customer name match', () => {
// 12375 is within 1% of 12500 (diff=125, tolerance=min(125,500)=125)
const { confidence } = calculateMatchScore(
makeTx({ amount: 12375 }),
makeInv()
)
expect(confidence).toBe(0.70)
})
it('returns 0.50 for fuzzy amount without customer match', () => {
const { confidence } = calculateMatchScore(
makeTx({ amount: 12375, description: 'Random', merchant_name: null }),
makeInv({ customer: makeCustomer({ name: 'Completely Different Co' }) })
)
expect(confidence).toBe(0.50)
})
it('returns 0 confidence when no amount match', () => {
const { confidence } = calculateMatchScore(
makeTx({ amount: 99999 }),
makeInv()
)
expect(confidence).toBe(0)
})
})
// ============================================================
// findMatchingInvoices (integration — mock Supabase)
// ============================================================
describe('findMatchingInvoices', () => {
const { supabase, mockResult } = createMockSupabase()
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty for expense transactions (amount <= 0)', async () => {
const tx = makeTransaction({ amount: -1000 })
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toEqual([])
})
it('returns empty when Supabase query errors', async () => {
mockResult({ data: null, error: { message: 'db error' } })
const tx = makeTransaction({ amount: 12500 })
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toEqual([])
})
it('matches by OCR reference with confidence 0.99', async () => {
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
mockResult({
data: [
{ ...makeInvoice({ invoice_number: 'F-2024001', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }) },
],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.99)
expect(result[0].matchReason).toContain('OCR-referens')
})
it('returns immediately on OCR match without further scoring', async () => {
const tx = makeTransaction({ amount: 12500, reference: 'F-2024001' })
mockResult({
data: [
{ ...makeInvoice({ invoice_number: 'F-2024001', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }) },
// Second invoice with exact amount — should not be scored
{
...makeInvoice({ id: 'inv-2', invoice_number: 'F-2024002', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Test match description' }),
},
],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
// Only the OCR match should be returned
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.99)
})
it('scores exact amount + customer name at 0.95', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Betalning Testbolaget', reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Testbolaget AB' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.95)
})
it('scores exact amount only at 0.80', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated text', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Completely Different Name' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.80)
})
it('sorts matches by confidence descending', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Betalning Testbolaget', merchant_name: null, reference: null })
mockResult({
data: [
// Exact amount, no name match → 0.80
{
...makeInvoice({ id: 'inv-low', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Nope Corp' }),
},
// Exact amount + name match → 0.95
{
...makeInvoice({ id: 'inv-high', total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Testbolaget AB' }),
},
],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(2)
expect(result[0].confidence).toBe(0.95)
expect(result[1].confidence).toBe(0.80)
})
it('filters out matches below 0.50 threshold', async () => {
const tx = makeTransaction({ amount: 99999, description: 'No match', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 50000, status: 'sent', remaining_amount: 50000, currency: 'SEK' }),
customer: makeCustomer({ name: 'Irrelevant' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toEqual([])
})
it('uses remaining_amount for partially_paid invoices', async () => {
const tx = makeTransaction({ amount: 5000, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, remaining_amount: 5000, status: 'partially_paid', currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].confidence).toBe(0.80) // exact amount match
})
it('skips invoices with non-matching currency', async () => {
const tx = makeTransaction({ amount: 12500, currency: 'SEK', description: 'Payment', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, remaining_amount: 12500, status: 'sent', currency: 'EUR', total_sek: null }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
const result = await findMatchingInvoices(supabase as never, 'company-1', tx)
// EUR invoice with no total_sek → currency mismatch → skipped
expect(result).toEqual([])
})
})
// ============================================================
// getBestInvoiceMatch
// ============================================================
describe('getBestInvoiceMatch', () => {
const { supabase, mockResult } = createMockSupabase()
beforeEach(() => {
vi.clearAllMocks()
})
it('returns highest-confidence match when above minConfidence', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx)
expect(result).not.toBeNull()
expect(result!.confidence).toBe(0.80)
})
it('returns null when best match below minConfidence', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
// minConfidence 0.90 → 0.80 match rejected
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx, 0.90)
expect(result).toBeNull()
})
it('defaults minConfidence to 0.80', async () => {
const tx = makeTransaction({ amount: 12500, description: 'Unrelated', merchant_name: null, reference: null })
mockResult({
data: [{
...makeInvoice({ total: 12500, status: 'sent', remaining_amount: 12500, currency: 'SEK' }),
customer: makeCustomer({ name: 'Different' }),
}],
error: null,
})
// Exact amount only → 0.80, meets default threshold
const result = await getBestInvoiceMatch(supabase as never, 'company-1', tx)
expect(result).not.toBeNull()
})
})
+281
View File
@@ -0,0 +1,281 @@
import { describe, it, expect } from 'vitest'
import {
getAvailableVatRates,
getVatTreatmentForRate,
getVatRules,
calculateVat,
calculateTotal,
formatVatRate,
getVatTreatmentLabel,
getVatSummaryFromItems,
getMomsRutaDescription,
} from '../vat-rules'
// ============================================================
// getAvailableVatRates
// ============================================================
describe('getAvailableVatRates', () => {
it('returns all 4 Swedish rates for individual customer', () => {
const rates = getAvailableVatRates('individual')
expect(rates).toHaveLength(4)
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
expect(rates.map((r) => r.treatment)).toEqual([
'standard_25',
'reduced_12',
'reduced_6',
'exempt',
])
})
it('returns all 4 Swedish rates for swedish_business', () => {
const rates = getAvailableVatRates('swedish_business')
expect(rates).toHaveLength(4)
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
})
it('returns only reverse_charge 0% for eu_business with validated VAT', () => {
const rates = getAvailableVatRates('eu_business', true)
expect(rates).toHaveLength(1)
expect(rates[0]).toEqual({
rate: 0,
label: '0% (omvänd skattskyldighet)',
treatment: 'reverse_charge',
})
})
it('returns all 4 rates for eu_business WITHOUT validated VAT', () => {
// ML compliance: must charge Swedish VAT when VAT number not validated
const rates = getAvailableVatRates('eu_business', false)
expect(rates).toHaveLength(4)
expect(rates.map((r) => r.rate)).toEqual([25, 12, 6, 0])
})
it('returns only export 0% for non_eu_business', () => {
const rates = getAvailableVatRates('non_eu_business')
expect(rates).toHaveLength(1)
expect(rates[0]).toEqual({
rate: 0,
label: '0% (export)',
treatment: 'export',
})
})
it('defaults vatNumberValidated to false', () => {
// eu_business without explicit vatNumberValidated should get all rates
const rates = getAvailableVatRates('eu_business')
expect(rates).toHaveLength(4)
})
})
// ============================================================
// getVatTreatmentForRate
// ============================================================
describe('getVatTreatmentForRate', () => {
it('maps 25 → standard_25', () => {
expect(getVatTreatmentForRate(25)).toBe('standard_25')
})
it('maps 12 → reduced_12', () => {
expect(getVatTreatmentForRate(12)).toBe('reduced_12')
})
it('maps 6 → reduced_6', () => {
expect(getVatTreatmentForRate(6)).toBe('reduced_6')
})
it('maps 0 → exempt', () => {
expect(getVatTreatmentForRate(0)).toBe('exempt')
})
it('defaults unknown rates to standard_25', () => {
expect(getVatTreatmentForRate(15)).toBe('standard_25')
expect(getVatTreatmentForRate(99)).toBe('standard_25')
})
})
// ============================================================
// getVatRules
// ============================================================
describe('getVatRules', () => {
it('returns standard_25 / rate 25 / ruta 05 for individual', () => {
const rules = getVatRules('individual')
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
it('returns standard_25 / rate 25 / ruta 05 for swedish_business', () => {
const rules = getVatRules('swedish_business')
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
it('returns reverse_charge / rate 0 / ruta 39 for eu_business with validated VAT', () => {
const rules = getVatRules('eu_business', true)
expect(rules.treatment).toBe('reverse_charge')
expect(rules.rate).toBe(0)
expect(rules.momsRuta).toBe('39')
// Verify text references Article 196 of Council Directive 2006/112/EC
expect(rules.reverseChargeText).toContain('Article 196')
expect(rules.reverseChargeText).toContain('2006/112/EC')
})
it('returns standard_25 / rate 25 / ruta 05 for eu_business WITHOUT validated VAT', () => {
const rules = getVatRules('eu_business', false)
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
it('returns export / rate 0 / ruta 40 for non_eu_business', () => {
const rules = getVatRules('non_eu_business')
expect(rules.treatment).toBe('export')
expect(rules.rate).toBe(0)
expect(rules.momsRuta).toBe('40')
// Verify text references ML 10 kap
expect(rules.reverseChargeText).toContain('ML 10 kap')
})
it('defaults to standard_25 for unknown customerType', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rules = getVatRules('unknown_type' as any)
expect(rules).toEqual({
treatment: 'standard_25',
rate: 25,
momsRuta: '05',
})
})
})
// ============================================================
// calculateVat
// Pin: vatRate is a whole number (25, not 0.25).
// Formula: Math.round(subtotal * vatRate) / 100
// ============================================================
describe('calculateVat', () => {
it('calculates 25% of 10000 → 2500', () => {
expect(calculateVat(10000, 25)).toBe(2500)
})
it('calculates 12% of 5000 → 600', () => {
expect(calculateVat(5000, 12)).toBe(600)
})
it('calculates 6% of 3000 → 180', () => {
expect(calculateVat(3000, 6)).toBe(180)
})
it('calculates 0% of 10000 → 0', () => {
expect(calculateVat(10000, 0)).toBe(0)
})
it('rounds correctly: 99.99 at 25% → 25', () => {
// Math.round(99.99 * 25) / 100 = Math.round(2499.75) / 100 = 2500 / 100 = 25
expect(calculateVat(99.99, 25)).toBe(25)
})
})
// ============================================================
// calculateTotal
// ============================================================
describe('calculateTotal', () => {
it('returns subtotal + VAT rounded: 10000 at 25% → 12500', () => {
expect(calculateTotal(10000, 25)).toBe(12500)
})
it('handles 0% VAT: total equals subtotal', () => {
expect(calculateTotal(5000, 0)).toBe(5000)
})
})
// ============================================================
// formatVatRate
// ============================================================
describe('formatVatRate', () => {
it('formats 25 as "25%"', () => {
expect(formatVatRate(25)).toBe('25%')
})
it('formats 0 as "0%"', () => {
expect(formatVatRate(0)).toBe('0%')
})
})
// ============================================================
// getVatTreatmentLabel
// ============================================================
describe('getVatTreatmentLabel', () => {
it('returns correct Swedish label for each treatment', () => {
expect(getVatTreatmentLabel('standard_25')).toBe('25% moms')
expect(getVatTreatmentLabel('reduced_12')).toBe('12% moms')
expect(getVatTreatmentLabel('reduced_6')).toBe('6% moms')
expect(getVatTreatmentLabel('reverse_charge')).toBe('Omvänd skattskyldighet (0%)')
expect(getVatTreatmentLabel('export')).toBe('Export (0%)')
expect(getVatTreatmentLabel('exempt')).toBe('Momsfritt')
})
})
// ============================================================
// getVatSummaryFromItems
// ============================================================
describe('getVatSummaryFromItems', () => {
it('returns single rate info when all items have same rate', () => {
const result = getVatSummaryFromItems([{ vat_rate: 25 }, { vat_rate: 25 }])
expect(result.isMixed).toBe(false)
expect(result.rate).toBe(25)
expect(result.treatment).toBe('standard_25')
expect(result.label).toBe('25% moms')
})
it('returns isMixed=true when items have different rates', () => {
const result = getVatSummaryFromItems([{ vat_rate: 25 }, { vat_rate: 12 }])
expect(result.isMixed).toBe(true)
expect(result.rate).toBeNull()
expect(result.treatment).toBeNull()
expect(result.label).toBe('Blandade momssatser')
})
it('treats null vat_rate as 0', () => {
const result = getVatSummaryFromItems([{ vat_rate: null }, { vat_rate: null }])
expect(result.isMixed).toBe(false)
expect(result.rate).toBe(0)
expect(result.treatment).toBe('exempt')
})
})
// ============================================================
// getMomsRutaDescription
// ============================================================
describe('getMomsRutaDescription', () => {
it('maps ruta 05 → "Utgående moms 25%"', () => {
expect(getMomsRutaDescription('05')).toBe('Utgående moms 25%')
})
it('maps ruta 39 → "Försäljning av tjänster till annat EU-land"', () => {
expect(getMomsRutaDescription('39')).toBe('Försäljning av tjänster till annat EU-land')
})
it('maps ruta 40 → "Export utanför EU"', () => {
expect(getMomsRutaDescription('40')).toBe('Export utanför EU')
})
it('returns the ruta string itself for unknown rutor', () => {
expect(getMomsRutaDescription('99')).toBe('99')
})
})
+4 -4
View File
@@ -27,7 +27,7 @@ const FUZZY_TOLERANCE = 0.01
/**
* Check if two amounts match exactly (within rounding)
*/
function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boolean {
export function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boolean {
// Round to 2 decimal places for comparison
const txRounded = Math.round(transactionAmount * 100) / 100
const invRounded = Math.round(invoiceTotal * 100) / 100
@@ -37,7 +37,7 @@ function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boo
/**
* Check if two amounts match within fuzzy tolerance (±1%)
*/
function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boolean {
export function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boolean {
if (invoiceTotal === 0) return false
const diff = Math.abs(transactionAmount - invoiceTotal)
// Cap fuzzy tolerance at 500 SEK to prevent false positives on large invoices
@@ -48,7 +48,7 @@ function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boo
/**
* Check if customer name appears in transaction counterparty
*/
function customerNameMatches(
export function customerNameMatches(
customerName: string | undefined,
transactionDescription: string,
merchantName: string | null
@@ -65,7 +65,7 @@ function customerNameMatches(
/**
* Calculate confidence score and match reason for an invoice match
*/
function calculateMatchScore(
export function calculateMatchScore(
transaction: Transaction,
invoice: Invoice & { customer?: Customer }
): { confidence: number; matchReason: string } {
@@ -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
})
})