* fix(invoices): return attachment filename in delivery history summaries The 20260723003000 hardening dropped attachment_filename from list_invoice_delivery_summaries, so the delivery history UI always fell back to the generic "faktura.pdf" label. Recreate the RPC with the filename included: it is derived from company name, customer name, invoice number, and date, all already visible to every company member, so the minimization boundary is unchanged. Addresses stay masked and message content, BCC, and checksums stay server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reconciliation): surface own-account transfer legs in match-to-voucher by default The second (incoming) leg of a transfer between two of the company's own bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog because the voucher counted as 'already matched' once its outgoing leg was linked, even though the incoming account's line had no settling transaction. Users read the empty default list as 'the app won't let me link this'. get_account_gl_lines_for_matching now counts links per settlement account: a transaction provably on another cash account no longer marks the voucher as matched for the requested account, so the unsettled transfer leg surfaces by default (and auto-selects on an exact match). Same-account N:1 stays behind the 'Visa aven matchade verifikationer' opt-in, and transactions without a resolvable cash account conservatively keep counting everywhere. get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile). Companion guard: mark_entry_as_opening_balance now refuses entries with linked bank transactions, since half-settled transfer vouchers became reachable in the reconciliation view's unmatched table where 'Mark som IB' renders; re-tagging one would strand its transaction against a movement- excluded entry. getReconciliationStatus counts unmatched GL lines with the account-scoped RPC so the status card agrees with the table. Fixes #1026 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of requests over 300ms. Target: p95 under 300ms. - requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of a second network getUser per request; getUser fallback keeps HS256 self-hosted and existing test mocks working; middleware still revocation-checks every /api request - resolve_active_company RPC (20260723161000): one round trip replaces 2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall back to the legacy query path - arsredovisning build-data: ~33 sequential round trips down to ~7, output byte-identical (snapshot-proven) - currency rate route: stop bypassing the exchange_rates cache (missing supabase arg caused an external Riksbanken call on every request) - document.get: parallelize row fetch, signed URL and audit event - list_company_accounts RPC (20260723170000): accounts list in one round trip instead of paging past PostgREST's 1000-row cap - vat-declaration route: drop a dead sequential company_settings query - get_kpi_report_aggregates RPC (20260723180000): KPI report's three full-period line scans collapsed into one aggregate call; dimension- filtered path unchanged - lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to warn, zero the eslint baseline ratchet All four gates green: lint 0 errors, 9163 tests, check:guards, build. Migrations applied idempotently to staging only; prod receives them via Supabase branching on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): resolve PR review findings across auth, VAT declaration, and IB retag - requireAuth getClaims fast path: pin iss (project URL) and aud ('authenticated'), log every fallback to getUser (ASVS V9.1 finding) - remove the ignored accountingMethod parameter from calculateVatDeclaration and the dead company_settings.accounting_method reads in xlsx/pdf/eskd routes; v1 API keeps accepting the query param but documents it as a no-op - close the mark_entry_as_opening_balance TOCTOU race with a transactions trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests; applied to staging and smoke-verified both directions - re-add the 42501 tenant guard to branch-local migration 20260723160000 (function body had silently reverted to the pre-20260619130100 definition) - document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt opening balance per BFNAR 2012:1 ch.29) - add KPI VAT-liability test covering reduced-rate output accounts 2621/2631 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard The re-added tenant guard carried the pre-20260703180000 raw NOT IN (SELECT user_company_ids()) pattern, which the null-safe-tenant-guards ratchet blocks. Staging re-synced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import {
|
|
fetchExchangeRate,
|
|
fetchMultipleRates,
|
|
fetchRateRange,
|
|
fetchLatestRate,
|
|
readCachedRate,
|
|
convertToSEK,
|
|
formatCurrencyAmount,
|
|
} from '../riksbanken'
|
|
|
|
// Mock logger to suppress output
|
|
vi.mock('@/lib/logger', () => ({
|
|
createLogger: () => ({
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
}),
|
|
}))
|
|
|
|
describe('fetchExchangeRate', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('returns rate 1 for SEK without fetching', async () => {
|
|
const fetchSpy = vi.spyOn(global, 'fetch')
|
|
const result = await fetchExchangeRate('SEK')
|
|
|
|
expect(result).toEqual({
|
|
currency: 'SEK',
|
|
rate: 1,
|
|
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
|
})
|
|
expect(fetchSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('parses EUR rate from API response', async () => {
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
|
|
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'))
|
|
|
|
expect(result).toEqual({
|
|
currency: 'EUR',
|
|
rate: 11.42,
|
|
date: '2025-01-15',
|
|
})
|
|
})
|
|
|
|
it('returns null on fetch error — never a hardcoded rate on the booking path', async () => {
|
|
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const result = await fetchExchangeRate('EUR')
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('tries fallback URL when primary returns 404 (no observation for the date)', async () => {
|
|
vi.spyOn(global, 'fetch')
|
|
.mockResolvedValueOnce(new Response('Not Found', { status: 404 }))
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([
|
|
{ value: '10.80', date: '2025-01-13' },
|
|
{ value: '10.85', date: '2025-01-14' },
|
|
]), { status: 200 })
|
|
)
|
|
|
|
const result = await fetchExchangeRate('USD', new Date('2025-01-15'))
|
|
|
|
expect(result).toEqual({
|
|
currency: 'USD',
|
|
rate: 10.85,
|
|
date: '2025-01-14',
|
|
})
|
|
})
|
|
|
|
it('retries once on 429 and does NOT fire the range-fallback request', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const fetchSpy = vi
|
|
.spyOn(global, 'fetch')
|
|
.mockResolvedValueOnce(
|
|
new Response('Too Many Requests', { status: 429, headers: { 'retry-after': '1' } })
|
|
)
|
|
.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }))
|
|
|
|
const promise = fetchExchangeRate('EUR', new Date('2025-01-15'))
|
|
await vi.runAllTimersAsync()
|
|
const result = await promise
|
|
|
|
// Two calls to the SAME single-day URL (initial + retry); the 7-day
|
|
// range endpoint is never hit — the old code fired it on 429 and
|
|
// doubled the load on an already rate-limited API.
|
|
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
|
const urls = fetchSpy.mock.calls.map((c) => String(c[0]))
|
|
expect(urls[0]).toBe(urls[1])
|
|
expect(urls[0]).toContain('/2025-01-15/2025-01-15')
|
|
expect(result).toBeNull()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('recovers when the 429 retry succeeds', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
vi.spyOn(global, 'fetch')
|
|
.mockResolvedValueOnce(new Response('Too Many Requests', { status: 429 }))
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
|
|
const promise = fetchExchangeRate('EUR', new Date('2025-01-15'))
|
|
await vi.runAllTimersAsync()
|
|
const result = await promise
|
|
|
|
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
describe('with the persistent exchange_rates cache (supabase passed)', () => {
|
|
type CacheRow = { rate: number; observation_date: string }
|
|
|
|
function makeSupabase(opts: {
|
|
exactHit?: CacheRow | null
|
|
latestHit?: CacheRow | null
|
|
onUpsert?: (row: Record<string, unknown>) => void
|
|
upsertError?: { code: string; message: string }
|
|
}) {
|
|
// .maybeSingle() terminates both the exact lookup and the latest
|
|
// lookup. Shared across from() calls so the once-queue holds: the
|
|
// first maybeSingle in a test is the exact lookup, subsequent ones
|
|
// are the latest-cached lookup.
|
|
const maybeSingle = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ data: opts.exactHit ?? null, error: null })
|
|
.mockResolvedValue({ data: opts.latestHit ?? null, error: null })
|
|
return {
|
|
from: vi.fn(() => ({
|
|
select: vi.fn().mockReturnThis(),
|
|
eq: vi.fn().mockReturnThis(),
|
|
lte: vi.fn().mockReturnThis(),
|
|
order: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockReturnThis(),
|
|
maybeSingle,
|
|
upsert: vi.fn((row: Record<string, unknown>) => {
|
|
opts.onUpsert?.(row)
|
|
return Promise.resolve({ data: null, error: opts.upsertError ?? null })
|
|
}),
|
|
})),
|
|
} as never
|
|
}
|
|
|
|
it('serves a cache hit without touching Riksbanken', async () => {
|
|
const fetchSpy = vi.spyOn(global, 'fetch')
|
|
const supabase = makeSupabase({ exactHit: { rate: 11.11, observation_date: '2025-01-15' } })
|
|
|
|
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
|
|
|
|
expect(result).toEqual({ currency: 'EUR', rate: 11.11, date: '2025-01-15' })
|
|
expect(fetchSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('writes a fetched rate into the cache', async () => {
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
let upserted: Record<string, unknown> | undefined
|
|
const supabase = makeSupabase({ onUpsert: (row) => (upserted = row) })
|
|
|
|
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
|
|
|
|
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
|
|
expect(upserted).toMatchObject({
|
|
currency: 'EUR',
|
|
rate_date: '2025-01-15',
|
|
rate: 11.42,
|
|
observation_date: '2025-01-15',
|
|
source: 'riksbanken',
|
|
})
|
|
})
|
|
|
|
it('still returns the fetched rate when the cache write is rejected by RLS', async () => {
|
|
// Since migration 20260710100000, INSERT on exchange_rates is
|
|
// service-role only. supabase-js reports the RLS rejection as a
|
|
// resolved { error }, not a throw: the rate must come back anyway.
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
const supabase = makeSupabase({
|
|
upsertError: { code: '42501', message: 'permission denied for table exchange_rates' },
|
|
})
|
|
|
|
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
|
|
|
|
expect(result).toEqual({ currency: 'EUR', rate: 11.42, date: '2025-01-15' })
|
|
})
|
|
|
|
it('falls back to the most recent cached observation when Riksbanken is down', async () => {
|
|
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
|
|
const supabase = makeSupabase({
|
|
exactHit: null,
|
|
latestHit: { rate: 11.38, observation_date: '2025-01-10' },
|
|
})
|
|
|
|
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
|
|
|
|
// An honest, dated observation — not a hardcoded 11.5.
|
|
expect(result).toEqual({ currency: 'EUR', rate: 11.38, date: '2025-01-10' })
|
|
})
|
|
|
|
it('returns null when Riksbanken is down and the cache is empty', async () => {
|
|
vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error'))
|
|
const supabase = makeSupabase({ exactHit: null, latestHit: null })
|
|
|
|
const result = await fetchExchangeRate('EUR', new Date('2025-01-15'), supabase)
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
})
|
|
|
|
// Exported so the currency.rate route can hit the exchange_rates cache in
|
|
// parallel with its sandbox guard instead of always going through
|
|
// fetchExchangeRate's sequential path.
|
|
describe('readCachedRate', () => {
|
|
it('maps an exact-date cache row to an ExchangeRate', async () => {
|
|
const maybeSingle = vi.fn().mockResolvedValue({
|
|
// rate arrives as a numeric string from PostgREST: must be Number()ed.
|
|
data: { rate: '11.25', observation_date: '2025-01-14' },
|
|
error: null,
|
|
})
|
|
const supabase = {
|
|
from: vi.fn(() => ({
|
|
select: vi.fn().mockReturnThis(),
|
|
eq: vi.fn().mockReturnThis(),
|
|
maybeSingle,
|
|
})),
|
|
} as never
|
|
|
|
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
|
|
|
|
expect(result).toEqual({ currency: 'EUR', rate: 11.25, date: '2025-01-14' })
|
|
})
|
|
|
|
it('returns null when the cache has no row for the date', async () => {
|
|
const supabase = {
|
|
from: vi.fn(() => ({
|
|
select: vi.fn().mockReturnThis(),
|
|
eq: vi.fn().mockReturnThis(),
|
|
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
|
|
})),
|
|
} as never
|
|
|
|
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('swallows thrown client errors and returns null (best-effort cache)', async () => {
|
|
const supabase = {
|
|
from: vi.fn(() => {
|
|
throw new Error('connection refused')
|
|
}),
|
|
} as never
|
|
|
|
const result = await readCachedRate(supabase, 'EUR', '2025-01-15')
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('fetchMultipleRates', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('returns Map with all requested currencies', async () => {
|
|
vi.spyOn(global, 'fetch')
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '10.50', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
|
|
const result = await fetchMultipleRates(['EUR', 'USD'])
|
|
|
|
expect(result.size).toBe(3) // EUR, USD, + always SEK
|
|
expect(result.get('SEK')!.rate).toBe(1)
|
|
expect(result.get('EUR')!.rate).toBe(11.42)
|
|
expect(result.get('USD')!.rate).toBe(10.50)
|
|
})
|
|
|
|
it('handles partial failure: returns fallback for failed currencies', async () => {
|
|
vi.spyOn(global, 'fetch')
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const result = await fetchMultipleRates(['EUR', 'GBP'])
|
|
|
|
expect(result.size).toBe(3)
|
|
expect(result.get('EUR')!.rate).toBe(11.42)
|
|
// GBP gets fallback rate (from the catch in fetchExchangeRate)
|
|
expect(result.get('GBP')).toBeDefined()
|
|
expect(result.get('GBP')!.rate).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('returns only SEK when given empty array', async () => {
|
|
const result = await fetchMultipleRates([])
|
|
expect(result.size).toBe(1)
|
|
expect(result.get('SEK')!.rate).toBe(1)
|
|
})
|
|
|
|
it('handles SEK in the input array without duplicate fetch', async () => {
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response(JSON.stringify([{ value: '11.42', date: '2025-01-15' }]), { status: 200 })
|
|
)
|
|
|
|
const result = await fetchMultipleRates(['SEK', 'EUR'])
|
|
|
|
expect(result.size).toBe(2)
|
|
expect(result.get('SEK')!.rate).toBe(1)
|
|
expect(result.get('EUR')!.rate).toBe(11.42)
|
|
})
|
|
})
|
|
|
|
describe('fetchRateRange', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('returns sorted array of rates', async () => {
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response(JSON.stringify([
|
|
{ value: '11.40', date: '2025-01-13' },
|
|
{ value: '11.45', date: '2025-01-15' },
|
|
{ value: '11.42', date: '2025-01-14' },
|
|
]), { status: 200 })
|
|
)
|
|
|
|
const result = await fetchRateRange(
|
|
'EUR',
|
|
new Date('2025-01-13'),
|
|
new Date('2025-01-15')
|
|
)
|
|
|
|
expect(result).toHaveLength(3)
|
|
expect(result[0].date).toBe('2025-01-13')
|
|
expect(result[1].date).toBe('2025-01-14')
|
|
expect(result[2].date).toBe('2025-01-15')
|
|
})
|
|
|
|
it('returns [rate:1] for SEK', async () => {
|
|
const fetchSpy = vi.spyOn(global, 'fetch')
|
|
const result = await fetchRateRange(
|
|
'SEK',
|
|
new Date('2025-01-13'),
|
|
new Date('2025-01-15')
|
|
)
|
|
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0].rate).toBe(1)
|
|
expect(fetchSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns empty array on error', async () => {
|
|
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const result = await fetchRateRange(
|
|
'EUR',
|
|
new Date('2025-01-13'),
|
|
new Date('2025-01-15')
|
|
)
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
|
|
it('returns empty array on non-200 response', async () => {
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response('Not Found', { status: 404 })
|
|
)
|
|
|
|
const result = await fetchRateRange(
|
|
'EUR',
|
|
new Date('2025-01-13'),
|
|
new Date('2025-01-15')
|
|
)
|
|
|
|
expect(result).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('fetchLatestRate', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('returns the last item from API response', async () => {
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response(JSON.stringify([
|
|
{ value: '11.40', date: '2025-01-13' },
|
|
{ value: '11.42', date: '2025-01-14' },
|
|
{ value: '11.45', date: '2025-01-15' },
|
|
]), { status: 200 })
|
|
)
|
|
|
|
const result = await fetchLatestRate('EUR')
|
|
|
|
expect(result).toEqual({
|
|
currency: 'EUR',
|
|
rate: 11.45,
|
|
date: '2025-01-15',
|
|
})
|
|
})
|
|
|
|
it('returns rate 1 for SEK', async () => {
|
|
const fetchSpy = vi.spyOn(global, 'fetch')
|
|
const result = await fetchLatestRate('SEK')
|
|
|
|
expect(result).toEqual({
|
|
currency: 'SEK',
|
|
rate: 1,
|
|
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
|
})
|
|
expect(fetchSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns fallback on error', async () => {
|
|
vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const result = await fetchLatestRate('EUR')
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result!.currency).toBe('EUR')
|
|
expect(result!.rate).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('returns null on empty API response', async () => {
|
|
vi.spyOn(global, 'fetch').mockResolvedValueOnce(
|
|
new Response(JSON.stringify([]), { status: 200 })
|
|
)
|
|
|
|
const result = await fetchLatestRate('EUR')
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('convertToSEK', () => {
|
|
it('converts amount correctly', () => {
|
|
expect(convertToSEK(100, 11.42)).toBe(1142)
|
|
})
|
|
|
|
it('handles zero amount', () => {
|
|
expect(convertToSEK(0, 11.42)).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('formatCurrencyAmount', () => {
|
|
it('formats EUR with symbol prefix', () => {
|
|
const result = formatCurrencyAmount(1234.56, 'EUR')
|
|
// sv-SE uses non-breaking space as thousands separator
|
|
expect(result).toContain('€')
|
|
expect(result).toContain('1')
|
|
expect(result).toContain('234')
|
|
})
|
|
|
|
it('formats SEK with currency suffix', () => {
|
|
const result = formatCurrencyAmount(1234.56, 'SEK')
|
|
expect(result).toContain('SEK')
|
|
})
|
|
|
|
it('formats NOK with currency suffix', () => {
|
|
const result = formatCurrencyAmount(100, 'NOK')
|
|
expect(result).toContain('NOK')
|
|
})
|
|
})
|